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