From ab2c19240fb2fe4c5ad7931854f331d1b511283c Mon Sep 17 00:00:00 2001 From: Phill Wolf Date: Sat, 8 Mar 2025 18:33:53 -0500 Subject: [PATCH 1/9] Subdivide reformatting whitespace edits at newline --- src/calva-fmt/src/format.ts | 22 +++++--------- src/calva-fmt/src/respacer.ts | 30 +++++++++++++++++-- .../unit/calva-fmt/respacer-test.ts | 16 +++++----- 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/src/calva-fmt/src/format.ts b/src/calva-fmt/src/format.ts index 09b83b2d9..903b65e5c 100644 --- a/src/calva-fmt/src/format.ts +++ b/src/calva-fmt/src/format.ts @@ -66,7 +66,7 @@ function rangeReformatChanges( const newText = healer.unbandage(healing, formattedHealedText); return originalText == newText ? [] - : respacer.whitespaceEdits(startIndex, originalText, newText); + : respacer.whitespaceEdits(eol, startIndex, originalText, newText); } } @@ -125,23 +125,17 @@ export function formatDocIndexInfo( if (!formatRange) { return; } + const eol = _convertEolNumToStringNotation(doc.eol); const formatted: { 'range-text': string; range: number[]; 'new-index': number; - } = formatIndex( - doc.getText(), - formatRange, - index, - _convertEolNumToStringNotation(doc.eol), - onType, - { - ...config.getConfigNow(), - ...extraConfig, - 'comment-form?': cursor.getFunctionName() === 'comment', - } - ); + } = formatIndex(doc.getText(), formatRange, index, eol, onType, { + ...config.getConfigNow(), + ...extraConfig, + 'comment-form?': cursor.getFunctionName() === 'comment', + }); const range: vscode.Range = new vscode.Range( doc.positionAt(formatted.range[0]), doc.positionAt(formatted.range[1]) @@ -152,7 +146,7 @@ export function formatDocIndexInfo( const changes = previousText == formattedText ? [] - : respacer.whitespaceEdits(doc.offsetAt(range.start), previousText, formattedText); + : respacer.whitespaceEdits(eol, doc.offsetAt(range.start), previousText, formattedText); return { formattedText: formattedText, range: range, diff --git a/src/calva-fmt/src/respacer.ts b/src/calva-fmt/src/respacer.ts index 1630b5c28..899e53dba 100644 --- a/src/calva-fmt/src/respacer.ts +++ b/src/calva-fmt/src/respacer.ts @@ -53,16 +53,40 @@ function spacedUnits(s: string): SpacedUnit[] { * Adjust a and b to the finest granularity of words * in either of them. */ -function alignSpacedUnits(a: SpacedUnit[], b: SpacedUnit[]): [SpacedUnit[], SpacedUnit[]] { +function alignSpacedUnits( + eol: string, + a: SpacedUnit[], + b: SpacedUnit[] +): [SpacedUnit[], SpacedUnit[]] { const a2 = [], b2 = []; while (a.length && b.length) { + // will consume a and b, eroding them with shift + // To the degree the next word in a and b is preceded by multi-line whitespace, + // subdivide it into lines, so that reformatting's changes to each + // of those lines will be a distinct text edit, + // in case cursors were located within the changed whitespace - + // we'd like VS Code to shift the cursors minimally. + while (true) { + const aSpaceFirstLineLength = a[0][0].indexOf(eol); + const bSpaceFirstLineLength = b[0][0].indexOf(eol); + if (aSpaceFirstLineLength == -1 || bSpaceFirstLineLength == -1) { + break; + } else { + a2.push([a[0][0].substring(0, aSpaceFirstLineLength), eol]); + b2.push([b[0][0].substring(0, bSpaceFirstLineLength), eol]); + a[0][0] = a[0][0].substring(aSpaceFirstLineLength + eol.length); + b[0][0] = b[0][0].substring(bSpaceFirstLineLength + eol.length); + } + } if (a[0][1] == b[0][1]) { + // same substance in a and b a2.push(a[0]); b2.push(b[0]); a.shift(); b.shift(); } else if (a[0][1].length < b[0][1].length) { + // a's substance is a prefix of b's const aWhole = a[0][1]; const bPart = b[0][1].slice(0, a[0][1].length); if (aWhole == bPart) { @@ -75,6 +99,7 @@ function alignSpacedUnits(a: SpacedUnit[], b: SpacedUnit[]): [SpacedUnit[], Spac return [undefined, undefined]; } } else { + // b's substance is a prefix of a's const bWhole = b[0][1]; const aPart = a[0][1].slice(0, b[0][1].length); if (bWhole == aPart) { @@ -102,6 +127,7 @@ function alignSpacedUnits(a: SpacedUnit[], b: SpacedUnit[]): [SpacedUnit[], Spac * @returns Whitespace changes to transform previousText to formattedText */ export function whitespaceEdits( + eol: string, offset: number, previousText: string, formattedText: string @@ -110,7 +136,7 @@ export function whitespaceEdits( const b = spacedUnits(formattedText); // A single word in a or b may have been split into multiple words in the other (eg at punctuation). // Adjust a and b to the finest granularity of words in either of them. - const [a2, b2] = alignSpacedUnits(a, b); + const [a2, b2] = alignSpacedUnits(eol, a, b); // The result should be an equal number of words in a and b: if (a2.length != b2.length) { console.error('Uneven words in a and b', 'a2', a2, 'b2', b2); diff --git a/src/extension-test/unit/calva-fmt/respacer-test.ts b/src/extension-test/unit/calva-fmt/respacer-test.ts index 68d43c324..ce249b7db 100644 --- a/src/extension-test/unit/calva-fmt/respacer-test.ts +++ b/src/extension-test/unit/calva-fmt/respacer-test.ts @@ -14,7 +14,7 @@ describe('respacer', () => { it('Inserts formatting space at the beginning', () => { const actual = docFromTextNotation('(def| foo 42)'); const expected = docFromTextNotation(' (def| foo 42)'); - const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + const spaceEdits = respacer.whitespaceEdits('\n', 0, getText(actual), getText(expected)); actual.model.editNow( spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } @@ -24,7 +24,7 @@ describe('respacer', () => { it('Inserts formatting space at the middle', () => { const actual = docFromTextNotation('(def foo|[42])'); const expected = docFromTextNotation(' (def foo| [42])'); - const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + const spaceEdits = respacer.whitespaceEdits('\n', 0, getText(actual), getText(expected)); actual.model.editNow( spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } @@ -34,7 +34,7 @@ describe('respacer', () => { it('Inserts formatting space at the end', () => { const actual = docFromTextNotation('(def foo| [42])'); const expected = docFromTextNotation('(def foo| [42]) '); - const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + const spaceEdits = respacer.whitespaceEdits('\n', 0, getText(actual), getText(expected)); actual.model.editNow( spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } @@ -44,7 +44,7 @@ describe('respacer', () => { it('Removes formatting space from the beginning', () => { const actual = docFromTextNotation(' (def foo| 42)'); const expected = docFromTextNotation('(def foo| 42)'); - const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + const spaceEdits = respacer.whitespaceEdits('\n', 0, getText(actual), getText(expected)); actual.model.editNow( spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } @@ -54,7 +54,7 @@ describe('respacer', () => { it('Removes formatting space from the middle', () => { const actual = docFromTextNotation('(def foo| 42)'); const expected = docFromTextNotation('(def foo| 42)'); - const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + const spaceEdits = respacer.whitespaceEdits('\n', 0, getText(actual), getText(expected)); actual.model.editNow( spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } @@ -64,7 +64,7 @@ describe('respacer', () => { it('Resizes formatting space in the middle', () => { const actual = docFromTextNotation('(def•foo| 42)'); const expected = docFromTextNotation('(def• foo| 42)'); - const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + const spaceEdits = respacer.whitespaceEdits('\n', 0, getText(actual), getText(expected)); actual.model.editNow( spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } @@ -74,7 +74,7 @@ describe('respacer', () => { it('Inserts, deletes, and resizes formatting space throughout', () => { const actual = docFromTextNotation(' (def•foo| 42)'); const expected = docFromTextNotation('(def• foo| 42) '); - const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + const spaceEdits = respacer.whitespaceEdits('\n', 0, getText(actual), getText(expected)); actual.model.editNow( spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } @@ -84,7 +84,7 @@ describe('respacer', () => { it('Preserves multiple cursors amidst formatting-space alterations', () => { const actual = docFromTextNotation(' |(def•f|2oo|3 42)'); const expected = docFromTextNotation('|(def• f|2oo|3 42 )'); - const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + const spaceEdits = respacer.whitespaceEdits('\n', 0, getText(actual), getText(expected)); actual.model.editNow( spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } From e0c6b302a7f538a741a96b3307ddc69adc01617e Mon Sep 17 00:00:00 2001 From: Phill Wolf Date: Sat, 8 Mar 2025 22:06:55 -0500 Subject: [PATCH 2/9] formatDocIndexRange using [-1,-1] as whole-doc sentinel to distinguish from a single form that happens to be coterminous with the document --- src/calva-fmt/src/format.ts | 48 +++++++++++++++++++++---------------- src/doc-mirror/index.ts | 2 +- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/calva-fmt/src/format.ts b/src/calva-fmt/src/format.ts index 903b65e5c..b4232d599 100644 --- a/src/calva-fmt/src/format.ts +++ b/src/calva-fmt/src/format.ts @@ -95,7 +95,7 @@ export async function formatRange(document: vscode.TextDocument, range: vscode.R return vscode.workspace.applyEdit(wsEdit); } -/** [start,end] of range to reformat with attention to offset 'index' */ +/** [start,end] of range to reformat with attention to offset 'index', or [-1,-1] to reformat the whole document */ export function formatDocIndexRange( doc: vscode.TextDocument, index: number, @@ -110,7 +110,7 @@ export function formatDocIndexRange( // If a top-level form "needs" formatting and is indented, reformat the whole document: const formatRangeSmall = _calculateFormatRange(extraConfig, cursor, index); - return formatRangeSmall ? formatRangeSmall : [0, doc.getText().length]; + return formatRangeSmall ? formatRangeSmall : [-1, -1]; } export function formatDocIndexInfo( @@ -122,7 +122,7 @@ export function formatDocIndexInfo( const mDoc = getDocument(doc); const cursor = mDoc.getTokenCursor(index); const formatRange = formatDocIndexRange(doc, index, extraConfig); - if (!formatRange) { + if (!formatRange || (formatRange[0] == -1 && formatRange[1] == -1)) { return; } const eol = _convertEolNumToStringNotation(doc.eol); @@ -223,23 +223,31 @@ export async function formatPosition( .map((sel) => doc.offsetAt(sel.active)) .map((index) => formatDocIndexRange(doc, index, extraConfig)) .filter((rng) => rng != undefined); - const dedupedRanges = nonOverlappingRanges(ranges); - const wholeDocRange: [number, number] = [0, doc.getText().length]; - const wholeDoc = - dedupedRanges.filter((r) => r[0] == wholeDocRange[0] && r[1] == wholeDocRange[1]).length > 0; - const orderedChanges: respacer.WhitespaceChange[] = wholeDoc - ? rangeReformatChanges( - doc, - new vscode.Range(doc.positionAt(wholeDocRange[0]), doc.positionAt(wholeDocRange[1])) - ) - : dedupedRanges - .map((rng) => rng[0]) - .flatMap((index) => { - const formattedInfo = formatDocIndexInfo(doc, onType, index, extraConfig); - return formattedInfo ? formattedInfo.changes : []; - }) - .sort((a, b) => b.start - a.start); - + const isWholeDoc = ranges.filter((r) => r[0] == -1 && r[1] == -1).length > 0; + let orderedChanges = undefined; + if (isWholeDoc) { + orderedChanges = rangeReformatChanges( + doc, + new vscode.Range(doc.positionAt(0), doc.positionAt(doc.getText().length)) + ); + } else { + const dedupedRanges = nonOverlappingRanges(ranges); + orderedChanges = dedupedRanges + .map((rng) => { + const cursorsInRange = editor.selections + .map((sel) => sel.active) + .map((point) => doc.offsetAt(point)) + .filter((offset) => offset >= rng[0] && offset < rng[1]); + return cursorsInRange.length > 0 ? cursorsInRange[0] : rng[0]; + }) + .flatMap((index) => { + // Find a cursor that might be in this block and pass it as the index to the reformatter. + // The reformatter may treat it specially, e.g., by not trimming it out of existence. + const formattedInfo = formatDocIndexInfo(doc, onType, index, extraConfig); + return formattedInfo ? formattedInfo.changes : []; + }) + .sort((a, b) => b.start - a.start); + } return editor.edit((textEditorEdit) => { let monotonicallyDecreasing = -1; orderedChanges.forEach((change) => { diff --git a/src/doc-mirror/index.ts b/src/doc-mirror/index.ts index ed7e48f5b..80f543e84 100644 --- a/src/doc-mirror/index.ts +++ b/src/doc-mirror/index.ts @@ -153,7 +153,7 @@ export class DocumentModel implements EditableModel { .flatMap((p) => { const doc = this.document.document; const formattedInfo = formatter.formatDocIndexInfo(doc, true, p); - return formattedInfo.changes; + return formattedInfo ? formattedInfo.changes : []; }) .filter( (function () { From abf8409306459d7991281713fa538877de626a24 Mon Sep 17 00:00:00 2001 From: Phill Wolf Date: Sat, 8 Mar 2025 22:14:06 -0500 Subject: [PATCH 3/9] Reapply "Merge pull request #2741 from pbwolf/2738-multicursor-reformat-C" This reverts commit 458d9d4676697fe702923d4623166baee8fb477c. --- src/calva-fmt/src/format.ts | 166 +++++++------ src/calva-fmt/src/respacer.ts | 133 +++++++++++ src/cursor-doc/model.ts | 85 +++---- src/cursor-doc/paredit.ts | 76 +++--- src/doc-mirror/index.ts | 220 ++++++++++++++++-- .../unit/calva-fmt/respacer-test.ts | 94 ++++++++ 6 files changed, 587 insertions(+), 187 deletions(-) create mode 100644 src/calva-fmt/src/respacer.ts create mode 100644 src/extension-test/unit/calva-fmt/respacer-test.ts diff --git a/src/calva-fmt/src/format.ts b/src/calva-fmt/src/format.ts index c8e608a18..09b83b2d9 100644 --- a/src/calva-fmt/src/format.ts +++ b/src/calva-fmt/src/format.ts @@ -1,9 +1,15 @@ import * as vscode from 'vscode'; import * as config from '../../formatter-config'; import * as outputWindow from '../../repl-window/repl-doc'; -import { getIndent, getDocumentOffset, getDocument } from '../../doc-mirror/index'; +import { + getIndent, + getDocumentOffset, + getDocument, + nonOverlappingRanges, +} from '../../doc-mirror/index'; import { formatTextAtRange, formatText, jsify } from '../../../out/cljs-lib/cljs-lib'; import * as util from '../../utilities'; +import * as respacer from './respacer'; import * as cursorDocUtils from '../../cursor-doc/utilities'; import { isUndefined, cloneDeep } from 'lodash'; import { LispTokenCursor } from '../../cursor-doc/token-cursor'; @@ -11,11 +17,6 @@ import { formatIndex } from './format-index'; import * as state from '../../state'; import * as healer from './healer'; -const FormatDepthDefaults = { - deftype: 2, - defprotocol: 2, -}; - export async function indentPosition(position: vscode.Position, document: vscode.TextDocument) { const editor = util.getActiveTextEditor(); const pos = new vscode.Position(position.line, 0); @@ -50,10 +51,10 @@ export async function indentPosition(position: vscode.Position, document: vscode } } -export function formatRangeEdits( +function rangeReformatChanges( document: vscode.TextDocument, originalRange: vscode.Range -): vscode.TextEdit[] | undefined { +): respacer.WhitespaceChange[] | undefined { const mirrorDoc = getDocument(document); const startIndex = document.offsetAt(originalRange.start); const cursor = mirrorDoc.getTokenCursor(startIndex); @@ -63,10 +64,24 @@ export function formatRangeEdits( const healing = healer.bandage(originalText, originalRange.start.character, eol); const formattedHealedText = formatCode(healing.healedText, document.eol); const newText = healer.unbandage(healing, formattedHealedText); - return [vscode.TextEdit.replace(originalRange, newText)]; + return originalText == newText + ? [] + : respacer.whitespaceEdits(startIndex, originalText, newText); } } +export function formatRangeEdits( + document: vscode.TextDocument, + originalRange: vscode.Range +): vscode.TextEdit[] | undefined { + return rangeReformatChanges(document, originalRange).map((chg) => + vscode.TextEdit.replace( + new vscode.Range(document.positionAt(chg.start), document.positionAt(chg.end)), + chg.text + ) + ); +} + export async function formatRange(document: vscode.TextDocument, range: vscode.Range) { const wsEdit: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); const edits = formatRangeEdits(document, range); @@ -80,24 +95,33 @@ export async function formatRange(document: vscode.TextDocument, range: vscode.R return vscode.workspace.applyEdit(wsEdit); } -export function formatPositionInfo( - editor: vscode.TextEditor, - onType: boolean = false, - extraConfig: CljFmtConfig = {} -) { - const doc: vscode.TextDocument = editor.document; - const index = doc.offsetAt(editor.selections[0].active); +/** [start,end] of range to reformat with attention to offset 'index' */ +export function formatDocIndexRange( + doc: vscode.TextDocument, + index: number, + extraConfig: CljFmtConfig +): [number, number] { const mDoc = getDocument(doc); - if (mDoc.model.documentVersion != doc.version) { - console.warn( - 'Model for formatPositionInfo is out of sync with document; will not reformat now' - ); + console.warn('Model is stale; skipping reformatting'); return; } const cursor = mDoc.getTokenCursor(index); - const formatRange = _calculateFormatRange(extraConfig, cursor, index); + // If a top-level form "needs" formatting and is indented, reformat the whole document: + const formatRangeSmall = _calculateFormatRange(extraConfig, cursor, index); + return formatRangeSmall ? formatRangeSmall : [0, doc.getText().length]; +} + +export function formatDocIndexInfo( + doc: vscode.TextDocument, + onType: boolean, + index: number, + extraConfig: CljFmtConfig = {} +) { + const mDoc = getDocument(doc); + const cursor = mDoc.getTokenCursor(index); + const formatRange = formatDocIndexRange(doc, index, extraConfig); if (!formatRange) { return; } @@ -124,12 +148,18 @@ export function formatPositionInfo( ); const newIndex: number = doc.offsetAt(range.start) + formatted['new-index']; const previousText: string = doc.getText(range); + const formattedText = formatted['range-text']; + const changes = + previousText == formattedText + ? [] + : respacer.whitespaceEdits(doc.offsetAt(range.start), previousText, formattedText); return { - formattedText: formatted['range-text'], + formattedText: formattedText, range: range, previousText: previousText, previousIndex: index, newIndex: newIndex, + changes: changes, }; } @@ -139,18 +169,21 @@ interface CljFmtConfig { 'remove-multiple-non-indenting-spaces?'?: boolean; } +/** [Start,end] of the range to reformat around the cursor, with special cases: + * - Undefined if not within a top-level form. + * - Undefined if the form to reformat would be a top-level form that is indented. + */ function _calculateFormatRange( config: CljFmtConfig, cursor: LispTokenCursor, index: number ): [number, number] { - const formatDepth = config?.['format-depth'] ?? _formatDepth(cursor); const rangeForTopLevelForm = cursor.rangeForDefun(index, false); if (!rangeForTopLevelForm) { return; } const topLevelStartCursor = cursor.doc.getTokenCursor(rangeForTopLevelForm[0]); - const rangeForList = cursor.rangeForList(formatDepth); + const rangeForList = cursor.rangeForList(1); if (rangeForList) { if (rangeForList[0] === rangeForTopLevelForm[0]) { if (topLevelStartCursor.rowCol[1] !== 0) { @@ -186,66 +219,47 @@ function _calculateFormatRange( } } -function _formatDepth(cursor: LispTokenCursor) { - const cursorClone = cursor.clone(); - cursorClone.backwardFunction(1); - return FormatDepthDefaults?.[cursorClone.getFunctionName()] ?? 1; -} - export async function formatPosition( editor: vscode.TextEditor, onType: boolean = false, extraConfig: CljFmtConfig = {} ): Promise { - // Stop trying if ever the document version changes - don't want to trample User's work - const doc: vscode.TextDocument = editor.document, - documentVersion = editor.document.version, - formattedInfo = formatPositionInfo(editor, onType, extraConfig); - if (documentVersion != editor.document.version) { - return; - } else if (formattedInfo && formattedInfo.previousText != formattedInfo.formattedText) { - return editor - .edit( - (textEditorEdit) => { - textEditorEdit.replace(formattedInfo.range, formattedInfo.formattedText); - }, - { undoStopAfter: false, undoStopBefore: false } + const doc: vscode.TextDocument = editor.document; + const ranges = editor.selections + .map((sel) => doc.offsetAt(sel.active)) + .map((index) => formatDocIndexRange(doc, index, extraConfig)) + .filter((rng) => rng != undefined); + const dedupedRanges = nonOverlappingRanges(ranges); + const wholeDocRange: [number, number] = [0, doc.getText().length]; + const wholeDoc = + dedupedRanges.filter((r) => r[0] == wholeDocRange[0] && r[1] == wholeDocRange[1]).length > 0; + const orderedChanges: respacer.WhitespaceChange[] = wholeDoc + ? rangeReformatChanges( + doc, + new vscode.Range(doc.positionAt(wholeDocRange[0]), doc.positionAt(wholeDocRange[1])) ) - .then((onFulfilled: boolean) => { - if (onFulfilled) { - if (documentVersion + 1 == editor.document.version) { - editor.selections = [ - new vscode.Selection( - doc.positionAt(formattedInfo.newIndex), - doc.positionAt(formattedInfo.newIndex) - ), - ]; - } - } - return onFulfilled; - }); - } else if (formattedInfo) { - return new Promise((resolve, _reject) => { - if (formattedInfo.newIndex != formattedInfo.previousIndex) { - editor.selections = [ - new vscode.Selection( - doc.positionAt(formattedInfo.newIndex), - doc.positionAt(formattedInfo.newIndex) - ), - ]; + : dedupedRanges + .map((rng) => rng[0]) + .flatMap((index) => { + const formattedInfo = formatDocIndexInfo(doc, onType, index, extraConfig); + return formattedInfo ? formattedInfo.changes : []; + }) + .sort((a, b) => b.start - a.start); + + return editor.edit((textEditorEdit) => { + let monotonicallyDecreasing = -1; + orderedChanges.forEach((change) => { + const pos1 = doc.positionAt(change.start); + const pos2 = doc.positionAt(change.end); + // with multiple cursors, especially near each other, the edits may overlap. + // VS Code rejects overlapping edits. Skip them: + if (monotonicallyDecreasing == -1 || change.end < monotonicallyDecreasing) { + const range = new vscode.Range(pos1, pos2); + textEditorEdit.replace(range, change.text); + monotonicallyDecreasing = change.start; } - resolve(true); - }); - } else if (!onType && !outputWindow.isResultsDoc(doc)) { - return formatRange( - doc, - new vscode.Range(doc.positionAt(0), doc.positionAt(doc.getText().length)) - ); - } else { - return new Promise((resolve, _reject) => { - resolve(true); }); - } + }); } // Debounce format-as-you-type and toss it aside if User seems still to be working diff --git a/src/calva-fmt/src/respacer.ts b/src/calva-fmt/src/respacer.ts new file mode 100644 index 000000000..1630b5c28 --- /dev/null +++ b/src/calva-fmt/src/respacer.ts @@ -0,0 +1,133 @@ +/** + * @module + * Adapter between a code reformatter and a multi-cursor VS Code document, + * that finds a set of whitespace edits to transform an original code + * block to an edited code block - on the understanding that the formatter + * intends to change only whitespace and that changing more than the minimum + * may mess up the document's cursors. + */ + +/** One step in transforming an unformatted + * document fragment to a formatted one by adjusting whitespace. + * start and end are offsets into the original document. + */ +export type WhitespaceChange = { + start: number; + end: number; + text: string; +}; + +/** Whitespace and substance that immediately follows it. + * Pre-format and re-formatted text can be expressed + * as an array of SpacedUnit. The substance members + * of the pre- and re-formatted arrays can be aligned, + * then changes in whitespace size can be translated to edits. + */ +type SpacedUnit = [spaces: string, stuff: string]; + +/** Array of [spaces, nonspaces] which if concatenated would equal s. + * In the Clojure custom, recognizes comma and JS regex \s as spaces. + */ +function spacedUnits(s: string): SpacedUnit[] { + const frags = s.match(/[\s,]+|[^\s,]+/g); + // Ensure 1st item is of whitespace: + if (frags[0].match(/[^\s,]/)) { + frags.unshift(''); + } + // Ensure last item is of non-whitespace stuff: + if (frags.length % 2) { + frags.push(''); + } + // Partition items into [space, stuff] pairs: + const units = []; + for (let i = 0; i < frags.length; i += 2) { + units.push([frags[i], frags[i + 1]]); + } + // Pad the end - in case the reformatting adds spaces to the end: + units.push(['', '']); + return units; +} + +/** A single word in string a or b may have been split into + * multiple words in the other (eg at punctuation). + * Adjust a and b to the finest granularity of words + * in either of them. + */ +function alignSpacedUnits(a: SpacedUnit[], b: SpacedUnit[]): [SpacedUnit[], SpacedUnit[]] { + const a2 = [], + b2 = []; + while (a.length && b.length) { + if (a[0][1] == b[0][1]) { + a2.push(a[0]); + b2.push(b[0]); + a.shift(); + b.shift(); + } else if (a[0][1].length < b[0][1].length) { + const aWhole = a[0][1]; + const bPart = b[0][1].slice(0, a[0][1].length); + if (aWhole == bPart) { + a2.push(a[0]); + a.shift(); + b2.push([b[0][0], bPart]); + b[0] = ['', b[0][1].slice(aWhole.length)]; + } else { + console.error('alignSpacedUnits: a/b mismatch wherein a is shorter'); + return [undefined, undefined]; + } + } else { + const bWhole = b[0][1]; + const aPart = a[0][1].slice(0, b[0][1].length); + if (bWhole == aPart) { + b2.push(b[0]); + b.shift(); + a2.push([a[0][0], aPart]); + a[0] = ['', a[0][1].slice(bWhole.length)]; + } else { + console.error('alignSpacedUnits: a/b mismatch wherein b is shorter'); + return [undefined, undefined]; + } + } + } + return [a2, b2]; +} + +/** + * Edits to transform previousText to formattedText (which differ + * only by whitespace). + * Edits are ordered from end- to start-of-document. + * + * @param offset of previousText in the document + * @param previousText unformatted text + * @param formattedText formatted version of previousText, which differs only in whitespace + * @returns Whitespace changes to transform previousText to formattedText + */ +export function whitespaceEdits( + offset: number, + previousText: string, + formattedText: string +): WhitespaceChange[] { + const a = spacedUnits(previousText); + const b = spacedUnits(formattedText); + // A single word in a or b may have been split into multiple words in the other (eg at punctuation). + // Adjust a and b to the finest granularity of words in either of them. + const [a2, b2] = alignSpacedUnits(a, b); + // The result should be an equal number of words in a and b: + if (a2.length != b2.length) { + console.error('Uneven words in a and b', 'a2', a2, 'b2', b2); + return []; + } + const ret: WhitespaceChange[] = []; + let aPos = offset; + for (let i = 0; i < a2.length; i++) { + const aSpaces = a2[i][0]; + const bSpaces = b2[i][0]; + if (aSpaces != bSpaces) { + const start: number = aPos; + const end: number = aPos + aSpaces.length; + const text: string = bSpaces; + ret.unshift({ start, end, text }); + } + aPos += a2[i][0].length + a2[i][1].length; + } + return ret; +} diff --git a/src/cursor-doc/model.ts b/src/cursor-doc/model.ts index 88cd9fbae..d6d88e52d 100644 --- a/src/cursor-doc/model.ts +++ b/src/cursor-doc/model.ts @@ -241,7 +241,6 @@ export class ModelEditSelection { export type ModelEditOptions = { undoStopBefore?: boolean; - formatDepth?: number; skipFormat?: boolean; selections?: ModelEditSelection[]; builder?: TextEditorEdit; @@ -290,37 +289,48 @@ export interface EditableDocument { // An editing transaction - array of ModelEdit - shifts the selection(s) // to compensate for insertions or deletions to their left. // Here we predict how edits will affect selections. -const selectionsAfterEdits = (function () { - const decodeChangeRange = function (edit): [any, any] { - return [edit.args[0], edit.args[2].length - (edit.args[1] - edit.args[0])]; +export const selectionsAfterEdits = (function () { + // 'Decoders' of ModelEdit: + // [threshold, point, change-in-size] + const decodeChangeRange = function (edit): [number, number, number] { + const delta = edit.args[2].length - (edit.args[1] - edit.args[0]); + const inserted = delta > 0 ? edit.args[2] : undefined; + const lastInsertedChar = !inserted || inserted == '' ? '' : inserted[inserted.length - 1]; + const point = edit.args[0]; + const threshold = ['(', '[', '{', '#{'].includes(lastInsertedChar) ? point - 1 : point; + return [threshold, point, delta]; }; - const decodeDeleteRange = function (edit): [any, any] { - return [edit.args[0], 0 - edit.args[1]]; + const decodeDeleteRange = function (edit): [number, number, number] { + return [edit.args[0], edit.args[0], 0 - edit.args[1]]; }; - const decodeInsertString = function (edit): [any, any] { - return [edit.args[0] + edit.args[1].length, edit.args[1].length]; + const decodeInsertString = function (edit): [number, number, number] { + return [edit.args[0] - 1, edit.args[0] + edit.args[1].length, edit.args[1].length]; }; - const bump = function (n, [point, delta]) { - return n != undefined ? (n > point ? n + delta : n) : undefined; + const bump = function (n: number, [threshold, point, delta]) { + if (n == undefined) { + return undefined; + } else { + return n > threshold ? Math.max(n + delta, point) : n; + } }; - return function (edits, selections: ModelEditSelection[]) { + return function (edits: ModelEdit[], selections: ModelEditSelection[]) { // The ModelEdit array is in order by end-of-doc to start. // Traverse it, bumping selections // according to the growth or shrinkage of each edit. let monotonicallyDecreasing = -1; // check edit order let retSelections: ModelEditSelection[] = [...selections]; for (let ic = 0; ic < edits.length; ic++) { - const affected: [any, any] = + const affected: [number, number, number] = edits[ic].editFn == 'deleteRange' ? decodeDeleteRange(edits[ic]) : edits[ic].editFn == 'changeRange' ? decodeChangeRange(edits[ic]) : decodeInsertString(edits[ic]); - const [point, delta] = affected; - if (monotonicallyDecreasing != -1 && point >= monotonicallyDecreasing) { + const [threshold, point, delta] = affected; + if (monotonicallyDecreasing != -1 && point > monotonicallyDecreasing) { console.error( 'Edits not back-to-front. Inference of resulting selection might be inaccurate' - ); // TBD take the time to sort? or should commands emit edits in back-to-front order? + ); } monotonicallyDecreasing = point; if (delta != 0) { @@ -599,51 +609,38 @@ export class LineInputModel implements EditableModel { } editNow(edits: ModelEdit[], options: ModelEditOptions): void { - const ultimateSelections = this.editTextNow(edits, options); + this.editTextNow(edits, options); if (this.document && options.selections) { this.document.selections = options.selections; } else { - // Mimic TextEditorEdit, which leaves the selection at the end of the insertion or start of deletion: - if (this.document && ultimateSelections) { - this.document.selections = ultimateSelections; + if (this.document) { + this.document.selections = selectionsAfterEdits(edits, this.document.selections); } } } - // Returns the selection that would mimic TextEditorEdit - editTextNow( - edits: ModelEdit[], - options: ModelEditOptions - ): ModelEditSelection[] { - let ultimateSelections = undefined; + editTextNow(edits: ModelEdit[], options: ModelEditOptions): void { for (const edit of edits) { switch (edit.editFn) { case 'insertString': { const fn = this.insertString; - ultimateSelections = this.insertString( - ...(edit.args.slice(0, 4) as Parameters) - ); + this.insertString(...(edit.args.slice(0, 4) as Parameters)); break; } case 'changeRange': { const fn = this.changeRange; - ultimateSelections = this.changeRange( - ...(edit.args.slice(0, 5) as Parameters) - ); + this.changeRange(...(edit.args.slice(0, 5) as Parameters)); break; } case 'deleteRange': { const fn = this.deleteRange; - ultimateSelections = this.deleteRange( - ...(edit.args.slice(0, 5) as Parameters) - ); + this.deleteRange(...(edit.args.slice(0, 5) as Parameters)); break; } default: break; } } - return ultimateSelections; } /** @@ -663,12 +660,9 @@ export class LineInputModel implements EditableModel { text: string, oldSelection?: ModelEditRange, newSelection?: ModelEditRange - ): ModelEditSelection[] { - const t1 = new Date(); - + ): void { const startPos = Math.min(start, end); const endPos = Math.max(start, end); - const deletedText = this.recordingUndo ? this.getText(startPos, endPos) : ''; const [startLine, startCol] = this.getRowCol(startPos); const [endLine, endCol] = this.getRowCol(endPos); // extract the lines we will replace @@ -715,11 +709,6 @@ export class LineInputModel implements EditableModel { this.changedLines.add(startLine + i); this.markDirty(startLine + i); } - - // console.log("Parsing took: ", new Date().valueOf() - t1.valueOf()); - - // To mimic TextEditorEdit: No change to selection by default: - return undefined; } /** @@ -737,10 +726,8 @@ export class LineInputModel implements EditableModel { text: string, oldSelection?: ModelEditRange, newSelection?: ModelEditRange - ): ModelEditSelection[] { + ): void { this.changeRange(offset, offset, text); - // To mimic TextEditorEdit: selection moves to end of insertion, by default - return [new ModelEditSelection(offset + text.length)]; } /** @@ -757,10 +744,8 @@ export class LineInputModel implements EditableModel { count: number, oldSelection?: ModelEditRange, newSelection?: ModelEditRange - ): ModelEditSelection[] { + ): void { this.changeRange(offset, offset + count, ''); - // To mimic TextEditorEdit: selection moves to start of deletion, by default - return [new ModelEditSelection(offset)]; } /** Return the offset of the last character in this model. */ diff --git a/src/cursor-doc/paredit.ts b/src/cursor-doc/paredit.ts index 31137531e..6a97cc69b 100644 --- a/src/cursor-doc/paredit.ts +++ b/src/cursor-doc/paredit.ts @@ -751,9 +751,7 @@ export function rewrapSexpr( const editsToApply = _(uniqEdits) .sortBy((e) => -e.args[0]) .value(); - return doc.model.edit(editsToApply, { - //skipFormat: selections.length > 1, // reformat-as-you-type works with only 1 selection - }); + return doc.model.edit(editsToApply, {}); } export async function splitSexp(doc: EditableDocument, start: number = doc.selections[0].active) { @@ -804,7 +802,7 @@ export async function joinSexp( [prevEnd, prevEnd], ]), ], - { selections: [new ModelEditSelection(prevEnd)], formatDepth: 2 } + { selections: [new ModelEditSelection(prevEnd)] } ); } } @@ -868,7 +866,7 @@ export async function killForwardList(doc: EditableDocument, [start, end]: [numb export async function forwardSlurpSexp( doc: EditableDocument, start: number = doc.selections[0].active, - extraOpts = { formatDepth: 1 } + extraOpts = {} ) { const cursor = doc.getTokenCursor(start); cursor.forwardList(); @@ -902,10 +900,7 @@ export async function forwardSlurpSexp( } ); } else { - const formatDepth = extraOpts['formatDepth'] ? extraOpts['formatDepth'] : 1; - return forwardSlurpSexp(doc, cursor.offsetStart, { - formatDepth: formatDepth + 1, - }); + return forwardSlurpSexp(doc, cursor.offsetStart, {}); } } } @@ -938,10 +933,7 @@ export async function backwardSlurpSexp( } ); } else { - const formatDepth = extraOpts['formatDepth'] ? extraOpts['formatDepth'] : 1; - return backwardSlurpSexp(doc, cursor.offsetStart, { - formatDepth: formatDepth + 1, - }); + return backwardSlurpSexp(doc, cursor.offsetStart, {}); } } } @@ -955,20 +947,23 @@ export async function forwardBarfSexp( if (cursor.getToken().type == 'close') { const offset = cursor.offsetStart, close = cursor.getToken().raw; + const insideEndOfList = cursor.clone(); cursor.backwardSexp(true, true); - cursor.backwardWhitespace(); - return doc.model.edit( - [ - new ModelEdit('deleteRange', [offset, close.length]), - new ModelEdit('insertString', [cursor.offsetStart, close]), - ], - start >= cursor.offsetStart - ? { - selections: [new ModelEditSelection(cursor.offsetStart)], - formatDepth: 2, - } - : { formatDepth: 2 } - ); + // Avoid overlapping deletion and insertion when the list is already empty: + if (cursor.offsetStart != insideEndOfList.offsetStart) { + cursor.backwardWhitespace(); + return doc.model.edit( + [ + new ModelEdit('deleteRange', [offset, close.length]), + new ModelEdit('insertString', [cursor.offsetStart, close]), + ], + start >= cursor.offsetStart + ? { + selections: [new ModelEditSelection(cursor.offsetStart)], + } + : {} + ); + } } } @@ -984,20 +979,23 @@ export async function backwardBarfSexp( const offset = cursor.offsetStart; const close = cursor.getToken().raw; cursor.next(); + const insideStartOfList = cursor.clone(); cursor.forwardSexp(true, true); - cursor.forwardWhitespace(false); - return doc.model.edit( - [ - new ModelEdit('changeRange', [cursor.offsetStart, cursor.offsetStart, close]), - new ModelEdit('deleteRange', [offset, tk.raw.length]), - ], - start <= cursor.offsetStart - ? { - selections: [new ModelEditSelection(cursor.offsetStart)], - formatDepth: 2, - } - : { formatDepth: 2 } - ); + // Avoid overlapping edits when the list is already empty + if (insideStartOfList.offsetStart != cursor.offsetStart) { + cursor.forwardWhitespace(false); + return doc.model.edit( + [ + new ModelEdit('changeRange', [cursor.offsetStart, cursor.offsetStart, close]), + new ModelEdit('deleteRange', [offset, tk.raw.length]), + ], + start <= cursor.offsetStart + ? { + selections: [new ModelEditSelection(cursor.offsetStart)], + } + : {} + ); + } } } diff --git a/src/doc-mirror/index.ts b/src/doc-mirror/index.ts index 65175bbfe..ed7e48f5b 100644 --- a/src/doc-mirror/index.ts +++ b/src/doc-mirror/index.ts @@ -2,6 +2,7 @@ export { getIndent } from '../cursor-doc/indent'; import * as vscode from 'vscode'; import * as utilities from '../utilities'; import * as formatter from '../calva-fmt/src/format'; +import * as respacer from '../calva-fmt/src/respacer'; import { LispTokenCursor } from '../cursor-doc/token-cursor'; import { ModelEdit, @@ -9,13 +10,82 @@ import { EditableModel, ModelEditOptions, LineInputModel, + ModelEditRange, ModelEditSelection, ModelEditFunction, + selectionsAfterEdits, } from '../cursor-doc/model'; -import { isUndefined } from 'lodash'; +import { isUndefined, sortedUniq } from 'lodash'; const documents = new Map(); +/** Non-nested ranges (favoring long ranges). + * The input ranges reflect forms, so they might overlap but only by nesting. + */ +export function nonOverlappingRanges(ranges: ModelEditRange[]): ModelEditRange[] { + const listRanges: ModelEditRange[] = ranges.sort( + (a: ModelEditRange, b: ModelEditRange) => b[1] - b[0] - (a[1] - a[0]) + ); + // Discard ranges embedded in other ranges. O(n^2) + // -Traverse the list once, for 'outer ranges', in order longest range to shortest. + // -At each step, traverse the remainder of the list once for 'inner ranges', + // discarding inner ranges included in the outer range. + // -Instead of moving array elements, just mark the bad ones using start=-1. + for (let i = 0; i < listRanges.length; i++) { + const outerRange = listRanges[i]; + if (outerRange[0] != -1) { + for (let j = i + 1; j < listRanges.length; j++) { + const innerRange = listRanges[j]; + if (innerRange[0] != -1) { + if (innerRange[0] >= outerRange[0] && innerRange[1] <= outerRange[1]) { + listRanges[j][0] = -1; + } + } + } + } + } + const disjointListRanges = listRanges.filter((r: ModelEditRange) => r[0] != -1); + return disjointListRanges; +} + +/** + * Ranges-to-reformat in a post-edit document, capturing disjoint lists surrounding the given edits, + * undefined if the top-level needs reformatting. + * Positions in edits are relative to the document *before* any of the edits are applied. + */ +const reformatListRangesForEdits = (function () { + // 'Decoders' of the [start, end] outer bounds of the new content inserted by a ModelEdit + const pointsChangeRange = function (edit: ModelEdit<'changeRange'>): number[] { + return [edit.args[0], edit.args[1]]; + }; + const pointsDeleteRange = function (edit: ModelEdit<'deleteRange'>): number[] { + return [edit.args[0], edit.args[0]]; + }; + const pointsInsertString = function (edit: ModelEdit<'insertString'>): number[] { + return [edit.args[0], edit.args[0] + edit.args[1].length]; + }; + const pointsModelEdit = function (edit: ModelEdit): number[] { + const e: any = edit; + return edit.editFn == 'deleteRange' + ? pointsDeleteRange(e) + : edit.editFn == 'changeRange' + ? pointsChangeRange(e) + : pointsInsertString(e); + }; + + return function (model: DocumentModel, edits: ModelEdit[]): ModelEditRange[] { + // (The edits' positions are as-of the moment *before* application of the edits.) + // Translate each edit to a start- and end-point of new content. + // Translate those points to start- and end-points of sexprs. + // Compute disjoint ranges. + const listRanges1: ModelEditRange[] = edits + .flatMap(pointsModelEdit) + .map((n: number) => model.getTokenCursor(n).rangeForList(1)); + const wholeDoc = listRanges1.filter((x) => x == undefined).length > 0; + return wholeDoc ? undefined : nonOverlappingRanges(listRanges1); + }; +})(); + export class DocumentModel implements EditableModel { readonly lineEndingLength: number; lineInputModel: LineInputModel; @@ -73,35 +143,141 @@ export class DocumentModel implements EditableModel { } if (!options.skipFormat) { const editor = utilities.getActiveTextEditor(); - void formatter.scheduleFormatAsType(editor, { - 'format-depth': options.formatDepth ?? 1, - }); + void formatter.scheduleFormatAsType(editor, {}); } } - edit(modelEdits: ModelEdit[], options: ModelEditOptions): Thenable { - const editor = utilities.getActiveTextEditor(), - undoStopBefore = !!options.undoStopBefore; - return editor - .edit( - (builder) => { - this.editNowTextOnly(modelEdits, { builder: builder, ...options }); - }, - { undoStopBefore, undoStopAfter: false } - ) - .then((isFulfilled) => { - if (isFulfilled) { - if (options.selections) { - this.document.selections = options.selections; + private postEditReformat(editor: vscode.TextEditor, offsets: number[]): Thenable { + // Now that the document has been edited, calculate the reformatting: + const reformatChange: respacer.WhitespaceChange[] = sortedUniq(offsets.sort((a, b) => a - b)) + .flatMap((p) => { + const doc = this.document.document; + const formattedInfo = formatter.formatDocIndexInfo(doc, true, p); + return formattedInfo.changes; + }) + .filter( + (function () { + // With multiple cursors, the reformat edits might overlap. + // VS Code rejects an edit transaction if any operations overlap. + // Remove overlapping edits: + let monotonicallyDecreasing = -1; + return function (change: respacer.WhitespaceChange) { + if (change.end < change.start) { + console.error('Backwards change!'); + return false; + } + if (monotonicallyDecreasing == -1 || change.end <= monotonicallyDecreasing) { + monotonicallyDecreasing = change.start; + return true; + } else { + return false; + } + }; + })() + ); + const doc = this.document.document; + // Do an edit transaction, even if insubstantial, just for the undoStopAfter=true. + return editor.edit( + (textEditorEdit) => { + let monotonicallyDecreasing = -1; + let prior = undefined; // weed out adjacent duplicates + reformatChange.forEach((change) => { + // with multiple cursors, especially near each other, the edits may overlap. + // VS Code rejects overlapping edits. Skip them: + if (monotonicallyDecreasing == -1 || change.end <= monotonicallyDecreasing) { + const pos1 = doc.positionAt(change.start); + const pos2 = doc.positionAt(change.end); + if (prior == undefined || prior.start != change.start || prior.end != change.end) { + prior = change; + const range = new vscode.Range(pos1, pos2); + textEditorEdit.replace(range, change.text); + monotonicallyDecreasing = change.start; + } + } else { + console.warn('Reformat is still out-of-order'); } - if (!options.skipFormat) { - return formatter.formatPosition(editor, true, { - 'format-depth': options.formatDepth ?? 1, - }); + }); + }, + // undoStopBefore, to fall in the same undo unit as the preceding edit. + { undoStopBefore: false, undoStopAfter: true } + ); + } + + edit(modelEdits: ModelEdit[], options: ModelEditOptions): Thenable { + // undoStopBefore===false joins this edit with the prior one in a single undoable unit. + const undoStopBefore = !(options.undoStopBefore === false); + // Nothing to do? + if (!modelEdits || modelEdits.length == 0) { + return Promise.resolve(true); + } + // Reformatting will retouch the spots affected by edits. + // The edits are stated in terms of the document-as-it-is, before any of the edits. + // Reformat's offsets must be in post-edit terms (i.e., "a later as-is", before reformatting). + // Translate pre-edit to post-edit offsets: + const surgicalRanges: ModelEditRange[] = reformatListRangesForEdits(this, modelEdits); + const rangesOrWhole: ModelEditRange[] = surgicalRanges + ? surgicalRanges + : [[0, this.document.document.getText().length]]; + const postEditPlanDraft = { + forDocumentVersion: this.document.document.version + 1, // none of this matters if another edit intervenes + reformatOffsets: options.skipFormat + ? undefined + : selectionsAfterEdits( + modelEdits, + rangesOrWhole.flatMap((r: ModelEditRange): ModelEditSelection[] => { + return [ + new ModelEditSelection(r[0], r[0], r[0], r[0]), + new ModelEditSelection(r[1], r[1], r[1], r[1]), + ]; + }) + ).flatMap((sel) => [sel.anchor, sel.active]), + selections: options.selections, + }; + const postEditPlan = + postEditPlanDraft.reformatOffsets || postEditPlanDraft.selections + ? postEditPlanDraft + : undefined; + // Do the edits (with undoStopAfter=false if we will reformat, + // to include the reformatting in the same undo-unit as the edit). + const editor = utilities.getActiveTextEditor(); + const editCompletion = editor.edit( + (builder) => { + this.editNowTextOnly(modelEdits, { builder: builder, ...options }); + }, + { undoStopAfter: options.skipFormat, undoStopBefore } + ); + if (!postEditPlan) { + return editCompletion; + } else { + return editCompletion.then((isFulfilled) => { + if (!isFulfilled) { + console.warn('Edit was not fulfilled'); + } else { + if (postEditPlan.forDocumentVersion != this.document.document.version) { + console.warn('Post-edit preempted by another edit'); + } else { + // 1. Apply selection overrides. + // 2. Reformat, adjusting the new selections. + if (postEditPlan.selections) { + this.document.selections = postEditPlan.selections; + } + if (postEditPlan.reformatOffsets) { + { + return this.postEditReformat(editor, postEditPlan.reformatOffsets).then( + (reformatFulfilled) => { + if (!reformatFulfilled) { + console.warn('Post-edit reformat was not fulfilled'); + } + return true; // because the main edit was fulfilled + } + ); + } + } } } return isFulfilled; }); + } } private insertEdit( diff --git a/src/extension-test/unit/calva-fmt/respacer-test.ts b/src/extension-test/unit/calva-fmt/respacer-test.ts new file mode 100644 index 000000000..68d43c324 --- /dev/null +++ b/src/extension-test/unit/calva-fmt/respacer-test.ts @@ -0,0 +1,94 @@ +import * as expect from 'expect'; +import * as respacer from '../../../calva-fmt/src/respacer'; +import * as model from '../../../cursor-doc/model'; +import { + docFromTextNotation, + textAndSelection, + getText, + textAndSelections, +} from '../common/text-notation'; + +model.initScanner(20000); + +describe('respacer', () => { + it('Inserts formatting space at the beginning', () => { + const actual = docFromTextNotation('(def| foo 42)'); + const expected = docFromTextNotation(' (def| foo 42)'); + const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + actual.model.editNow( + spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), + { skipFormat: true } + ); + expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + }); + it('Inserts formatting space at the middle', () => { + const actual = docFromTextNotation('(def foo|[42])'); + const expected = docFromTextNotation(' (def foo| [42])'); + const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + actual.model.editNow( + spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), + { skipFormat: true } + ); + expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + }); + it('Inserts formatting space at the end', () => { + const actual = docFromTextNotation('(def foo| [42])'); + const expected = docFromTextNotation('(def foo| [42]) '); + const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + actual.model.editNow( + spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), + { skipFormat: true } + ); + expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + }); + it('Removes formatting space from the beginning', () => { + const actual = docFromTextNotation(' (def foo| 42)'); + const expected = docFromTextNotation('(def foo| 42)'); + const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + actual.model.editNow( + spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), + { skipFormat: true } + ); + expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + }); + it('Removes formatting space from the middle', () => { + const actual = docFromTextNotation('(def foo| 42)'); + const expected = docFromTextNotation('(def foo| 42)'); + const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + actual.model.editNow( + spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), + { skipFormat: true } + ); + expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + }); + it('Resizes formatting space in the middle', () => { + const actual = docFromTextNotation('(def•foo| 42)'); + const expected = docFromTextNotation('(def• foo| 42)'); + const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + actual.model.editNow( + spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), + { skipFormat: true } + ); + expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + }); + it('Inserts, deletes, and resizes formatting space throughout', () => { + const actual = docFromTextNotation(' (def•foo| 42)'); + const expected = docFromTextNotation('(def• foo| 42) '); + const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + actual.model.editNow( + spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), + { skipFormat: true } + ); + expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + }); + it('Preserves multiple cursors amidst formatting-space alterations', () => { + const actual = docFromTextNotation(' |(def•f|2oo|3 42)'); + const expected = docFromTextNotation('|(def• f|2oo|3 42 )'); + const spaceEdits = respacer.whitespaceEdits(0, getText(actual), getText(expected)); + actual.model.editNow( + spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), + { skipFormat: true } + ); + expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + }); +}); From 81638a5ee92b7842cf181dbee871bec2d4bacb26 Mon Sep 17 00:00:00 2001 From: Phill Wolf Date: Sun, 9 Mar 2025 00:01:56 -0500 Subject: [PATCH 4/9] Auto-reformat: if no form encloses the offset of interest, fall back to the form of that offset --- src/calva-fmt/src/format.ts | 1 + src/doc-mirror/index.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/calva-fmt/src/format.ts b/src/calva-fmt/src/format.ts index b4232d599..818e22295 100644 --- a/src/calva-fmt/src/format.ts +++ b/src/calva-fmt/src/format.ts @@ -51,6 +51,7 @@ export async function indentPosition(position: vscode.Position, document: vscode } } +/** undefined if range starts in a string or comment */ function rangeReformatChanges( document: vscode.TextDocument, originalRange: vscode.Range diff --git a/src/doc-mirror/index.ts b/src/doc-mirror/index.ts index 80f543e84..91db7c052 100644 --- a/src/doc-mirror/index.ts +++ b/src/doc-mirror/index.ts @@ -80,7 +80,16 @@ const reformatListRangesForEdits = (function () { // Compute disjoint ranges. const listRanges1: ModelEditRange[] = edits .flatMap(pointsModelEdit) - .map((n: number) => model.getTokenCursor(n).rangeForList(1)); + .map((n: number) => { + const cursor = model.getTokenCursor(n); + let x = cursor.rangeForList(1); + // Fall back to rangeForCurrentForm if no form encloses the offset: + if (!x || x[0]==undefined) + { + x = cursor.rangeForCurrentForm(n); + } + return x; + }); const wholeDoc = listRanges1.filter((x) => x == undefined).length > 0; return wholeDoc ? undefined : nonOverlappingRanges(listRanges1); }; From 189eca5f8854ef06e089b8c046070b7f137dfae7 Mon Sep 17 00:00:00 2001 From: Phill Wolf Date: Sun, 9 Mar 2025 00:03:21 -0500 Subject: [PATCH 5/9] Waive rule about not reindenting the top level form at the beginning of the range to reformat, in the case where the range is the whole document --- src/calva-fmt/src/format.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/calva-fmt/src/format.ts b/src/calva-fmt/src/format.ts index 818e22295..f2597faaa 100644 --- a/src/calva-fmt/src/format.ts +++ b/src/calva-fmt/src/format.ts @@ -59,15 +59,24 @@ function rangeReformatChanges( const mirrorDoc = getDocument(document); const startIndex = document.offsetAt(originalRange.start); const cursor = mirrorDoc.getTokenCursor(startIndex); - if (!cursor.withinString() && !cursor.withinComment()) { + // Do not format comments as individual ranges. + // But do not evade formatting the whole doc if it happens to begin with a comment. + if (startIndex == 0 || (!cursor.withinString() && !cursor.withinComment())) { const eol = _convertEolNumToStringNotation(document.eol); const originalText = document.getText(originalRange); const healing = healer.bandage(originalText, originalRange.start.character, eol); const formattedHealedText = formatCode(healing.healedText, document.eol); - const newText = healer.unbandage(healing, formattedHealedText); + const newTextDraft = healer.unbandage(healing, formattedHealedText); + // unbandage aligned top-level forms flush-left, except the first one. + // When formatting the whole document, align the first form flush-left. + const newText = startIndex == 0 ? newTextDraft.trim() : newTextDraft; return originalText == newText ? [] : respacer.whitespaceEdits(eol, startIndex, originalText, newText); + } else + { + console.warn("Range starting in comment or string is not being formatted") + return []; } } From fbed1d5e21a4c05f90bc394b4fe855c7972d1d9d Mon Sep 17 00:00:00 2001 From: Phill Wolf Date: Sun, 9 Mar 2025 00:03:38 -0500 Subject: [PATCH 6/9] Move comment closer to applicable code --- src/calva-fmt/src/format.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/calva-fmt/src/format.ts b/src/calva-fmt/src/format.ts index f2597faaa..3d640ecf8 100644 --- a/src/calva-fmt/src/format.ts +++ b/src/calva-fmt/src/format.ts @@ -244,6 +244,8 @@ export async function formatPosition( const dedupedRanges = nonOverlappingRanges(ranges); orderedChanges = dedupedRanges .map((rng) => { + // Find a cursor that might be in this block and pass it as the index to the reformatter. + // The reformatter may treat it specially, e.g., by not trimming it out of existence. const cursorsInRange = editor.selections .map((sel) => sel.active) .map((point) => doc.offsetAt(point)) @@ -251,8 +253,6 @@ export async function formatPosition( return cursorsInRange.length > 0 ? cursorsInRange[0] : rng[0]; }) .flatMap((index) => { - // Find a cursor that might be in this block and pass it as the index to the reformatter. - // The reformatter may treat it specially, e.g., by not trimming it out of existence. const formattedInfo = formatDocIndexInfo(doc, onType, index, extraConfig); return formattedInfo ? formattedInfo.changes : []; }) From 6f4f588e046d3d02fb372fb07a4321045d839c21 Mon Sep 17 00:00:00 2001 From: Phill Wolf Date: Sun, 9 Mar 2025 10:47:42 -0400 Subject: [PATCH 7/9] Prettier --- src/calva-fmt/src/format.ts | 9 ++++----- src/doc-mirror/index.ts | 21 +++++++++------------ 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/calva-fmt/src/format.ts b/src/calva-fmt/src/format.ts index 3d640ecf8..531b102d1 100644 --- a/src/calva-fmt/src/format.ts +++ b/src/calva-fmt/src/format.ts @@ -59,8 +59,8 @@ function rangeReformatChanges( const mirrorDoc = getDocument(document); const startIndex = document.offsetAt(originalRange.start); const cursor = mirrorDoc.getTokenCursor(startIndex); - // Do not format comments as individual ranges. - // But do not evade formatting the whole doc if it happens to begin with a comment. + // Do not format comments as individual ranges. + // But do not evade formatting the whole doc if it happens to begin with a comment. if (startIndex == 0 || (!cursor.withinString() && !cursor.withinComment())) { const eol = _convertEolNumToStringNotation(document.eol); const originalText = document.getText(originalRange); @@ -73,9 +73,8 @@ function rangeReformatChanges( return originalText == newText ? [] : respacer.whitespaceEdits(eol, startIndex, originalText, newText); - } else - { - console.warn("Range starting in comment or string is not being formatted") + } else { + console.warn('Range starting in comment or string is not being formatted'); return []; } } diff --git a/src/doc-mirror/index.ts b/src/doc-mirror/index.ts index 91db7c052..a1d5358fa 100644 --- a/src/doc-mirror/index.ts +++ b/src/doc-mirror/index.ts @@ -78,18 +78,15 @@ const reformatListRangesForEdits = (function () { // Translate each edit to a start- and end-point of new content. // Translate those points to start- and end-points of sexprs. // Compute disjoint ranges. - const listRanges1: ModelEditRange[] = edits - .flatMap(pointsModelEdit) - .map((n: number) => { - const cursor = model.getTokenCursor(n); - let x = cursor.rangeForList(1); - // Fall back to rangeForCurrentForm if no form encloses the offset: - if (!x || x[0]==undefined) - { - x = cursor.rangeForCurrentForm(n); - } - return x; - }); + const listRanges1: ModelEditRange[] = edits.flatMap(pointsModelEdit).map((n: number) => { + const cursor = model.getTokenCursor(n); + let x = cursor.rangeForList(1); + // Fall back to rangeForCurrentForm if no form encloses the offset: + if (!x || x[0] == undefined) { + x = cursor.rangeForCurrentForm(n); + } + return x; + }); const wholeDoc = listRanges1.filter((x) => x == undefined).length > 0; return wholeDoc ? undefined : nonOverlappingRanges(listRanges1); }; From 99c1fc3533dfaf1d00b29867a20ac1a3beb395ca Mon Sep 17 00:00:00 2001 From: Phill Wolf Date: Sun, 9 Mar 2025 10:48:11 -0400 Subject: [PATCH 8/9] Impose the formatter's newIndex cursor position, if there is only one cursor --- src/calva-fmt/src/format.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/calva-fmt/src/format.ts b/src/calva-fmt/src/format.ts index 531b102d1..d23319239 100644 --- a/src/calva-fmt/src/format.ts +++ b/src/calva-fmt/src/format.ts @@ -234,6 +234,7 @@ export async function formatPosition( .filter((rng) => rng != undefined); const isWholeDoc = ranges.filter((r) => r[0] == -1 && r[1] == -1).length > 0; let orderedChanges = undefined; + let aNewIndex = undefined; if (isWholeDoc) { orderedChanges = rangeReformatChanges( doc, @@ -253,11 +254,13 @@ export async function formatPosition( }) .flatMap((index) => { const formattedInfo = formatDocIndexInfo(doc, onType, index, extraConfig); + aNewIndex = formattedInfo.newIndex; return formattedInfo ? formattedInfo.changes : []; }) .sort((a, b) => b.start - a.start); } - return editor.edit((textEditorEdit) => { + const docVersionBeforeFormatting = doc.version; + const formattingEditsPromised = editor.edit((textEditorEdit) => { let monotonicallyDecreasing = -1; orderedChanges.forEach((change) => { const pos1 = doc.positionAt(change.start); @@ -271,6 +274,27 @@ export async function formatPosition( } }); }); + // Impose the formatter's newIndex cursor position, if there is only one cursor: + if (editor.selections.length == 1) { + return formattingEditsPromised.then((done) => { + // doc.version will be unchanged if there were no edits + const expectedDocVersion = + orderedChanges.length > 0 ? docVersionBeforeFormatting + 1 : docVersionBeforeFormatting; + if (done) { + if (doc.version == expectedDocVersion) { + if (editor.selections.length == 1) { + if (aNewIndex != doc.offsetAt(editor.selections[0].active)) { + const p = doc.positionAt(aNewIndex); + editor.selections = [new vscode.Selection(p, p)]; + } + } + } + } + return done; + }); + } else { + return formattingEditsPromised; + } } // Debounce format-as-you-type and toss it aside if User seems still to be working From 7f9923d238df3e53814d5d6280f05b6b17921b4b Mon Sep 17 00:00:00 2001 From: Phill Wolf Date: Sun, 9 Mar 2025 10:48:50 -0400 Subject: [PATCH 9/9] Unit test: reformatting edits keep the cursor on the same line when adjusting whitespace --- src/cursor-doc/model.ts | 2 +- .../unit/calva-fmt/respacer-test.ts | 30 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/cursor-doc/model.ts b/src/cursor-doc/model.ts index d6d88e52d..1808672cf 100644 --- a/src/cursor-doc/model.ts +++ b/src/cursor-doc/model.ts @@ -297,7 +297,7 @@ export const selectionsAfterEdits = (function () { const inserted = delta > 0 ? edit.args[2] : undefined; const lastInsertedChar = !inserted || inserted == '' ? '' : inserted[inserted.length - 1]; const point = edit.args[0]; - const threshold = ['(', '[', '{', '#{'].includes(lastInsertedChar) ? point - 1 : point; + const threshold = ['(', '[', '{', '#{', ' '].includes(lastInsertedChar) ? point - 1 : point; return [threshold, point, delta]; }; const decodeDeleteRange = function (edit): [number, number, number] { diff --git a/src/extension-test/unit/calva-fmt/respacer-test.ts b/src/extension-test/unit/calva-fmt/respacer-test.ts index ce249b7db..cee0949a0 100644 --- a/src/extension-test/unit/calva-fmt/respacer-test.ts +++ b/src/extension-test/unit/calva-fmt/respacer-test.ts @@ -3,6 +3,7 @@ import * as respacer from '../../../calva-fmt/src/respacer'; import * as model from '../../../cursor-doc/model'; import { docFromTextNotation, + textNotationFromDoc, textAndSelection, getText, textAndSelections, @@ -19,17 +20,17 @@ describe('respacer', () => { spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } ); - expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + expect(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); }); it('Inserts formatting space at the middle', () => { const actual = docFromTextNotation('(def foo|[42])'); - const expected = docFromTextNotation(' (def foo| [42])'); + const expected = docFromTextNotation(' (def foo |[42])'); const spaceEdits = respacer.whitespaceEdits('\n', 0, getText(actual), getText(expected)); actual.model.editNow( spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } ); - expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + expect(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); }); it('Inserts formatting space at the end', () => { const actual = docFromTextNotation('(def foo| [42])'); @@ -39,7 +40,7 @@ describe('respacer', () => { spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } ); - expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + expect(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); }); it('Removes formatting space from the beginning', () => { const actual = docFromTextNotation(' (def foo| 42)'); @@ -49,7 +50,7 @@ describe('respacer', () => { spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } ); - expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + expect(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); }); it('Removes formatting space from the middle', () => { const actual = docFromTextNotation('(def foo| 42)'); @@ -59,7 +60,7 @@ describe('respacer', () => { spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } ); - expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + expect(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); }); it('Resizes formatting space in the middle', () => { const actual = docFromTextNotation('(def•foo| 42)'); @@ -69,7 +70,7 @@ describe('respacer', () => { spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } ); - expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + expect(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); }); it('Inserts, deletes, and resizes formatting space throughout', () => { const actual = docFromTextNotation(' (def•foo| 42)'); @@ -79,7 +80,7 @@ describe('respacer', () => { spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } ); - expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + expect(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); }); it('Preserves multiple cursors amidst formatting-space alterations', () => { const actual = docFromTextNotation(' |(def•f|2oo|3 42)'); @@ -89,6 +90,17 @@ describe('respacer', () => { spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), { skipFormat: true } ); - expect(textAndSelection(actual)).toEqual(textAndSelection(expected)); + expect(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); + }); + it('Keeps the cursor on the same line when adjusting whitespace', () => { + const actual = docFromTextNotation('(foo•|•:a)'); + const expected = docFromTextNotation('(foo• |•:a)'); + const eol = actual.model.lineEnding; + const spaceEdits = respacer.whitespaceEdits(eol, 0, getText(actual), getText(expected)); + actual.model.editNow( + spaceEdits.map((se) => new model.ModelEdit('changeRange', [se.start, se.end, se.text])), + { skipFormat: true } + ); + expect(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); }); });