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}