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