001package gudusoft.gsqlparser.pp2.layout.rules;
002
003import gudusoft.gsqlparser.ETokenType;
004import gudusoft.gsqlparser.pp2.dialect.DialectStrategy;
005import gudusoft.gsqlparser.pp2.island.ClausePart;
006import gudusoft.gsqlparser.pp2.island.ClauseScopeAnnotator.ClauseScopeResult;
007import gudusoft.gsqlparser.pp2.layout.LayoutContext;
008import gudusoft.gsqlparser.pp2.layout.LayoutPriorities;
009import gudusoft.gsqlparser.pp2.layout.LayoutRule;
010import gudusoft.gsqlparser.pp2.token.Pp2Token;
011import gudusoft.gsqlparser.pp2.token.Pp2TokenStream;
012import gudusoft.gsqlparser.pp2.token.TokenRole;
013
014import java.util.Collections;
015import java.util.EnumSet;
016import java.util.Locale;
017import java.util.Set;
018
019/**
020 * Requests a linebreak before each major clause keyword so a query lays out one
021 * clause per line: {@code FROM}, {@code WHERE}, {@code GROUP BY}, {@code HAVING},
022 * {@code ORDER BY}.
023 *
024 * <p>It breaks before a token that carries the {@link TokenRole#KEYWORD_CLAUSE}
025 * role (set by S21) whose {@link ClausePart} is one of the targeted clauses. It
026 * does <b>not</b> break before JOIN keywords (S26 owns JOIN layout) nor before a
027 * clause keyword that immediately follows a master keyword (so {@code DELETE FROM}
028 * / {@code INSERT INTO} stay on one line). {@code SELECT} master layout and set
029 * operators are handled by {@link SetOperatorRules}.
030 *
031 * <p>Priority {@link LayoutPriorities#CLAUSE_LINEBREAK}. Requires the
032 * {@link ClauseScopeResult} to be attached to the context. Iterative; read-only
033 * over tokens.
034 *
035 * <p>Plan reference: §7.3/S25, §7.4/S25.
036 */
037public final class ClauseLinebreakRules implements LayoutRule {
038
039    private static final Set<ClausePart> BREAK_BEFORE = EnumSet.of(
040        ClausePart.FROM, ClausePart.WHERE, ClausePart.GROUP_BY,
041        ClausePart.HAVING, ClausePart.ORDER_BY);
042
043    @Override
044    public int priority() { return LayoutPriorities.CLAUSE_LINEBREAK; }
045
046    @Override
047    public String name() { return "ClauseLinebreakRules"; }
048
049    @Override
050    public void apply(LayoutContext context) {
051        ClauseScopeResult clause = context.getClauseScope();
052        if (clause == null) return; // nothing to do without clause analysis
053        Set<String> dialectClauseKeywords = dialectClauseKeywords(context.getDialect());
054        Pp2TokenStream stream = context.getStream();
055        int n = Math.min(stream.size(), clause.size());
056        for (int i = 1; i < n; i++) {
057            Pp2Token t = stream.get(i);
058            boolean universalClause = t.hasRole(TokenRole.KEYWORD_CLAUSE)
059                && BREAK_BEFORE.contains(clause.partAt(i));
060            boolean dialectClause = startsDialectClausePhrase(stream, i, dialectClauseKeywords);
061            if (!universalClause && !dialectClause) continue;
062            // Keep "DELETE FROM" / "<master> <clause>" together, even with a
063            // comment between the master and the clause keyword.
064            int prev = prevSolid(stream, i);
065            if (prev >= 0 && stream.get(prev).hasRole(TokenRole.KEYWORD_MASTER)) continue;
066            context.requestLinebreaksBefore(i, 1);
067            // Start the clause keyword at column 0 (override the spacing rule's
068            // inter-word blank); indentation, if any, is added by S28.
069            context.requestBlanksBefore(i, 0);
070        }
071    }
072
073    private static Set<String> dialectClauseKeywords(DialectStrategy dialect) {
074        return dialect == null ? Collections.<String>emptySet()
075            : dialect.additionalClauseKeywords();
076    }
077
078    /** True if a dialect clause-start phrase begins (matches consecutively) at token i. */
079    private static boolean startsDialectClausePhrase(Pp2TokenStream stream, int i,
080                                                     Set<String> phrases) {
081        if (phrases.isEmpty()) return false;
082        if (stream.get(i).getSourceToken().tokentype != ETokenType.ttkeyword) return false;
083        for (String phrase : phrases) {
084            if (matchesPhraseAt(stream, i, phrase.split("\\s+"))) return true;
085        }
086        return false;
087    }
088
089    /** True if {@code words} match consecutive solid tokens starting at {@code start}. */
090    private static boolean matchesPhraseAt(Pp2TokenStream stream, int start, String[] words) {
091        int idx = start;
092        for (int k = 0; k < words.length; k++) {
093            if (k > 0) idx = nextSolid(stream, idx);
094            if (idx < 0 || idx >= stream.size()) return false;
095            String text = stream.get(idx).getText();
096            if (text == null || !text.equalsIgnoreCase(words[k])) return false;
097        }
098        return true;
099    }
100
101    private static int nextSolid(Pp2TokenStream stream, int i) {
102        for (int j = i + 1; j < stream.size(); j++) {
103            ETokenType type = stream.get(j).getSourceToken().tokentype;
104            if (type == ETokenType.ttsimplecomment
105                || type == ETokenType.ttbracketedcomment
106                || type == ETokenType.ttCPPComment) {
107                continue;
108            }
109            return j;
110        }
111        return -1;
112    }
113
114    /** Index of the first non-comment token before {@code i}, or -1. */
115    private static int prevSolid(Pp2TokenStream stream, int i) {
116        for (int j = i - 1; j >= 0; j--) {
117            ETokenType type = stream.get(j).getSourceToken().tokentype;
118            if (type == ETokenType.ttsimplecomment
119                || type == ETokenType.ttbracketedcomment
120                || type == ETokenType.ttCPPComment) {
121                continue;
122            }
123            return j;
124        }
125        return -1;
126    }
127}
128