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}