001package gudusoft.gsqlparser.pp2.engine; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.TBaseType; 005import gudusoft.gsqlparser.TGSqlParser; 006import gudusoft.gsqlparser.TSourceTokenList; 007import gudusoft.gsqlparser.pp.logger.PPLogger; 008import gudusoft.gsqlparser.pp2.FormatDiagnostic; 009import gudusoft.gsqlparser.pp2.FormatStatus; 010import gudusoft.gsqlparser.pp2.Pp2FormatOptions; 011import gudusoft.gsqlparser.pp2.Pp2FormatResult; 012import gudusoft.gsqlparser.pp2.RendererId; 013import gudusoft.gsqlparser.pp2.region.ParseRecoveryEngine; 014import gudusoft.gsqlparser.pp2.region.RegionParseOutcome; 015import gudusoft.gsqlparser.pp2.overlay.AstOverlayAnnotator; 016import gudusoft.gsqlparser.pp2.region.StatementBoundaryDetector; 017import gudusoft.gsqlparser.pp2.region.StatementRange; 018import gudusoft.gsqlparser.pp2.render.ConservativeTokenRenderer; 019import gudusoft.gsqlparser.pp2.render.GuardedAstDelegate; 020import gudusoft.gsqlparser.pp2.render.LexicalIslandRenderer; 021import gudusoft.gsqlparser.pp2.render.RegionAssembler; 022import gudusoft.gsqlparser.pp2.render.RenderedRegion; 023import gudusoft.gsqlparser.pp2.token.Pp2TokenStream; 024import gudusoft.gsqlparser.pp2.token.Pp2TokenStreamBuilder; 025import gudusoft.gsqlparser.pp2.token.SourceSpanLedger; 026import gudusoft.gsqlparser.pp2.token.TokenEquivalence; 027import gudusoft.gsqlparser.pp2.zone.ProtectedZoneDetector; 028 029import java.util.ArrayList; 030import java.util.Collections; 031import java.util.List; 032 033/** 034 * Fault-tolerant SQL formatter engine — Phase-2 MVP orchestrator. 035 * 036 * <h2>Pipeline summary</h2> 037 * 038 * <p>Each {@link #format(String, EDbVendor, Pp2FormatOptions)} call runs the 039 * following stages in order: 040 * 041 * <ol> 042 * <li><b>Tokenize</b> — {@link TGSqlParser#tokenizeSqltext()} produces a 043 * raw {@link TSourceTokenList}.</li> 044 * <li><b>Token spine</b> — {@link Pp2TokenStreamBuilder} adapts the token 045 * list into a {@link Pp2TokenStream}: folds whitespace into 046 * {@code precedingBlanks} / {@code precedingLinebreaks} counts, 047 * preserves comments as first-class tokens.</li> 048 * <li><b>Ledger</b> — {@link SourceSpanLedger} records every byte of the 049 * original input so the assembler can restore inter-region trivia 050 * verbatim.</li> 051 * <li><b>Zone annotation</b> — {@link ProtectedZoneDetector} annotates 052 * {@code NO_FORMAT_ZONE}, {@code COMMENT_LINE}, {@code COMMENT_BLOCK}, 053 * and template-placeholder roles onto the token stream.</li> 054 * <li><b>Boundary detection</b> — {@link StatementBoundaryDetector} walks 055 * the annotated stream and emits one {@link StatementRange} per 056 * statement.</li> 057 * <li><b>Parse recovery</b> — {@link ParseRecoveryEngine#parseAll(List)} 058 * attempts per-region parsing. Each outcome is tagged 059 * {@code AST_OK | AST_ERROR | TRIVIA}.</li> 060 * <li><b>Dispatch</b> — {@link EngineDispatch} routes each outcome to the 061 * appropriate renderer (plan §5.2 three-tier strategy): 062 * {@code AST_OK} → {@link GuardedAstDelegate} (fallback to 063 * conservative on guard failure); {@code AST_ERROR} → 064 * {@link ConservativeTokenRenderer}; {@code TRIVIA} → passthrough. 065 * Each rendered text is wrapped in a {@link RenderedRegion}.</li> 066 * <li><b>Assembly</b> — {@link RegionAssembler} interleaves the rendered 067 * texts with the original inter-region trivia from the ledger, 068 * producing the final output string.</li> 069 * <li><b>Result</b> — a {@link Pp2FormatResult} carrying the assembled 070 * text, {@link FormatStatus}, per-region {@link Pp2FormatResult.Region} 071 * records, and all diagnostics accumulated across the pipeline.</li> 072 * </ol> 073 * 074 * <h2>Defensive behaviour</h2> 075 * 076 * <p>The engine never throws for non-null inputs. Any {@link Throwable} that 077 * escapes the pipeline — from the tokenizer, ledger builder, boundary 078 * detector, or assembler — is caught, logged via {@link PPLogger}, and the 079 * engine falls back to returning the original SQL unchanged with 080 * {@link FormatStatus#FAILED} and a {@link FormatDiagnostic.Severity#FATAL} 081 * diagnostic. 082 * 083 * <h2>Thread safety</h2> 084 * 085 * <p>The engine instance is stateless (all mutable objects are allocated per 086 * {@code format} call). Concurrent calls with different inputs are safe. The 087 * inner {@link ParseRecoveryEngine} allocates a fresh 088 * {@link gudusoft.gsqlparser.pp2.region.ParserPool} per call. 089 * 090 * <p>Plan reference: §5.1, §7.3/S16, §7.4/S16, §10.4. 091 */ 092public final class Pp2Engine { 093 094 /** 095 * Format the given SQL string using the supplied database vendor and 096 * options. Never throws for non-null inputs. 097 * 098 * @param sql the raw SQL to format; must not be null 099 * @param vendor the database dialect; governs tokenization, keyword 100 * recognition, and boundary detection 101 * @param opts formatting options; must not be null 102 * @return a {@link Pp2FormatResult} carrying the formatted text and 103 * per-region metadata; never null 104 * @throws NullPointerException if any argument is null 105 */ 106 public Pp2FormatResult format(String sql, EDbVendor vendor, 107 Pp2FormatOptions opts) { 108 if (sql == null) throw new NullPointerException("sql"); 109 if (vendor == null) throw new NullPointerException("vendor"); 110 if (opts == null) throw new NullPointerException("opts"); 111 112 // Trial-build size gate. The formatter is deliberately fault-tolerant 113 // (parse failures degrade to verbatim output with a success status), so 114 // without this explicit refusal an oversized input would be echoed back 115 // as OK instead of rejected. Always false in full builds. 116 if (gudusoft.gsqlparser.TrialInputGuard.checkText(sql).isRefused()) { 117 List<FormatDiagnostic> diags = Collections.singletonList( 118 new FormatDiagnostic(FormatDiagnostic.Severity.FATAL, 0, sql.length(), 119 TBaseType.trialSizeMessage())); 120 return new Pp2FormatResult(sql, FormatStatus.FAILED, 121 Collections.<Pp2FormatResult.Region>emptyList(), diags); 122 } 123 124 try { 125 if (opts.wholeDocumentFastPath) { 126 Pp2FormatResult whole = wholeDocumentFastPath(sql, vendor, opts); 127 if (whole != null) return whole; 128 } 129 return formatInternal(sql, vendor, opts); 130 } catch (Throwable t) { 131 // Top-level safety net: if anything escaped the pipeline, return 132 // the original SQL unchanged so the caller never gets an exception. 133 PPLogger.error(t); 134 PPLogger.info("Pp2Engine: unhandled throwable escaped pipeline; " 135 + "returning original SQL unchanged. vendor=" + vendor 136 + " sqlLen=" + sql.length()); 137 List<FormatDiagnostic> diags = Collections.singletonList( 138 new FormatDiagnostic(FormatDiagnostic.Severity.FATAL, 0, sql.length(), 139 "Pp2Engine: pipeline threw " + t.getClass().getSimpleName() 140 + (t.getMessage() != null ? ": " + t.getMessage() : ""))); 141 return new Pp2FormatResult(sql, FormatStatus.FAILED, 142 Collections.<Pp2FormatResult.Region>emptyList(), diags); 143 } 144 } 145 146 /** 147 * Whole-document fast path: when the input parses as a whole and 148 * {@code FormatterFactory.pp} formats it with the same options without 149 * changing a token, that text is the pp2 result ({@code OK}, one 150 * {@code GUARDED_AST} region). The regional pipeline reparses statement 151 * ranges independently and its reassembled text was not byte-identical to 152 * whole-document pp on 60 of 1000 parseable corpus inputs. 153 * 154 * @return the result, or {@code null} when the regional pipeline must run 155 */ 156 private static Pp2FormatResult wholeDocumentFastPath(String sql, EDbVendor vendor, 157 Pp2FormatOptions opts) { 158 try { 159 TGSqlParser parser = new TGSqlParser(vendor); 160 parser.sqltext = sql; 161 if (parser.parse() != 0 || parser.sqlstatements == null || parser.sqlstatements.size() == 0) { 162 return null; 163 } 164 String text = gudusoft.gsqlparser.pp.stmtformatter.FormatterFactory.pp(parser, opts.toGFmtOpt()); 165 if (text == null || text.isEmpty()) { 166 return null; 167 } 168 if (!gudusoft.gsqlparser.pp2.token.TokenEquivalence.equalsModuloFormatting(sql, text, opts, vendor, true)) { 169 PPLogger.info("Pp2Engine: whole-document pp output is not token-equivalent; using the regional pipeline. vendor=" 170 + vendor + " sqlLen=" + sql.length()); 171 return null; 172 } 173 // one region per parsed statement, so the region list still describes the input 174 List<Pp2FormatResult.Region> regions = new java.util.ArrayList<Pp2FormatResult.Region>(); 175 for (int i = 0; i < parser.sqlstatements.size(); i++) { 176 gudusoft.gsqlparser.TCustomSqlStatement st = parser.sqlstatements.get(i); 177 if (st == null || st.getStartToken() == null || st.getEndToken() == null) continue; 178 int start = (int) Math.max(0, Math.min(sql.length(), st.getStartToken().offset)); 179 int end = (int) Math.max(start, Math.min(sql.length(), 180 gudusoft.gsqlparser.pp2.token.TokenCoverage.endOffset(st.getEndToken()))); 181 regions.add(new Pp2FormatResult.Region(start, end, RendererId.GUARDED_AST, FormatStatus.OK)); 182 } 183 if (regions.isEmpty()) { 184 regions.add(new Pp2FormatResult.Region(0, sql.length(), RendererId.GUARDED_AST, FormatStatus.OK)); 185 } 186 return new Pp2FormatResult(text, FormatStatus.OK, regions, 187 Collections.<FormatDiagnostic>emptyList()); 188 } catch (Throwable t) { 189 PPLogger.info("Pp2Engine: whole-document pp threw " + t.getClass().getSimpleName() 190 + "; using the regional pipeline. vendor=" + vendor + " sqlLen=" + sql.length()); 191 return null; 192 } 193 } 194 195 private Pp2FormatResult formatInternal(String sql, EDbVendor vendor, 196 Pp2FormatOptions opts) { 197 // All collaborators are allocated per call so the no-shared-mutable-state 198 // (and therefore thread-safety) guarantee holds unconditionally, even if 199 // a detector later grows internal scratch/cache state. 200 Pp2TokenStreamBuilder streamBuilder = new Pp2TokenStreamBuilder(); 201 StatementBoundaryDetector boundaryDetector = new StatementBoundaryDetector(); 202 ProtectedZoneDetector zoneDetector = new ProtectedZoneDetector(); 203 RegionAssembler assembler = new RegionAssembler(vendor); 204 205 // Stage 1: tokenize. 206 TGSqlParser parser = new TGSqlParser(vendor); 207 parser.sqltext = sql; 208 parser.tokenizeSqltext(); 209 TSourceTokenList tokenList = parser.getSourcetokenlist(); 210 211 // Stage 2: build token spine. 212 Pp2TokenStreamBuilder.BuildResult buildResult = streamBuilder.build(tokenList); 213 Pp2TokenStream stream = buildResult.getStream(); 214 215 // Stage 3: build source-span ledger (byte authority). 216 SourceSpanLedger ledger = SourceSpanLedger.build(sql, tokenList); 217 218 // Stage 4: annotate protected zones (NO_FORMAT_ZONE, comments, etc.). 219 zoneDetector.annotate(stream); 220 221 // Stage 5: detect statement boundaries. 222 List<StatementRange> ranges = boundaryDetector.detect(stream, vendor); 223 224 // Empty stream → no regions; return source unchanged. 225 if (ranges.isEmpty()) { 226 return new Pp2FormatResult(sql, FormatStatus.OK, 227 Collections.<Pp2FormatResult.Region>emptyList(), 228 new ArrayList<FormatDiagnostic>(ledger.getDiagnostics())); 229 } 230 231 // Stages 6+7 INTERLEAVED — parse and render each region in turn. 232 // 233 // Memory: an AST_OK region carries a live TGSqlParser (parse tables + 234 // AST). Parsing every region up front (parseAll) and only then 235 // rendering would keep N parsers alive simultaneously — on a script 236 // with thousands of valid statements that exhausts the heap (root-caused 237 // in S36: ~300KB/parser × N → OOM even at -Xmx2g). Interleaving keeps at 238 // most ONE region's parser/AST live at a time: each region is parsed, 239 // rendered, and then discarded before the next region's parse resets the 240 // shared pool. This also removes the need to promote AST_OK outcomes to 241 // a dedicated fresh parser (parseAll's strategy), cutting allocation 242 // further. The rendered output is identical — the AST content a region 243 // parses to is the same whichever parser instance holds it. 244 ParseRecoveryEngine recovery = new ParseRecoveryEngine( 245 vendor, sql, stream, opts); 246 GuardedAstDelegate astDelegate = new GuardedAstDelegate(vendor); 247 LexicalIslandRenderer lexicalIsland = new LexicalIslandRenderer(vendor); 248 ConservativeTokenRenderer conservative = new ConservativeTokenRenderer(); 249 EngineDispatch dispatch = new EngineDispatch(astDelegate, lexicalIsland, conservative); 250 251 List<RenderedRegion> renderedRegions = new ArrayList<RenderedRegion>(ranges.size()); 252 List<FormatDiagnostic> allDiags = new ArrayList<FormatDiagnostic>(ledger.getDiagnostics()); 253 List<Pp2FormatResult.Region> regionRecords = 254 new ArrayList<Pp2FormatResult.Region>(ranges.size()); 255 256 // S33 AST overlay (v3 bridge): off by default. When enabled, annotate 257 // each cleanly-parsed region's tokens with AST-derived roles. v2 does 258 // not render from these roles, so this never changes output here — it 259 // lands the annotation infrastructure a v3 renderer will consume. 260 AstOverlayAnnotator overlay = opts.astOverlayEnabled 261 ? new AstOverlayAnnotator() : null; 262 263 for (StatementRange range : ranges) { 264 RegionParseOutcome outcome = recovery.parseRegion(range); 265 if (overlay != null) { 266 applyAstOverlay(overlay, outcome, stream); 267 } 268 EngineDispatch.DispatchResult result = dispatch.dispatch(outcome, stream, opts); 269 renderedRegions.add(new RenderedRegion( 270 outcome.getRange(), result.text, result.rendererId)); 271 allDiags.addAll(result.diagnostics); 272 regionRecords.add(toRegionRecord(outcome, result.rendererId)); 273 // outcome is dropped here; the next parseRegion() resets the pool, 274 // freeing this region's parser/AST. At most one is ever live. 275 } 276 277 // Stage 8: assemble into final output. 278 String assembled = assembler.assemble(renderedRegions, ledger, opts); 279 280 // Stage 8b: content-preservation safety net for statement-break 281 // normalization. Inserting a newline between two statements that shared 282 // a physical line can, in SQL*Plus-aware dialects, reclassify a 283 // following bare identifier as a SQL*Plus command (e.g. ttidentifier -> 284 // ttsqlpluscmd) — a token-stream change that only arises for malformed 285 // input whose region starts with a bare word, never for real SQL whose 286 // statements start with keywords. If the break altered the solid token 287 // stream, fall back to verbatim inter-region trivia, which is byte- (and 288 // therefore token-) preserving. Guarded to the case where breaking could 289 // have acted (>= 2 regions) so single-statement formatting pays nothing. 290 if (opts.breakStatementsOnNewLine && renderedRegions.size() >= 2 291 && !TokenEquivalence.equalsModuloFormatting( 292 sql, assembled, opts, vendor, true)) { 293 PPLogger.info("Pp2Engine: statement-break normalization altered the " 294 + "token stream (vendor=" + vendor + "); falling back to verbatim " 295 + "inter-region trivia to preserve content."); 296 assembled = assembler.assemble(renderedRegions, ledger, opts, false); 297 } 298 299 // Stage 9: compute overall status and build result. 300 FormatStatus status = computeStatus(renderedRegions, allDiags); 301 302 return new Pp2FormatResult(assembled, status, regionRecords, allDiags); 303 } 304 305 /** 306 * Run the {@link AstOverlayAnnotator} on a single AST_OK region, stamping 307 * AST-derived roles directly onto the engine's whole-script {@code stream} 308 * (the tokens every renderer sees). The region was parsed from 309 * {@code originalSql.substring(range.getStartOffset(), ...)}, so the region 310 * AST's token offsets are region-relative; passing the range's start offset 311 * as the adjustment translates them onto the absolute-offset stream. 312 * 313 * <p>Best-effort: any failure is logged and swallowed so the feature flag 314 * can never break formatting. In v2 no renderer consumes these roles, so 315 * this stamping has no effect on output — it is the v3 evolution seam. 316 */ 317 private static void applyAstOverlay(AstOverlayAnnotator overlay, 318 RegionParseOutcome outcome, 319 Pp2TokenStream stream) { 320 if (outcome.getStatus() != RegionParseOutcome.Status.AST_OK 321 || outcome.getStatement() == null) { 322 return; 323 } 324 try { 325 overlay.annotate(outcome.getStatement(), stream, 326 outcome.getRange().getStartOffset()); 327 } catch (Throwable t) { 328 PPLogger.error(t); 329 PPLogger.info("Pp2Engine: AST overlay annotation failed for region " 330 + outcome.getRange() + "; continuing without overlay roles."); 331 } 332 } 333 334 /** 335 * Compute the overall {@link FormatStatus}: 336 * <ul> 337 * <li>{@link FormatStatus#OK} — every region used {@link RendererId#GUARDED_AST}; 338 * no diagnostics at WARNING or above.</li> 339 * <li>{@link FormatStatus#OK_WITH_RECOVERY} — at least one region used 340 * the conservative renderer, or at least one WARNING/ERROR diagnostic 341 * was emitted (but no FATAL).</li> 342 * <li>{@link FormatStatus#FAILED} — at least one FATAL diagnostic.</li> 343 * </ul> 344 */ 345 private static FormatStatus computeStatus(List<RenderedRegion> regions, 346 List<FormatDiagnostic> diags) { 347 for (FormatDiagnostic d : diags) { 348 if (d.getSeverity() == FormatDiagnostic.Severity.FATAL) { 349 return FormatStatus.FAILED; 350 } 351 } 352 for (RenderedRegion r : regions) { 353 if (isRecoveryRenderer(r.getRendererId())) { 354 return FormatStatus.OK_WITH_RECOVERY; 355 } 356 } 357 for (FormatDiagnostic d : diags) { 358 if (d.getSeverity() == FormatDiagnostic.Severity.WARNING 359 || d.getSeverity() == FormatDiagnostic.Severity.ERROR) { 360 return FormatStatus.OK_WITH_RECOVERY; 361 } 362 } 363 return FormatStatus.OK; 364 } 365 366 /** A region was recovered (not the clean AST path) if it used the island or conservative renderer. */ 367 private static boolean isRecoveryRenderer(RendererId id) { 368 return id == RendererId.CONSERVATIVE || id == RendererId.LEXICAL_ISLAND; 369 } 370 371 /** 372 * Build one per-region {@link Pp2FormatResult.Region} record from a parse 373 * outcome and the renderer that produced its text. The region-local status 374 * is {@code OK} for {@code GUARDED_AST} and {@code OK_WITH_RECOVERY} for the 375 * island / conservative recovery renderers (or any AST_ERROR region). 376 * Called per region in the interleaved loop, so it never needs to retain 377 * the full outcome list. 378 */ 379 private static Pp2FormatResult.Region toRegionRecord(RegionParseOutcome outcome, 380 RendererId rendererId) { 381 FormatStatus regionStatus; 382 if (isRecoveryRenderer(rendererId)) { 383 regionStatus = FormatStatus.OK_WITH_RECOVERY; 384 } else if (outcome.getStatus() == RegionParseOutcome.Status.AST_ERROR) { 385 // Shouldn't happen (AST_ERROR → island/conservative), but be safe. 386 regionStatus = FormatStatus.OK_WITH_RECOVERY; 387 } else { 388 regionStatus = FormatStatus.OK; 389 } 390 return new Pp2FormatResult.Region( 391 outcome.getRange().getStartOffset(), 392 outcome.getRange().getEndOffset(), 393 rendererId, 394 regionStatus); 395 } 396}