|
|
@@ -209,7 +209,121 @@ export function formatDocument(
|
|
|
text: string, rawOptions?: Partial<FormatOptions>, filePath = '', docDir = '',
|
|
|
): string {
|
|
|
const opts = normalizeFormatOptions(rawOptions ?? {});
|
|
|
- if (text.length === 0) return '';
|
|
|
+ const { entries, eol } = formatLineEntries(text, opts, filePath, docDir);
|
|
|
+ const joined = entries.map(l => l.text).join('\n');
|
|
|
+ if (!opts.insertFinalNewline) return joined.split('\n').join(eol);
|
|
|
+ return (joined.length > 0 ? joined + '\n' : '').split('\n').join(eol);
|
|
|
+}
|
|
|
+
|
|
|
+/** One formatted output line and the 0-based input line it came from.
|
|
|
+ *
|
|
|
+ * Input lines swallowed by blank-line collapsing have no entry; the
|
|
|
+ * range formatter turns those gaps into deletions. Every input line
|
|
|
+ * produces at most one entry. */
|
|
|
+export interface FormattedLine {
|
|
|
+ src: number;
|
|
|
+ text: string;
|
|
|
+}
|
|
|
+
|
|
|
+/** One edit for `textDocument/rangeFormatting`: a character range plus
|
|
|
+ * its replacement, directly mappable to an LSP `TextEdit`. */
|
|
|
+export interface RangeEdit {
|
|
|
+ startLine: number;
|
|
|
+ startCh: number;
|
|
|
+ endLine: number;
|
|
|
+ endCh: number;
|
|
|
+ newText: string;
|
|
|
+}
|
|
|
+
|
|
|
+/** Format a line range, honouring full-document context (indentation
|
|
|
+ * levels depend on preceding lines) while touching nothing outside
|
|
|
+ * `[startLine, endLine]`. An empty range formats the cursor line.
|
|
|
+ * Whole lines are replaced (or, when blank-collapsing drops them,
|
|
|
+ * deleted), so applying the edits reproduces exactly what the document
|
|
|
+ * formatter would emit for those lines — including the final newline
|
|
|
+ * when the range reaches end of file. Never throws on broken input. */
|
|
|
+export function formatRangeEdits(
|
|
|
+ text: string, startLine: number, endLine: number,
|
|
|
+ rawOptions?: Partial<FormatOptions>, filePath = '', docDir = '',
|
|
|
+): RangeEdit[] {
|
|
|
+ if (text.length === 0) return [];
|
|
|
+ const opts = normalizeFormatOptions(rawOptions ?? {});
|
|
|
+ // LSP line splitting (keeps `\r` inside CRLF lines, like the server).
|
|
|
+ const srcLines = text.split('\n');
|
|
|
+ const srcCount = srcLines.length;
|
|
|
+ const from = Math.min(Math.max(0, Math.min(startLine, endLine)), srcCount - 1);
|
|
|
+ const to = Math.min(Math.max(Math.max(startLine, endLine), from), srcCount - 1);
|
|
|
+ const { entries } = formatLineEntries(text, rawOptions, filePath, docDir);
|
|
|
+ const bySrc = new Map<number, string>();
|
|
|
+ for (const e of entries) {
|
|
|
+ if (e.src >= from && e.src <= to && !bySrc.has(e.src)) bySrc.set(e.src, e.text);
|
|
|
+ }
|
|
|
+ const edits: RangeEdit[] = [];
|
|
|
+ let runFrom = -1;
|
|
|
+ const parts: string[] = [];
|
|
|
+ let tailNewlined = false;
|
|
|
+ // A run over source lines [runFrom, endExclusive) becomes one edit.
|
|
|
+ // The range consumes whole lines including their breaks, so kept lines
|
|
|
+ // are re-terminated in newText (except at end of file, where the
|
|
|
+ // document formatter's own final-newline rule applies).
|
|
|
+ const flush = (endExclusive: number): void => {
|
|
|
+ if (runFrom < 0) return;
|
|
|
+ const atEof = endExclusive >= srcCount;
|
|
|
+ if (atEof && parts.length === 0 && runFrom > 0) {
|
|
|
+ // Deleting trailing lines to end of file: an empty final line owns
|
|
|
+ // no break of its own, so swallow the preceding break instead —
|
|
|
+ // otherwise the deletion is a no-op and a blank line survives.
|
|
|
+ const prevLen = srcLines[runFrom - 1].length;
|
|
|
+ edits.push({
|
|
|
+ startLine: runFrom - 1, startCh: prevLen,
|
|
|
+ endLine: srcCount - 1, endCh: srcLines[srcCount - 1].length,
|
|
|
+ newText: opts.insertFinalNewline && entries.length > 0 ? '\n' : '',
|
|
|
+ });
|
|
|
+ runFrom = -1;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ let newText = parts.join('\n');
|
|
|
+ if (parts.length > 0 && (atEof ? opts.insertFinalNewline : true)) newText += '\n';
|
|
|
+ if (atEof && newText.endsWith('\n')) tailNewlined = true;
|
|
|
+ const end = atEof
|
|
|
+ ? { line: srcCount - 1, ch: srcLines[srcCount - 1].length }
|
|
|
+ : { line: endExclusive, ch: 0 };
|
|
|
+ edits.push({
|
|
|
+ startLine: runFrom, startCh: 0,
|
|
|
+ endLine: end.line, endCh: end.ch, newText,
|
|
|
+ });
|
|
|
+ runFrom = -1;
|
|
|
+ parts.length = 0;
|
|
|
+ };
|
|
|
+ for (let s = from; s <= to; s++) {
|
|
|
+ const want = bySrc.get(s);
|
|
|
+ if (want !== undefined && want === srcLines[s]) {
|
|
|
+ flush(s);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (runFrom < 0) runFrom = s;
|
|
|
+ if (want !== undefined) parts.push(want);
|
|
|
+ }
|
|
|
+ flush(to + 1);
|
|
|
+ // The document formatter ends non-empty output with a newline: when the
|
|
|
+ // range reaches end of file over an unchanged tail that lacks one, the
|
|
|
+ // applied document would otherwise miss it.
|
|
|
+ if (to === srcCount - 1 && opts.insertFinalNewline && !text.endsWith('\n') &&
|
|
|
+ entries.length > 0 && !tailNewlined) {
|
|
|
+ const last = srcLines[srcCount - 1].length;
|
|
|
+ edits.push({
|
|
|
+ startLine: srcCount - 1, startCh: last,
|
|
|
+ endLine: srcCount - 1, endCh: last, newText: '\n',
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return edits;
|
|
|
+}
|
|
|
+
|
|
|
+function formatLineEntries(
|
|
|
+ text: string, rawOptions?: Partial<FormatOptions>, filePath = '', docDir = '',
|
|
|
+): { entries: FormattedLine[]; eol: string } {
|
|
|
+ const opts = normalizeFormatOptions(rawOptions ?? {});
|
|
|
+ if (text.length === 0) return { entries: [], eol: '\n' };
|
|
|
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
|
const dir = docDir || (filePath ? path.dirname(filePath) : '');
|
|
|
const tokens: RichToken[] = [
|
|
|
@@ -240,7 +354,7 @@ export function formatDocument(
|
|
|
const indentOf = (level: number): string =>
|
|
|
opts.useTabs ? '\t'.repeat(level) : ' '.repeat(opts.indentSize * level);
|
|
|
|
|
|
- const out: string[] = [];
|
|
|
+ const entries: FormattedLine[] = [];
|
|
|
let level = 0;
|
|
|
let casePending = false;
|
|
|
let blanks = 0;
|
|
|
@@ -251,7 +365,7 @@ export function formatDocument(
|
|
|
const startsHere = lineToks.filter(t => t.comment && t.line === i);
|
|
|
|
|
|
if (verbatim.has(i)) {
|
|
|
- out.push(opts.trimTrailingWhitespace ? srcLines[i]?.replace(/[ \t]+$/, '') ?? '' : srcLines[i] ?? '');
|
|
|
+ entries.push({ src: i, text: opts.trimTrailingWhitespace ? srcLines[i]?.replace(/[ \t]+$/, '') ?? '' : srcLines[i] ?? '' });
|
|
|
level = applyDelta(code, level, { casePending }).level;
|
|
|
casePending = applyDelta(code, level, { casePending }).casePending;
|
|
|
continue;
|
|
|
@@ -259,7 +373,7 @@ export function formatDocument(
|
|
|
|
|
|
if (code.length === 0 && startsHere.length === 0) {
|
|
|
blanks++;
|
|
|
- if (blanks <= opts.emptyLineLimit) out.push('');
|
|
|
+ if (blanks <= opts.emptyLineLimit) entries.push({ src: i, text: '' });
|
|
|
continue;
|
|
|
}
|
|
|
blanks = 0;
|
|
|
@@ -267,7 +381,7 @@ export function formatDocument(
|
|
|
if (code.length === 0) {
|
|
|
// Comment-only line: indent, no level change.
|
|
|
const parts = startsHere.map(c => firstFragment(c));
|
|
|
- out.push(indentOf(level) + parts.join(' '));
|
|
|
+ entries.push({ src: i, text: indentOf(level) + parts.join(' ') });
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
@@ -278,16 +392,24 @@ export function formatDocument(
|
|
|
for (const c of trailing) body += ' ' + firstFragment(c);
|
|
|
let line = indentOf(emitLevel) + body;
|
|
|
if (opts.trimTrailingWhitespace) line = line.replace(/[ \t]+$/, '');
|
|
|
- out.push(line);
|
|
|
+ entries.push({ src: i, text: line });
|
|
|
|
|
|
const applied = applyDelta(code, level, { casePending });
|
|
|
level = applied.level;
|
|
|
casePending = applied.casePending;
|
|
|
}
|
|
|
|
|
|
- if (!opts.insertFinalNewline) return out.join('\n').split('\n').join(eol);
|
|
|
- const stripped = out.join('\n').replace(/[ \t\n]+$/, '');
|
|
|
- return (stripped.length > 0 ? stripped + '\n' : '').split('\n').join(eol);
|
|
|
+ if (!opts.insertFinalNewline) return { entries, eol };
|
|
|
+ // Drop the trailing blank tail (entry-space twin of the old
|
|
|
+ // `join('\n').replace(/[ \t\n]+$/, '')`: trailing entries are
|
|
|
+ // whitespace-only exactly when the joined tail matches).
|
|
|
+ while (entries.length > 0 && /^[ \t]*$/.test(entries[entries.length - 1].text)) {
|
|
|
+ entries.pop();
|
|
|
+ }
|
|
|
+ if (entries.length > 0) {
|
|
|
+ entries[entries.length - 1].text = entries[entries.length - 1].text.replace(/[ \t]+$/, '');
|
|
|
+ }
|
|
|
+ return { entries, eol };
|
|
|
}
|
|
|
|
|
|
function firstFragment(c: RichToken): string {
|