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}