001package gudusoft.gsqlparser.pp2.region;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.ETokenType;
005import gudusoft.gsqlparser.TSourceToken;
006import gudusoft.gsqlparser.pp2.token.Pp2Token;
007import gudusoft.gsqlparser.pp2.token.Pp2TokenStream;
008import gudusoft.gsqlparser.pp2.token.TokenRole;
009
010import java.util.ArrayList;
011import java.util.HashSet;
012import java.util.List;
013import java.util.Locale;
014import java.util.Set;
015
016/**
017 * Walks a {@link Pp2TokenStream} and produces a list of
018 * {@link StatementRange}s — one per statement in the source.
019 *
020 * <p>Terminators recognized:
021 * <ul>
022 *   <li>{@code ;} — standard SQL statement separator. Every dialect.</li>
023 *   <li>{@code GO} (keyword) — SQL Server / Sybase batch separator,
024 *       recognized only when the vendor is in the T-SQL family. It is not
025 *       part of the statement it ends: the range stops before it, and the
026 *       separator, with the count of SQL Server's {@code GO n}, is left to
027 *       the gap between ranges, which the assembler emits verbatim. Every
028 *       renderer then works on a statement alone, and a region's parse is
029 *       one statement.</li>
030 * </ul>
031 *
032 * <h2>What this detector handles correctly</h2>
033 *
034 * <ul>
035 *   <li><b>Strings containing {@code ;}</b> — string literals tokenize as
036 *       a single {@code ttsqstring} token; the inner {@code ;} never
037 *       appears as a {@code ttsemicolon} so no special case is needed.</li>
038 *   <li><b>PL/SQL block-internal {@code ;}</b> — a {@code BEGIN ... END;}
039 *       block has internal semicolons at depth ≥ 1; only depth-0
040 *       semicolons split statements. The detector tracks BEGIN/END depth
041 *       and treats {@code END IF / LOOP / WHILE / REPEAT / FOR} as
042 *       inner-construct ends that do not pop the block-depth counter
043 *       (those keywords don't push depth, so there's nothing to pop).</li>
044 *   <li><b>CASE expressions inside a {@code BEGIN} block</b> —
045 *       {@code CASE} pushes its own {@code caseDepth} counter so its
046 *       unqualified {@code END} doesn't accidentally pop the surrounding
047 *       block-depth. {@code END CASE} pops {@code caseDepth} (and the
048 *       trailing {@code CASE} keyword is consumed as a qualifier, not as
049 *       a new {@code CASE} push).</li>
050 *   <li><b>NO_FORMAT zones</b> — tokens flagged with
051 *       {@link TokenRole#NO_FORMAT_ZONE} (by S9's
052 *       {@code ProtectedZoneDetector}) are treated as opaque: depth
053 *       is not adjusted, terminators are not consumed.</li>
054 *   <li><b>Truncated final statement</b> — a stream that ends without a
055 *       terminator yields a final range with
056 *       {@link StatementRange.Terminator#NONE}.</li>
057 * </ul>
058 *
059 * <h2>What this detector deliberately does NOT handle</h2>
060 *
061 * <ul>
062 *   <li>Standalone Oracle {@code DECLARE ... BEGIN ... END;} blocks where
063 *       the {@code DECLARE} clause has its own {@code ;} at depth 0 — the
064 *       detector will split there. S22's island recognizer can refine.
065 *       PL/SQL in practice almost always wraps in {@code BEGIN}.</li>
066 *   <li>Oracle SQL*Plus {@code /} on its own line as a block terminator —
067 *       not in the plan §7.3/S11 scope. The {@code /} appears as a normal
068 *       token; the next statement starts after it.</li>
069 * </ul>
070 *
071 * <p>Plan reference: §7.3/S11, §7.4/S11.
072 */
073public final class StatementBoundaryDetector {
074
075    /**
076     * Inner-construct keywords that may follow {@code END}. Each takes a
077     * different code path:
078     *
079     * <ul>
080     *   <li>{@code IF / LOOP / WHILE / REPEAT / FOR} — these constructs
081     *       don't push any depth counter (their internal scope is left to
082     *       S22's island recognizer), so the matching {@code END FOO} is
083     *       a no-op for the depth model.</li>
084     *   <li>{@code CASE} — paired with a {@code CASE} keyword earlier that
085     *       pushed {@code caseDepth}. {@code END CASE} pops
086     *       {@code caseDepth}; the trailing {@code CASE} keyword is
087     *       consumed as a qualifier (does NOT push again).</li>
088     * </ul>
089     */
090    private static final Set<String> END_QUALIFIERS;
091    static {
092        Set<String> q = new HashSet<String>();
093        q.add("IF");
094        q.add("LOOP");
095        q.add("CASE");
096        q.add("WHILE");
097        q.add("REPEAT");
098        q.add("FOR");
099        END_QUALIFIERS = q;
100    }
101
102    /**
103     * Detect statement boundaries.
104     *
105     * @param stream non-null token stream (typically already annotated by
106     *               S9's {@code ProtectedZoneDetector})
107     * @param vendor non-null vendor identity; used to decide whether
108     *               {@code GO} is a terminator
109     * @return an immutable list of {@link StatementRange}s in source order
110     * @throws NullPointerException if either argument is null
111     */
112    public List<StatementRange> detect(Pp2TokenStream stream, EDbVendor vendor) {
113        if (stream == null) throw new NullPointerException("stream");
114        if (vendor == null) throw new NullPointerException("vendor");
115        final boolean goIsTerminator = isTsqlFamily(vendor);
116        final int n = stream.size();
117        List<StatementRange> out = new ArrayList<StatementRange>();
118        int rangeStart = 0;
119        int blockDepth = 0;
120        int caseDepth = 0;
121        // When an "END CASE" sequence pops caseDepth, the following CASE
122        // keyword is the qualifier — not a new CASE statement. Skip its
123        // push effect.
124        boolean swallowNextCaseAsQualifier = false;
125
126        for (int i = 0; i < n; i++) {
127            Pp2Token wrapped = stream.get(i);
128            // Skip tokens inside a NO_FORMAT zone — opaque, no depth or
129            // terminator effects.
130            if (wrapped.hasRole(TokenRole.NO_FORMAT_ZONE)) {
131                continue;
132            }
133            TSourceToken t = wrapped.getSourceToken();
134            ETokenType type = t.tokentype;
135            String text = wrapped.getText();
136
137            if (type == ETokenType.ttkeyword && text != null) {
138                String upper = text.toUpperCase(Locale.ROOT);
139                if ("BEGIN".equals(upper)) {
140                    blockDepth++;
141                } else if ("CASE".equals(upper)) {
142                    if (swallowNextCaseAsQualifier) {
143                        swallowNextCaseAsQualifier = false;
144                    } else {
145                        caseDepth++;
146                    }
147                } else if ("END".equals(upper)) {
148                    String nextQual = nextSolidKeywordText(stream, i);
149                    String nextUpper = nextQual == null
150                        ? null
151                        : nextQual.toUpperCase(Locale.ROOT);
152                    if (nextUpper != null && END_QUALIFIERS.contains(nextUpper)) {
153                        // Qualified END. END CASE pops caseDepth and
154                        // marks the trailing CASE as a consumed qualifier;
155                        // END IF/LOOP/WHILE/REPEAT/FOR are no-ops because
156                        // those keywords don't push.
157                        if ("CASE".equals(nextUpper)) {
158                            if (caseDepth > 0) caseDepth--;
159                            swallowNextCaseAsQualifier = true;
160                        }
161                    } else {
162                        // Unqualified END. Pop caseDepth first (CASE
163                        // expressions outrank block scope), then blockDepth.
164                        if (caseDepth > 0) {
165                            caseDepth--;
166                        } else if (blockDepth > 0) {
167                            blockDepth--;
168                        }
169                    }
170                }
171            }
172
173            // Terminators only fire at top-level depth.
174            if (blockDepth != 0 || caseDepth != 0) continue;
175
176            if (type == ETokenType.ttsemicolon) {
177                out.add(makeRange(stream, rangeStart, i + 1,
178                    StatementRange.Terminator.SEMICOLON));
179                rangeStart = i + 1;
180            } else if (goIsTerminator
181                && type == ETokenType.ttkeyword
182                && "GO".equalsIgnoreCase(text)) {
183                if (i > rangeStart) {
184                    out.add(makeRange(stream, rangeStart, i,
185                        StatementRange.Terminator.GO));
186                }
187                int next = i + 1;
188                if (next < n && isBatchCount(stream.get(next))) next++;
189                rangeStart = next;
190                i = next - 1;
191            }
192        }
193
194        // Truncated final statement (no terminator at end).
195        if (rangeStart < n) {
196            out.add(makeRange(stream, rangeStart, n,
197                StatementRange.Terminator.NONE));
198        }
199        return java.util.Collections.unmodifiableList(out);
200    }
201
202    /**
203     * Return the text of the next solid (non-comment, non-empty) token
204     * after {@code fromIndex}, or {@code null} if there is none. Tokens
205     * inside a {@link TokenRole#NO_FORMAT_ZONE} are skipped — they're
206     * opaque to the depth model.
207     */
208    private static String nextSolidKeywordText(Pp2TokenStream stream, int fromIndex) {
209        for (int j = fromIndex + 1; j < stream.size(); j++) {
210            Pp2Token next = stream.get(j);
211            if (next.hasRole(TokenRole.NO_FORMAT_ZONE)) continue;
212            if (next.hasRole(TokenRole.COMMENT_LINE)
213                || next.hasRole(TokenRole.COMMENT_BLOCK)) {
214                continue;
215            }
216            String nextText = next.getText();
217            if (nextText == null || nextText.isEmpty()) continue;
218            return nextText;
219        }
220        return null;
221    }
222
223    private static StatementRange makeRange(Pp2TokenStream stream,
224                                            int startIdx, int endIdxExclusive,
225                                            StatementRange.Terminator terminator) {
226        int startOffset = startIdx < stream.size()
227            ? (int) stream.get(startIdx).getSourceToken().offset
228            : 0;
229        int endOffset = startOffset;
230        if (endIdxExclusive > startIdx) {
231            Pp2Token last = stream.get(endIdxExclusive - 1);
232            String txt = last.getText();
233            int textLen = txt == null ? 0 : txt.length();
234            endOffset = (int) last.getSourceToken().offset + textLen;
235        }
236        return new StatementRange(startIdx, endIdxExclusive,
237            startOffset, endOffset, terminator);
238    }
239
240    /** The count of SQL Server's {@code GO n}: a number on the line of its {@code GO}. */
241    private static boolean isBatchCount(Pp2Token token) {
242        return token.getSourceToken().tokentype == ETokenType.ttnumber
243            && token.getPrecedingLinebreaks() == 0
244            && !token.hasRole(TokenRole.NO_FORMAT_ZONE);
245    }
246
247    /**
248     * True when {@code GO} is a batch separator for the vendor: SQL Server,
249     * Azure SQL, Sybase ASE and IQ, and SQL Anywhere.
250     */
251    private static boolean isTsqlFamily(EDbVendor vendor) {
252        if (vendor == null) return false;
253        switch (vendor) {
254            case dbvmssql:
255            case dbvazuresql:
256            case dbvsybase:
257                return true;
258            case dbvsybasease:
259                return true;
260            case dbvsqlanywhere:
261                return true;
262            case dbvsybaseiq:
263                return true;
264            default:
265                return false;
266        }
267    }
268}