diff --git a/src/calva-fmt/src/format.ts b/src/calva-fmt/src/format.ts index c8e608a18..d23319239 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,23 +51,46 @@ export async function indentPosition(position: vscode.Position, document: vscode } } -export function formatRangeEdits( +/** undefined if range starts in a string or comment */ +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); - 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); - return [vscode.TextEdit.replace(originalRange, newText)]; + 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 []; } } +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,56 +104,65 @@ 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', or [-1,-1] to reformat the whole document */ +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 (!formatRange) { + // If a top-level form "needs" formatting and is indented, reformat the whole document: + const formatRangeSmall = _calculateFormatRange(extraConfig, cursor, index); + return formatRangeSmall ? formatRangeSmall : [-1, -1]; +} + +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 || (formatRange[0] == -1 && formatRange[1] == -1)) { 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]) ); 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(eol, 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 +172,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,65 +222,78 @@ 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 } - ) - .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) - ), - ]; - } - resolve(true); - }); - } else if (!onType && !outputWindow.isResultsDoc(doc)) { - return formatRange( + 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 isWholeDoc = ranges.filter((r) => r[0] == -1 && r[1] == -1).length > 0; + let orderedChanges = undefined; + let aNewIndex = undefined; + if (isWholeDoc) { + orderedChanges = rangeReformatChanges( doc, new vscode.Range(doc.positionAt(0), doc.positionAt(doc.getText().length)) ); } else { - return new Promise((resolve, _reject) => { - resolve(true); + 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)) + .filter((offset) => offset >= rng[0] && offset < rng[1]); + return cursorsInRange.length > 0 ? cursorsInRange[0] : rng[0]; + }) + .flatMap((index) => { + const formattedInfo = formatDocIndexInfo(doc, onType, index, extraConfig); + aNewIndex = formattedInfo.newIndex; + return formattedInfo ? formattedInfo.changes : []; + }) + .sort((a, b) => b.start - a.start); + } + const docVersionBeforeFormatting = doc.version; + const formattingEditsPromised = 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; + } }); + }); + // 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; } } diff --git a/src/calva-fmt/src/respacer.ts b/src/calva-fmt/src/respacer.ts new file mode 100644 index 000000000..899e53dba --- /dev/null +++ b/src/calva-fmt/src/respacer.ts @@ -0,0 +1,159 @@ +/** + * @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( + 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) { + 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 { + // 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) { + 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( + eol: string, + 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(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); + 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..1808672cf 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..a1d5358fa 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,88 @@ 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) => { + 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); + }; +})(); + export class DocumentModel implements EditableModel { readonly lineEndingLength: number; lineInputModel: LineInputModel; @@ -73,35 +149,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 ? 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..cee0949a0 --- /dev/null +++ b/src/extension-test/unit/calva-fmt/respacer-test.ts @@ -0,0 +1,106 @@ +import * as expect from 'expect'; +import * as respacer from '../../../calva-fmt/src/respacer'; +import * as model from '../../../cursor-doc/model'; +import { + docFromTextNotation, + textNotationFromDoc, + 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('\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(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 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(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); + }); + it('Inserts formatting space at the end', () => { + const actual = 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(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); + }); + it('Removes formatting space from the beginning', () => { + const actual = 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(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); + }); + it('Removes formatting space from the middle', () => { + const actual = 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(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(expected)); + }); + it('Resizes formatting space in the middle', () => { + const actual = 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(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(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('\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(textNotationFromDoc(actual)).toEqual(textNotationFromDoc(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('\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(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)); + }); +});