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}