001package gudusoft.gsqlparser.pp2.render;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.pp.logger.PPLogger;
005import gudusoft.gsqlparser.pp2.Pp2FormatOptions;
006import gudusoft.gsqlparser.pp2.token.SourceSpanLedger;
007import gudusoft.gsqlparser.pp2.zone.CommentPolicy;
008
009import java.util.List;
010
011/**
012 * Assembles per-region rendered texts into a single output string, bridging
013 * gaps between regions with the original inter-region trivia extracted from
014 * the {@link SourceSpanLedger}.
015 *
016 * <h2>Why the ledger owns inter-region trivia</h2>
017 *
018 * <p>Every {@link RegionRenderer} intentionally omits the leading whitespace
019 * for the <em>first</em> token of its range (see {@link RenderedRegion}).
020 * The bytes between adjacent regions — whitespace, comments, blank lines,
021 * {@code --BEGIN_NO_FORMAT}/{@code --END_NO_FORMAT} blocks — live in the
022 * original source at positions {@code [r_i.endOffset, r_{i+1}.startOffset)}.
023 * The assembler is the counterparty that places those bytes into the output,
024 * so they reach the caller byte-for-byte regardless of which renderer handled
025 * the surrounding statements.
026 *
027 * <h2>Assembly model</h2>
028 *
029 * <p>Given an ordered, non-overlapping list of {@link RenderedRegion}s:
030 * <ol>
031 *   <li><b>Preamble</b> — {@code source[0, first.startOffset)}</li>
032 *   <li>For each region {@code r_i}:
033 *     <ol>
034 *       <li>Emit {@code r_i.getText()} (the renderer's output)</li>
035 *       <li>Emit {@code source[r_i.endOffset, r_{i+1}.startOffset)}
036 *           (inter-region gap; the final region skips this step)</li>
037 *     </ol>
038 *   </li>
039 *   <li><b>Trailer</b> — {@code source[last.endOffset, source.length())}</li>
040 * </ol>
041 *
042 * <p>An empty regions list returns the entire source string unchanged — all
043 * bytes come from the preamble/trailer path.
044 *
045 * <h2>Comment policy</h2>
046 *
047 * <p>Phase-2 MVP implements only {@link CommentPolicy#PRESERVE}: inter-region
048 * bytes are always emitted verbatim. {@link CommentPolicy#REANCHOR} and
049 * {@link CommentPolicy#REFLOW} log a notice and fall back to PRESERVE; the
050 * Phase-3 island pipeline will provide real semantics.
051 *
052 * <h2>Defensive behaviour</h2>
053 *
054 * <p>Overlapping or out-of-order region offsets (which should never occur when
055 * {@code StatementBoundaryDetector} S11 produces the list) are detected and
056 * logged; overlap bytes are skipped rather than emitted twice. Out-of-bounds
057 * source-slice requests are also guarded — no byte is lost due to an offset
058 * arithmetic bug in the caller.
059 *
060 * <p>Plan reference: §7.3/S15, §7.4/S15, §10.4.
061 */
062public final class RegionAssembler {
063
064    /**
065     * Dialect whose comment syntax governs {@link #nextRegionStartsWithComment}.
066     * May be {@code null} (vendor-agnostic): then only the universal {@code --}
067     * and {@code /*} openers are recognised.
068     */
069    private final EDbVendor vendor;
070
071    /** Vendor-agnostic assembler ({@code --} and {@code /*} comments only). */
072    public RegionAssembler() {
073        this(null);
074    }
075
076    /**
077     * Assembler that recognises {@code vendor}'s line-comment syntax when
078     * deciding whether a region begins with a (trailing) comment. In
079     * particular {@code #} is treated as a line comment for every dialect
080     * except the SQL Server family, where {@code #name} is a temp-table
081     * identifier rather than a comment.
082     */
083    public RegionAssembler(EDbVendor vendor) {
084        this.vendor = vendor;
085    }
086
087    /**
088     * Assemble rendered regions into a final output string.
089     *
090     * @param regions ordered (source position), non-overlapping list of
091     *                rendered regions; empty list is allowed
092     * @param ledger  the source-span ledger for the original SQL;
093     *                {@link SourceSpanLedger#getSource()} is the byte authority
094     * @param opts    pp2 format options;
095     *                {@link Pp2FormatOptions#commentPolicy} governs how
096     *                inter-region trivia is emitted
097     * @return the assembled output string; never null
098     * @throws NullPointerException if any argument is null
099     */
100    public String assemble(List<RenderedRegion> regions,
101                           SourceSpanLedger ledger,
102                           Pp2FormatOptions opts) {
103        if (opts == null) throw new NullPointerException("opts");
104        return assemble(regions, ledger, opts, opts.breakStatementsOnNewLine);
105    }
106
107    /**
108     * Assemble rendered regions, with explicit control over statement-break
109     * normalization. Identical to {@link #assemble(List, SourceSpanLedger,
110     * Pp2FormatOptions)} except {@code breakStatements} overrides
111     * {@link Pp2FormatOptions#breakStatementsOnNewLine}. The engine uses this
112     * overload to re-assemble verbatim ({@code breakStatements=false}) as a
113     * content-preservation fallback when a break would alter the token stream.
114     *
115     * @param breakStatements when {@code true}, whitespace-only line-break-free
116     *                        gaps between statements become a single newline;
117     *                        when {@code false}, all inter-region trivia is
118     *                        emitted verbatim
119     */
120    public String assemble(List<RenderedRegion> regions,
121                           SourceSpanLedger ledger,
122                           Pp2FormatOptions opts,
123                           boolean breakStatements) {
124        if (regions == null) throw new NullPointerException("regions");
125        if (ledger == null) throw new NullPointerException("ledger");
126        if (opts == null) throw new NullPointerException("opts");
127
128        String source = ledger.getSource();
129
130        if (regions.isEmpty()) {
131            // All bytes are inter-region trivia; return source unchanged.
132            return source;
133        }
134
135        if (opts.commentPolicy != CommentPolicy.PRESERVE) {
136            PPLogger.info("RegionAssembler: commentPolicy="
137                + opts.commentPolicy
138                + " is not yet implemented in the Phase-2 MVP; "
139                + "falling back to PRESERVE (verbatim inter-region trivia)");
140        }
141
142        StringBuilder out = new StringBuilder(source.length());
143
144        // Preamble: source bytes before the first region's first token.
145        int firstStart = regions.get(0).getRange().getStartOffset();
146        appendSourceSlice(out, source, 0, firstStart, "preamble");
147
148        for (int i = 0; i < regions.size(); i++) {
149            RenderedRegion region = regions.get(i);
150            int rStart = region.getRange().getStartOffset();
151            int rEnd   = region.getRange().getEndOffset();
152
153            // Emit the renderer's output for this region.
154            out.append(region.getText());
155
156            if (i + 1 < regions.size()) {
157                int nextStart = regions.get(i + 1).getRange().getStartOffset();
158                if (rEnd > nextStart) {
159                    // Overlapping regions — the boundary detector should never
160                    // produce this, but log and skip rather than double-emit.
161                    PPLogger.info("RegionAssembler: region[" + i + "].endOffset="
162                        + rEnd + " > region[" + (i + 1) + "].startOffset="
163                        + nextStart + " — overlapping ranges; skipping gap");
164                } else if (breakStatements
165                        && isWhitespaceOnlyWithoutLineBreak(source, rEnd, nextStart)
166                        && !nextRegionStartsWithComment(source, nextStart)) {
167                    // Two statements share a physical line (separated only by
168                    // horizontal whitespace, no line break). Emit a single
169                    // newline so each statement gets its own line. The next
170                    // region renders its first token at column 0, so the
171                    // dropped horizontal spaces would otherwise become stray
172                    // leading indentation.
173                    //
174                    // Skipped when the next region begins with a comment: such a
175                    // comment is a trailing comment of the statement just emitted
176                    // (e.g. "SELECT 1; -- c"), and breaking here would detach it
177                    // onto its own line. Preserving the gap keeps it attached;
178                    // the comment's own line break still separates the next
179                    // statement.
180                    out.append('\n');
181                } else {
182                    // Inter-region gap: whitespace, comments, blank lines, etc.
183                    appendSourceSlice(out, source, rEnd, nextStart,
184                        "gap[" + i + "->" + (i + 1) + "]");
185                }
186            }
187        }
188
189        // Trailer: source bytes after the last region's last token.
190        int lastEnd = regions.get(regions.size() - 1).getRange().getEndOffset();
191        appendSourceSlice(out, source, lastEnd, source.length(), "trailer");
192
193        return out.toString();
194    }
195
196    /**
197     * Append {@code source[from..to)} to {@code out}. Guards against
198     * out-of-bounds indices: an invalid range logs a warning and emits
199     * nothing rather than throwing, so a single bad region cannot corrupt the
200     * rest of the assembly.
201     */
202    private static void appendSourceSlice(StringBuilder out,
203                                          String source,
204                                          int from, int to,
205                                          String label) {
206        if (from >= to) return;  // empty or zero-width slice
207        if (from < 0 || to > source.length()) {
208            PPLogger.info("RegionAssembler: " + label + " source slice ["
209                + from + ".." + to + ") is out of bounds "
210                + "(sourceLen=" + source.length() + "); skipping");
211            return;
212        }
213        out.append(source, from, to);
214    }
215
216    /**
217     * True when {@code source[from..to)} consists solely of horizontal
218     * whitespace (or is empty) and contains no line break. Such a gap means
219     * two statements sit on the same physical line; the assembler replaces it
220     * with a newline when {@link Pp2FormatOptions#breakStatementsOnNewLine} is
221     * on. A gap containing a {@code \n}/{@code \r} (statements already on
222     * separate lines) or any comment / non-whitespace trivia returns
223     * {@code false} so it is preserved verbatim.
224     */
225    private static boolean isWhitespaceOnlyWithoutLineBreak(String source,
226                                                            int from, int to) {
227        if (from < 0 || to > source.length()) return false;  // bounds-guard; verbatim path handles it
228        if (from >= to) return true;  // adjacent statements (no gap) still want a break
229        for (int i = from; i < to; i++) {
230            char c = source.charAt(i);
231            if (c == '\n' || c == '\r') return false;  // already line-separated
232            if (!Character.isWhitespace(c)) return false;  // comment / other trivia
233        }
234        return true;
235    }
236
237    /**
238     * True when the next region begins with a comment at {@code start}. Such a
239     * comment is a trailing comment of the preceding statement; inserting a
240     * statement break before it would detach it onto its own line, so we
241     * preserve the gap verbatim instead.
242     *
243     * <p>The universal {@code --} and {@code /*} openers are always recognised.
244     * {@code #} is recognised as a line comment for every dialect except the
245     * SQL Server family ({@code dbvmssql}/{@code dbvazuresql}/{@code dbvsybase}),
246     * where {@code #name} is a temp-table identifier, not a comment. Erring
247     * toward "is a comment" only ever preserves trivia (skips a break), so a
248     * rare misclassification degrades gracefully rather than corrupting output.
249     */
250    private boolean nextRegionStartsWithComment(String source, int start) {
251        if (start < 0 || start >= source.length()) return false;
252        if (source.startsWith("--", start) || source.startsWith("/*", start)) {
253            return true;
254        }
255        return source.charAt(start) == '#' && hashIsLineComment();
256    }
257
258    /** True when {@code #} starts a line comment in {@link #vendor}. */
259    private boolean hashIsLineComment() {
260        if (vendor == null) return false;  // vendor-agnostic: don't guess
261        switch (vendor) {
262            case dbvmssql:
263            case dbvazuresql:
264            case dbvsybase:
265                return false;  // #name is a temp-table identifier here
266            default:
267                return true;   // MySQL family and others: # is a line comment
268        }
269    }
270}