001package gudusoft.gsqlparser.pp2.token;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.ETokenType;
005import gudusoft.gsqlparser.TGSqlParser;
006import gudusoft.gsqlparser.TSourceToken;
007import gudusoft.gsqlparser.TSourceTokenList;
008import gudusoft.gsqlparser.pp.para.styleenums.TCaseOption;
009import gudusoft.gsqlparser.pp2.Pp2FormatOptions;
010
011import java.util.ArrayList;
012import java.util.Arrays;
013import java.util.List;
014import java.util.Locale;
015
016/**
017 * Compares two SQL inputs for token-level equivalence modulo formatting.
018 *
019 * <p>The contract — verified by 12 hand-built cases in
020 * {@code TokenEquivalenceTest} — is that two SQL strings are equivalent
021 * when:
022 *
023 * <ul>
024 *   <li>Their <i>solid + comment</i> token sequences (in order) are equal
025 *       character-by-character.</li>
026 *   <li>Whitespace differences (spaces, tabs, linebreaks, indent) are
027 *       ignored.</li>
028 *   <li>Comment position may shift between the two streams — the comments
029 *       must appear in the same relative order, but their surrounding
030 *       whitespace does not matter.</li>
031 *   <li>Case differences on keywords and unquoted identifiers are tolerated
032 *       when {@code caseInsensitive=true}. Quoted identifiers, string
033 *       literals, and operator/punctuation tokens are always compared
034 *       byte-exact because case changes there alter semantics.</li>
035 * </ul>
036 *
037 * <p>This helper is the safety net for several downstream slices:
038 * <ul>
039 *   <li><b>S13</b> ({@code GuardedAstDelegate}) — verifies that
040 *       {@code FormatterFactory.pp()}'s output preserves every solid
041 *       input token.</li>
042 *   <li><b>S32</b> golden tests — sanity-checks goldens against their
043 *       inputs.</li>
044 *   <li><b>S33</b> overlay annotator — exercises the equivalence helper
045 *       on real corpus inputs.</li>
046 *   <li><b>S34</b> content-preservation — the strongest property test on
047 *       the 50-SQL corpus.</li>
048 *   <li><b>S35</b> per-vendor smoke — confirms every vendor preserves
049 *       content through the pp2 pipeline.</li>
050 * </ul>
051 *
052 * <p>The helper deliberately operates on raw SQL strings (not
053 * {@link Pp2TokenStream} instances) because the comparison is between an
054 * arbitrary input and an arbitrary output — there is no single pp2 stream
055 * shared between the two. The internal tokenization uses the GSP lexer
056 * directly with the same overlap-skip discipline as
057 * {@link Pp2TokenStreamBuilder}.
058 *
059 * <p>Changes the user explicitly asked for (variable or delimited-name case,
060 * compact mode's comment rewrite, comment removal) are accepted in their exact
061 * expected form when a {@link TransformPolicy} allows them; see that class.
062 * {@code Pp2FormatOptions.transformPolicy} carries it to pp2's guards.
063 *
064 * <p>Plan reference: §7.3/S10, §7.4/S10, Q8.
065 */
066public final class TokenEquivalence {
067
068    private TokenEquivalence() {
069        // utility class
070    }
071
072    /**
073     * Case-insensitive comparison using the default Oracle dialect. The
074     * "case insensitive" mode tolerates case differences on keywords and
075     * unquoted identifiers; literals and quoted identifiers are always
076     * byte-exact.
077     */
078    public static boolean equalsModuloFormatting(String left, String right,
079                                                 Pp2FormatOptions opts) {
080        return equalsModuloFormatting(left, right, opts,
081            EDbVendor.dbvoracle, true);
082    }
083
084    /**
085     * Full-control comparison.
086     *
087     * @param left  first SQL string; must not be null
088     * @param right second SQL string; must not be null
089     * @param opts  pp2 options; its {@code transformPolicy} allows the
090     *              requested transformations; may be null (none allowed)
091     * @param vendor dialect to tokenize with; must not be null
092     * @param caseInsensitive when {@code true}, keyword and unquoted
093     *                        identifier comparisons are case-insensitive;
094     *                        when {@code false}, all comparisons are
095     *                        byte-exact on text
096     * @return {@code true} iff the two strings have the same token
097     *         sequence under the rules described in the class Javadoc
098     * @throws NullPointerException if {@code left}, {@code right}, or
099     *         {@code vendor} is null
100     */
101    public static boolean equalsModuloFormatting(String left, String right,
102                                                 Pp2FormatOptions opts,
103                                                 EDbVendor vendor,
104                                                 boolean caseInsensitive) {
105        return equalsModuloFormatting(left, right, opts, vendor, caseInsensitive, false);
106    }
107
108    /**
109     * {@link #equalsModuloFormatting(String, String, Pp2FormatOptions, EDbVendor, boolean)} for two
110     * texts that follow other statements: {@code stringsAtStart} is true when one of those turned
111     * the quoted-identifier option off, so that every double-quoted text of both may be a string
112     * (see {@link DoubleQuotedStrings}).
113     */
114    public static boolean equalsModuloFormatting(String left, String right,
115                                                 Pp2FormatOptions opts,
116                                                 EDbVendor vendor,
117                                                 boolean caseInsensitive,
118                                                 boolean stringsAtStart) {
119        if (left == null) throw new NullPointerException("left");
120        if (right == null) throw new NullPointerException("right");
121        if (vendor == null) throw new NullPointerException("vendor");
122        TransformPolicy policy = opts == null || opts.transformPolicy == null
123            ? TransformPolicy.NONE : opts.transformPolicy;
124        SplitTokens a = tokenizeSplit(left, vendor, stringsAtStart);
125        SplitTokens b = tokenizeSplit(right, vendor, stringsAtStart);
126        // Solid (non-comment) tokens must match in order.
127        if (!compareSolids(a.solids, b.solids, caseInsensitive, policy, vendor)) {
128            return false;
129        }
130        // Comments must appear in the same relative order, and each
131        // comment's text must be byte-exact. Position may shift relative
132        // to the solid tokens (plan §16 Q8).
133        return compareComments(a.comments, b.comments, policy);
134    }
135
136    /**
137     * The solid-token half of {@link #equalsModuloFormatting(String, String,
138     * Pp2FormatOptions, EDbVendor, boolean)}: every non-comment token of
139     * {@code left} appears in {@code right} once, in order, with the same
140     * type and (case-insensitively for keywords and unquoted identifiers
141     * when {@code caseInsensitive}) the same text. Exposed so that callers
142     * reporting the two invariants separately (the format worker) apply the
143     * same policy as the combined check.
144     */
145    public static boolean solidTokensEquivalent(String left, String right,
146                                                EDbVendor vendor,
147                                                boolean caseInsensitive) {
148        return solidTokensEquivalent(left, right, vendor, caseInsensitive, TransformPolicy.NONE);
149    }
150
151    /**
152     * {@link #solidTokensEquivalent(String, String, EDbVendor, boolean)} that
153     * also accepts the solid-token transformations {@code policy} allows.
154     */
155    public static boolean solidTokensEquivalent(String left, String right,
156                                                EDbVendor vendor,
157                                                boolean caseInsensitive,
158                                                TransformPolicy policy) {
159        if (left == null) throw new NullPointerException("left");
160        if (right == null) throw new NullPointerException("right");
161        if (vendor == null) throw new NullPointerException("vendor");
162        return compareSolids(tokenizeSplit(left, vendor, false).solids,
163            tokenizeSplit(right, vendor, false).solids, caseInsensitive,
164            policy == null ? TransformPolicy.NONE : policy, vendor);
165    }
166
167    /**
168     * The comment half of {@link #equalsModuloFormatting(String, String,
169     * Pp2FormatOptions, EDbVendor, boolean)}: the comments of {@code left}
170     * appear in {@code right} in the same relative order with byte-exact
171     * text; their position relative to the solid tokens may differ.
172     */
173    public static boolean commentsPreserved(String left, String right,
174                                            EDbVendor vendor) {
175        return commentsPreserved(left, right, vendor, TransformPolicy.NONE);
176    }
177
178    /**
179     * {@link #commentsPreserved(String, String, EDbVendor)} that also accepts
180     * the comment transformations {@code policy} allows.
181     */
182    public static boolean commentsPreserved(String left, String right,
183                                            EDbVendor vendor,
184                                            TransformPolicy policy) {
185        if (left == null) throw new NullPointerException("left");
186        if (right == null) throw new NullPointerException("right");
187        if (vendor == null) throw new NullPointerException("vendor");
188        return compareComments(tokenizeSplit(left, vendor, false).comments,
189            tokenizeSplit(right, vendor, false).comments,
190            policy == null ? TransformPolicy.NONE : policy);
191    }
192
193    private static boolean compareSolids(List<TokenInfo> a, List<TokenInfo> b,
194                                         boolean caseInsensitive, TransformPolicy policy,
195                                         EDbVendor vendor) {
196        if (a.size() != b.size()) return false;
197        for (int i = 0; i < a.size(); i++) {
198            TokenInfo x = a.get(i);
199            TokenInfo y = b.get(i);
200            if (tokensEquivalent(x, y, caseInsensitive)) continue;
201            if (!policy.isNone() && transformedAsRequested(x, y, policy, vendor)) continue;
202            return false;
203        }
204        return true;
205    }
206
207    /** Dialects in which a bracketed token is a delimited name. */
208    private static final java.util.Set<String> BRACKETS_ARE_NAMES = new java.util.HashSet<String>(Arrays.asList(
209        "dbvmssql", "dbvazuresql", "dbvsybase", "dbvsybasease", "dbvsybaseiq", "dbvsqlanywhere",
210        "dbvaccess", "dbvsqlite"));
211
212    /** Solid-token transformations: the output token is exactly the expected one. */
213    private static boolean transformedAsRequested(TokenInfo x, TokenInfo y, TransformPolicy policy,
214                                                  EDbVendor vendor) {
215        if (x.type != y.type) return false;
216        switch (x.type) {
217            case ttsqlvar:
218                // The classic formatter's variable case covers @variables only.
219                return policy.getVariableCase() != null
220                    && y.text.equals(variableWithCase(x.text, policy.getVariableCase()));
221            case ttdqstring:
222                // a double-quoted text that may be a string keeps its case (DoubleQuotedStrings)
223                return policy.getQuotedIdentifierCase() != null
224                    && !x.mayBeString && !y.mayBeString
225                    && x.text.startsWith("\"") && x.text.endsWith("\"")
226                    && delimitedWithCase(x.text, policy.getQuotedIdentifierCase()).contains(y.text);
227            case ttdbstring:
228                return policy.getQuotedIdentifierCase() != null
229                    && BRACKETS_ARE_NAMES.contains(vendor.name())
230                    && x.text.startsWith("[") && x.text.endsWith("]")
231                    && delimitedWithCase(x.text, policy.getQuotedIdentifierCase()).contains(y.text);
232            default:
233                // {...} is an ODBC escape, never a name; everything else is
234                // outside every policy.
235                return false;
236        }
237    }
238
239    /**
240     * The variable as the classic formatter cases it: the first character (the
241     * {@code @} sigil) kept, the case applied to the rest; InitCap lowercases the
242     * rest and then capitalizes its first character only if it is an ASCII
243     * letter ({@code @@ROWCOUNT} becomes {@code @@rowcount}).
244     */
245    static String variableWithCase(String text, TCaseOption c) {
246        if (text.isEmpty()) return text;
247        String prefix = text.substring(0, 1);
248        String rest = text.substring(1);
249        switch (c) {
250            case CoUppercase:
251                return prefix + rest.toUpperCase(Locale.ROOT);
252            case CoLowercase:
253                return prefix + rest.toLowerCase(Locale.ROOT);
254            case CoInitCap: {
255                String lower = rest.toLowerCase(Locale.ROOT);
256                if (!lower.isEmpty() && lower.charAt(0) >= 'a' && lower.charAt(0) <= 'z') {
257                    lower = Character.toUpperCase(lower.charAt(0)) + lower.substring(1);
258                }
259                return prefix + lower;
260            }
261            default:
262                return text;
263        }
264    }
265
266    /**
267     * The delimited name with {@code c} applied inside its delimiters. For
268     * InitCap the two engines differ, so both forms are accepted: the classic
269     * formatter capitalizes the first letter inside the delimiters; GSP's
270     * {@code CapitalisationProcessor} capitalizes the first character of the
271     * token (the delimiter) and each character after a space.
272     */
273    static List<String> delimitedWithCase(String text, TCaseOption c) {
274        if (text.length() < 2) return new ArrayList<String>();
275        String inner = text.substring(1, text.length() - 1);
276        String classic = text.charAt(0) + withCase(inner, c) + text.charAt(text.length() - 1);
277        if (c != TCaseOption.CoInitCap) return Arrays.asList(classic);
278        StringBuilder gsp = new StringBuilder(text.length());
279        char prev = ' ';
280        for (char ch : text.toCharArray()) {
281            gsp.append(ch != ' ' && prev == ' ' ? Character.toUpperCase(ch) : Character.toLowerCase(ch));
282            prev = ch;
283        }
284        return Arrays.asList(classic, gsp.toString());
285    }
286
287    private static String withCase(String s, TCaseOption c) {
288        switch (c) {
289            case CoUppercase:
290                return s.toUpperCase(Locale.ROOT);
291            case CoLowercase:
292                return s.toLowerCase(Locale.ROOT);
293            case CoInitCap:
294                return s.isEmpty() ? s
295                    : s.substring(0, 1).toUpperCase(Locale.ROOT) + s.substring(1).toLowerCase(Locale.ROOT);
296            default:
297                return s;
298        }
299    }
300
301    /**
302     * Comments that are code, not commentary: optimizer hints ({@code /*+},
303     * Oracle {@code --+}) and MySQL executable comments ({@code /*!}). No
304     * policy lets them change or disappear.
305     */
306    private static boolean isProtectedComment(TokenInfo t) {
307        return t.text.startsWith("/*+") || t.text.startsWith("--+") || t.text.startsWith("/*!");
308    }
309
310    /**
311     * Comments: byte-exact and in order, unless the policy allows compact
312     * mode's rewrite of ordinary {@code --} comments or the removal of every
313     * comment that is not code.
314     */
315    private static boolean compareComments(List<TokenInfo> a, List<TokenInfo> b, TransformPolicy policy) {
316        if (compareSequences(a, b, false)) return true;
317        if (policy.isRemoveComments()) {
318            List<TokenInfo> kept = new ArrayList<TokenInfo>();
319            for (TokenInfo t : a) {
320                if (isProtectedComment(t)) kept.add(t);
321            }
322            if (compareSequences(kept, b, false)) return true;
323        }
324        if (policy.isCompactComments() && a.size() == b.size()) {
325            for (int i = 0; i < a.size(); i++) {
326                TokenInfo x = a.get(i);
327                TokenInfo y = b.get(i);
328                if (x.type == y.type && x.text.equals(y.text)) continue;
329                if (!compactRewrite(x, y)) return false;
330            }
331            return true;
332        }
333        return false;
334    }
335
336    /**
337     * Whether {@code y} is compact mode's rewrite of the line comment {@code x}:
338     * {@code -- body} as {@code /* -- body *}{@code /} or {@code /* -- body*}{@code /},
339     * with the body as written or without its trailing blanks. Only ordinary
340     * {@code --} comments qualify: not hints ({@code --+}), not {@code #} or
341     * {@code //} comments, and not a body that would close the block early.
342     */
343    private static boolean compactRewrite(TokenInfo x, TokenInfo y) {
344        if (x.type != ETokenType.ttsimplecomment || y.type != ETokenType.ttbracketedcomment) return false;
345        if (!x.text.startsWith("--") || isProtectedComment(x) || x.text.contains("*/")) return false;
346        String body = x.text;
347        int end = body.length();
348        while (end > 0 && (body.charAt(end - 1) == '\n' || body.charAt(end - 1) == '\r')) end--;
349        body = body.substring(0, end);
350        for (String b : Arrays.asList(body, rtrim(body))) {
351            if (y.text.equals("/* " + b + " */") || y.text.equals("/* " + b + "*/")) return true;
352        }
353        return false;
354    }
355
356    private static String rtrim(String s) {
357        int end = s.length();
358        while (end > 0 && Character.isWhitespace(s.charAt(end - 1))) end--;
359        return s.substring(0, end);
360    }
361
362    private static boolean compareSequences(List<TokenInfo> a, List<TokenInfo> b,
363                                            boolean caseInsensitive) {
364        if (a.size() != b.size()) return false;
365        for (int i = 0; i < a.size(); i++) {
366            if (!tokensEquivalent(a.get(i), b.get(i), caseInsensitive)) {
367                return false;
368            }
369        }
370        return true;
371    }
372
373    /** Lightweight value carrier for comparable tokens. */
374    private static final class TokenInfo {
375        final ETokenType type;
376        final String text;
377        /** A double-quoted text that may be a string rather than a name. */
378        final boolean mayBeString;
379        TokenInfo(ETokenType type, String text, boolean mayBeString) {
380            this.type = type;
381            this.text = text;
382            this.mayBeString = mayBeString;
383        }
384    }
385
386    /** Tokenization result split into solid tokens and comments. */
387    private static final class SplitTokens {
388        final List<TokenInfo> solids;
389        final List<TokenInfo> comments;
390        SplitTokens(List<TokenInfo> solids, List<TokenInfo> comments) {
391            this.solids = solids;
392            this.comments = comments;
393        }
394    }
395
396    private static SplitTokens tokenizeSplit(String sql, EDbVendor vendor, boolean stringsAtStart) {
397        TGSqlParser parser = new TGSqlParser(vendor);
398        parser.sqltext = sql;
399        parser.tokenizeSqltext();
400        TSourceTokenList list = parser.getSourcetokenlist();
401        List<TokenInfo> solids = new ArrayList<TokenInfo>(list.size());
402        List<TokenInfo> comments = new ArrayList<TokenInfo>();
403        long lastEmittedEnd = -1L;
404        // the lexer types a double-quoted text as a delimited name even where it may be a string
405        long stringsAfter = stringsAtStart ? DoubleQuotedStrings.ALL : DoubleQuotedStrings.stringsAfter(vendor, list);
406        for (int i = 0; i < list.size(); i++) {
407            TSourceToken t = list.get(i);
408            if (t == null) continue;
409            String text = t.toString();
410            if (text == null || text.isEmpty()) continue;
411            // Skip phantom shadowed tokens (GSP "${name}" overlap quirk —
412            // see TokenCoverage Javadoc and slice S9 resume doc).
413            if (TokenCoverage.isFullyShadowed(t, lastEmittedEnd)) {
414                continue;
415            }
416            ETokenType type = t.tokentype;
417            // Skip whitespace entirely.
418            if (Pp2TokenStreamBuilder.isFoldable(type)) {
419                continue;
420            }
421            TokenInfo info = new TokenInfo(type, text,
422                type == ETokenType.ttdqstring && t.offset > stringsAfter);
423            if (isCommentType(type)) {
424                comments.add(info);
425            } else {
426                solids.add(info);
427            }
428            lastEmittedEnd = TokenCoverage.endOffset(t);
429        }
430        return new SplitTokens(solids, comments);
431    }
432
433    private static boolean isCommentType(ETokenType type) {
434        if (type == null) return false;
435        switch (type) {
436            case ttsimplecomment:
437            case ttbracketedcomment:
438            case ttCPPComment:
439                return true;
440            default:
441                return false;
442        }
443    }
444
445    private static boolean tokensEquivalent(TokenInfo a, TokenInfo b,
446                                            boolean caseInsensitive) {
447        if (a.type != b.type) {
448            // The Oracle lexer classifies a word at column 0 as a SQL*Plus
449            // command (EXIT, SET ...) and the same word, once indented by the
450            // formatter, as the PL/SQL keyword it really is: same text, only
451            // the lexer's guess changed (sqllog corpus, oracle/020).
452            boolean sqlplusVsKeyword = (a.type == ETokenType.ttsqlpluscmd && b.type == ETokenType.ttkeyword)
453                || (a.type == ETokenType.ttkeyword && b.type == ETokenType.ttsqlpluscmd);
454            return sqlplusVsKeyword && a.text.equalsIgnoreCase(b.text);
455        }
456        if (caseInsensitive && typeAllowsCaseChange(a.type)) {
457            return a.text.equalsIgnoreCase(b.text);
458        }
459        return a.text.equals(b.text);
460    }
461
462    /**
463     * Token types whose case may legitimately change between input and
464     * output: keywords and unquoted identifiers. Everything else
465     * (literals, quoted identifiers, comments, punctuation, operators)
466     * is compared byte-exact even in case-insensitive mode.
467     */
468    private static boolean typeAllowsCaseChange(ETokenType type) {
469        if (type == null) return false;
470        switch (type) {
471            case ttkeyword:
472            case ttnonreservedkeyword:
473            case ttidentifier:
474                return true;
475            default:
476                return false;
477        }
478    }
479}