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}