ソースを参照

Format selection for Modula-2 editors

textDocument/rangeFormatting on top of the document formatter: the
core loop records each output line's source line, and formatRangeEdits
runs the full pipeline (exact indentation context) while emitting
whole-line replacements and deletions only inside the selection.
Empty selections format the cursor line; selections reaching EOF also
settle blank collapsing and the final newline.

Validation: full-document output byte-identical to pre-refactor code
across 21 files x 6 option sets; 60 range scenarios (partial, whole,
EOF, out-of-bounds, reversed) with tokens preserved, outside-range
lines untouched and re-format stability; whole-range application
reproduces the document formatter exactly; live LSP round-trip formats
exactly the selected lines.
Eric Streit 6 日 前
親
コミット
4814193de2

+ 9 - 1
extensions/modula2-language/FORMATTING.md

@@ -1,7 +1,9 @@
 # Modula-2 formatting
 
 `Format Document` (Shift+Alt+F) normalises a whole file through the
-language server (`textDocument/formatting`). Every rule below is settable
+language server (`textDocument/formatting`), and `Format Selection`
+does the same for the selected lines (`textDocument/rangeFormatting`).
+Every rule below is settable
 under `modula2.format.*` in Settings; changes apply live, no reload.
 
 ## What the formatter does
@@ -46,3 +48,9 @@ formatted output still compiles with `gm2`.
   editor's auto-uppercasing.
 - `CLIENT` tab/indent settings (`editor.tabSize`) do not drive Modula-2
   formatting; `modula2.format.*` always wins.
+- `Format Selection` formats whole lines intersecting the selection,
+  using full-document context (indentation levels depend on preceding
+  lines) while leaving everything outside the selection byte-identical —
+  including collapsing surplus blank lines and the final newline when
+  the selection reaches end of file. With an empty selection, the
+  cursor line is formatted.

+ 131 - 9
extensions/modula2-language/src/m2/format.ts

@@ -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 {

+ 24 - 1
extensions/modula2-language/src/server.ts

@@ -14,7 +14,7 @@ import { lex, Token } from './m2/lexer';
 import { M2Symbol, M2Unit, parseUnitText } from './m2/parser';
 import { analyseUnit } from './m2/analyse';
 import { computeSemanticTokens, semanticTokensLegend } from './m2/semantic';
-import { DEFAULT_FORMAT_OPTIONS, FormatOptions, formatDocument, normalizeFormatOptions } from './m2/format';
+import { DEFAULT_FORMAT_OPTIONS, FormatOptions, formatDocument, formatRangeEdits, normalizeFormatOptions } from './m2/format';
 import {
   moduleExports, recordFieldsOf, resolveName, toLocation, visibleSymbols,
   findReferencesInText, LocatedOccurrence,
@@ -44,6 +44,7 @@ connection.onInitialize((params: InitializeParams): InitializeResult => {
       referencesProvider: true,
       renameProvider: { prepareProvider: true },
       documentFormattingProvider: true,
+      documentRangeFormattingProvider: true,
       semanticTokensProvider: {
         legend: semanticTokensLegend(),
         full: true,
@@ -490,6 +491,28 @@ connection.onDocumentFormatting(params => {
   return [edit];
 });
 
+connection.onDocumentRangeFormatting(params => {
+  const document = documents.get(params.textDocument.uri);
+  if (!document) return null;
+  const filePath = uriToFilePath(document.uri);
+  try {
+    const edits = formatRangeEdits(
+      document.getText(), params.range.start.line, params.range.end.line,
+      formatOptions, filePath, path.dirname(filePath),
+    );
+    return edits.map((e): TextEdit => ({
+      range: {
+        start: { line: e.startLine, character: e.startCh },
+        end: { line: e.endLine, character: e.endCh },
+      },
+      newText: e.newText,
+    }));
+  } catch (error) {
+    connection.console.error(`range formatting failed: ${error instanceof Error ? error.message : String(error)}`);
+    return null;
+  }
+});
+
 connection.languages.semanticTokens.on(params => {
   const document = documents.get(params.textDocument.uri);
   if (!document) return { data: [] };