diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b74cce7..7ee6acd22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Changes to Calva. ## [Unreleased] +- Fix: [Paredit garbles while backspacing rapidly](https://github.com/BetterThanTomorrow/calva/issues/2611) + ## [2.0.482] - 2024-12-03 - Fix: [Added 'replace-refer-all-with-alias' & 'replace-refer-all-with-refer' actions to calva.](https://github.com/BetterThanTomorrow/calva/issues/2667) diff --git a/src/calva-fmt/src/format.ts b/src/calva-fmt/src/format.ts index af4076f77..8cdbc60b0 100644 --- a/src/calva-fmt/src/format.ts +++ b/src/calva-fmt/src/format.ts @@ -49,10 +49,10 @@ export async function indentPosition(position: vscode.Position, document: vscode } } -export async function formatRangeEdits( +export function formatRangeEdits( document: vscode.TextDocument, originalRange: vscode.Range -): Promise { +): vscode.TextEdit[] | undefined { const mirrorDoc = getDocument(document); const startIndex = document.offsetAt(originalRange.start); const cursor = mirrorDoc.getTokenCursor(startIndex); @@ -63,7 +63,7 @@ export async function formatRangeEdits( const trailingWs = originalText.match(/\s*$/)[0]; const missingTexts = cursorDocUtils.getMissingBrackets(originalText); const healedText = `${missingTexts.prepend}${originalText.trim()}${missingTexts.append}`; - const formattedHealedText = await formatCode(healedText, document.eol); + const formattedHealedText = formatCode(healedText, document.eol); const leadingEolPos = leadingWs.lastIndexOf(eol); const startIndent = leadingEolPos === -1 @@ -86,7 +86,7 @@ export async function formatRangeEdits( export async function formatRange(document: vscode.TextDocument, range: vscode.Range) { const wsEdit: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); - const edits = await formatRangeEdits(document, range); + const edits = formatRangeEdits(document, range); if (isUndefined(edits)) { console.error('formatRangeEdits returned undefined!', cloneDeep({ document, range })); @@ -97,14 +97,22 @@ export async function formatRange(document: vscode.TextDocument, range: vscode.R return vscode.workspace.applyEdit(wsEdit); } -export async function formatPositionInfo( +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); - const cursor = getDocument(doc).getTokenCursor(index); + 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' + ); + return; + } + const cursor = mDoc.getTokenCursor(index); const formatRange = _calculateFormatRange(extraConfig, cursor, index); if (!formatRange) { @@ -122,7 +130,7 @@ export async function formatPositionInfo( _convertEolNumToStringNotation(doc.eol), onType, { - ...(await config.getConfig()), + ...config.getConfigNow(), ...extraConfig, 'comment-form?': cursor.getFunctionName() === 'comment', } @@ -206,9 +214,13 @@ export async function formatPosition( 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, - formattedInfo = await formatPositionInfo(editor, onType, extraConfig); - if (formattedInfo && formattedInfo.previousText != formattedInfo.formattedText) { + 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) => { @@ -217,16 +229,19 @@ export async function formatPosition( { undoStopAfter: false, undoStopBefore: false } ) .then((onFulfilled: boolean) => { - editor.selections = [ - new vscode.Selection( - doc.positionAt(formattedInfo.newIndex), - doc.positionAt(formattedInfo.newIndex) - ), - ]; + if (onFulfilled) { + if (documentVersion + 1 == editor.document.version) { + editor.selections = [ + new vscode.Selection( + doc.positionAt(formattedInfo.newIndex), + doc.positionAt(formattedInfo.newIndex) + ), + ]; + } + } return onFulfilled; }); - } - if (formattedInfo) { + } else if (formattedInfo) { return new Promise((resolve, _reject) => { if (formattedInfo.newIndex != formattedInfo.previousIndex) { editor.selections = [ @@ -238,16 +253,54 @@ export async function formatPosition( } resolve(true); }); - } - if (!onType && !outputWindow.isResultsDoc(doc)) { + } 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 +let scheduledFormatCircumstances = undefined; +const scheduledFormatDelayMs = 250; + +function formatPositionCallback(extraConfig: CljFmtConfig) { + if ( + scheduledFormatCircumstances && + vscode.window.activeTextEditor === scheduledFormatCircumstances['editor'] && + vscode.window.activeTextEditor.document.version == + scheduledFormatCircumstances['documentVersion'] + ) { + formatPosition(scheduledFormatCircumstances['editor'], true, extraConfig).finally(() => { + scheduledFormatCircumstances = undefined; + }); + } + // do not anull scheduledFormatCircumstances. Another callback might have been scheduled +} + +export function scheduleFormatAsType(editor: vscode.TextEditor, extraConfig: CljFmtConfig = {}) { + const expectedDocumentVersionUponCallback = 1 + editor.document.version; + if ( + !scheduledFormatCircumstances || + expectedDocumentVersionUponCallback != scheduledFormatCircumstances['documentVersion'] + ) { + // Unschedule (if scheduled) & reschedule: best effort to reformat at a quiet time + if (scheduledFormatCircumstances?.timeoutId) { + clearTimeout(scheduledFormatCircumstances?.timeoutId); + } + scheduledFormatCircumstances = { + editor: editor, + documentVersion: expectedDocumentVersionUponCallback, + timeoutId: setTimeout(function () { + formatPositionCallback(extraConfig); + }, scheduledFormatDelayMs), + }; } - return new Promise((resolve, _reject) => { - resolve(true); - }); } export function formatPositionCommand(editor: vscode.TextEditor) { @@ -262,11 +315,11 @@ export function trimWhiteSpacePositionCommand(editor: vscode.TextEditor) { void formatPosition(editor, false, { 'remove-multiple-non-indenting-spaces?': true }); } -export async function formatCode(code: string, eol: number) { +export function formatCode(code: string, eol: number) { const d = { 'range-text': code, eol: _convertEolNumToStringNotation(eol), - config: await config.getConfig(), + config: config.getConfigNow(), }; const result = jsify(formatText(d)); if (!result['error']) { diff --git a/src/cursor-doc/model.ts b/src/cursor-doc/model.ts index a8dd213c5..3e5b490a2 100644 --- a/src/cursor-doc/model.ts +++ b/src/cursor-doc/model.ts @@ -2,7 +2,7 @@ import { Scanner, Token, ScannerState } from './clojure-lexer'; import { LispTokenCursor } from './token-cursor'; import { deepEqual as equal } from '../util/object'; import { isNumber, isUndefined } from 'lodash'; -import { TextDocument, Selection } from 'vscode'; +import { TextDocument, Selection, TextEditorEdit } from 'vscode'; import _ = require('lodash'); let scanner: Scanner; @@ -244,6 +244,7 @@ export type ModelEditOptions = { formatDepth?: number; skipFormat?: boolean; selections?: ModelEditSelection[]; + builder?: TextEditorEdit; }; export interface EditableModel { @@ -257,6 +258,15 @@ export interface EditableModel { */ edit: (edits: ModelEdit[], options: ModelEditOptions) => Thenable; + /** + * Performs a model edit batch "synchronously", + * using the TextEditorEdit at the 'builder' key of options if applicable. + * For some EditableModel's these are performed as one atomic set of edits. + * @param edits What to do + * @param options The TextEditorEdit (at the 'builder' key, if applicable) and other options + */ + editNow: (edits: ModelEdit[], options: ModelEditOptions) => void; + getText: (start: number, end: number, mustBeWithin?: boolean) => string; getLineText: (line: number) => string; getOffsetForLine: (line: number) => number; @@ -276,7 +286,6 @@ export interface EditableDocument { insertString: (text: string) => void; getSelectionText: () => string; delete: () => Thenable; - backspace: () => Thenable; } /** The underlying model for the REPL readline. */ @@ -527,27 +536,7 @@ export class LineInputModel implements EditableModel { */ edit(edits: ModelEdit[], options: ModelEditOptions): Thenable { return new Promise((resolve, reject) => { - for (const edit of edits) { - switch (edit.editFn) { - case 'insertString': { - const fn = this.insertString; - this.insertString(...(edit.args.slice(0, 4) as Parameters)); - break; - } - case 'changeRange': { - const fn = this.changeRange; - this.changeRange(...(edit.args.slice(0, 5) as Parameters)); - break; - } - case 'deleteRange': { - const fn = this.deleteRange; - this.deleteRange(...(edit.args.slice(0, 5) as Parameters)); - break; - } - default: - break; - } - } + this.editTextNow(edits, options); if (this.document && options.selections) { this.document.selections = options.selections; } @@ -555,6 +544,54 @@ export class LineInputModel implements EditableModel { }); } + editNow(edits: ModelEdit[], options: ModelEditOptions): void { + const ultimateSelections = 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; + } + } + } + + // Returns the selection that would mimic TextEditorEdit + editTextNow( + edits: ModelEdit[], + options: ModelEditOptions + ): ModelEditSelection[] { + let ultimateSelections = undefined; + for (const edit of edits) { + switch (edit.editFn) { + case 'insertString': { + const fn = this.insertString; + ultimateSelections = 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) + ); + break; + } + case 'deleteRange': { + const fn = this.deleteRange; + ultimateSelections = this.deleteRange( + ...(edit.args.slice(0, 5) as Parameters) + ); + break; + } + default: + break; + } + } + return ultimateSelections; + } + /** * Changes the model. Deletes any text between `start` and `end`, and the inserts `text`. * @@ -572,7 +609,7 @@ export class LineInputModel implements EditableModel { text: string, oldSelection?: ModelEditRange, newSelection?: ModelEditRange - ) { + ): ModelEditSelection[] { const t1 = new Date(); const startPos = Math.min(start, end); @@ -626,6 +663,9 @@ export class LineInputModel implements EditableModel { } // console.log("Parsing took: ", new Date().valueOf() - t1.valueOf()); + + // To mimic TextEditorEdit: No change to selection by default: + return undefined; } /** @@ -643,9 +683,10 @@ export class LineInputModel implements EditableModel { text: string, oldSelection?: ModelEditRange, newSelection?: ModelEditRange - ): number { - this.changeRange(offset, offset, text, oldSelection, newSelection); - return text.length; + ): ModelEditSelection[] { + this.changeRange(offset, offset, text); + // To mimic TextEditorEdit: selection moves to end of insertion, by default + return [new ModelEditSelection(offset + text.length)]; } /** @@ -662,8 +703,10 @@ export class LineInputModel implements EditableModel { count: number, oldSelection?: ModelEditRange, newSelection?: ModelEditRange - ) { - this.changeRange(offset, offset + count, '', oldSelection, newSelection); + ): ModelEditSelection[] { + 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. */ @@ -762,16 +805,4 @@ export class StringDocument implements EditableDocument { selections: [new ModelEditSelection(p)], }); } - - backspace() { - const anchor = this.selections[0].anchor; - const active = this.selections[0].active; - const [left, right] = - anchor == active - ? [Math.max(0, anchor - 1), anchor] - : [Math.min(anchor, active), Math.max(anchor, active)]; - return this.model.edit([new ModelEdit('deleteRange', [left, right - left])], { - selections: [new ModelEditSelection(left)], - }); - } } diff --git a/src/cursor-doc/paredit.ts b/src/cursor-doc/paredit.ts index 56a896fd8..ab525d6ce 100644 --- a/src/cursor-doc/paredit.ts +++ b/src/cursor-doc/paredit.ts @@ -12,6 +12,7 @@ import { LispTokenCursor } from './token-cursor'; import { backspaceOnWhitespace } from './backspace-on-whitespace'; import _ = require('lodash'); import { isEqual, last, property } from 'lodash'; +import { TextEditorEdit } from 'vscode'; // NB: doc.model.edit returns a Thenable, so that the vscode Editor can compose commands. // But don't put such chains in this module because that won't work in the repl-console. @@ -1083,34 +1084,37 @@ function onlyWhitespaceLeftOfCursor(offset, cursor: LispTokenCursor) { } function backspaceOnWhitespaceEdit( + builder: TextEditorEdit, doc: EditableDocument, cursor: LispTokenCursor, config?: FormatterConfig ) { const changeArgs = backspaceOnWhitespace(doc, cursor, config); - return doc.model.edit( + return doc.model.editNow( [ - new ModelEdit('changeRange', [ - changeArgs.start, - changeArgs.end, - ' '.repeat(changeArgs.indent), - ]), + new ModelEdit('deleteRange', [changeArgs.end, changeArgs.start - changeArgs.end]), + new ModelEdit('insertString', [changeArgs.end, ' '.repeat(changeArgs.indent)]), ], { - selections: [new ModelEditSelection(changeArgs.end + changeArgs.indent)], + builder: builder, skipFormat: true, } ); } -export async function backspace( +export function backspace( doc: EditableDocument, + builder?: TextEditorEdit, config?: FormatterConfig, start: number = doc.selections[0].anchor, end: number = doc.selections[0].active -): Promise { +): void { if (start != end) { - return doc.backspace(); + const [left, right] = [Math.min(start, end), Math.max(start, end)]; + return doc.model.editNow([new ModelEdit('deleteRange', [left, right - left])], { + builder: builder, + skipFormat: true, + }); } else { const cursor = doc.getTokenCursor(start); const isTopLevel = doc.getTokenCursor(end).atTopLevel(); @@ -1120,20 +1124,21 @@ export async function backspace( ? nextToken // we are “in” a token : cursor.getPrevToken(); // we are “between” tokens if (prevToken.type == 'prompt') { - return new Promise((resolve) => resolve(true)); + return; } else if (nextToken.type == 'prompt') { - return new Promise((resolve) => resolve(true)); + return; } else if (doc.model.getText(start - 2, start, true) == '\\"') { // delete quoted double quote - return doc.model.edit([new ModelEdit('deleteRange', [start - 2, 2])], { - selections: [new ModelEditSelection(start - 2)], + return doc.model.editNow([new ModelEdit('deleteRange', [start - 2, 2])], { + builder: builder, + skipFormat: true, }); } else if (prevToken.type === 'open' && nextToken.type === 'close') { // delete empty list - return doc.model.edit( + return doc.model.editNow( [new ModelEdit('deleteRange', [start - prevToken.raw.length, prevToken.raw.length + 1])], { - selections: [new ModelEditSelection(start - prevToken.raw.length)], + builder: builder, } ); } else if ( @@ -1142,13 +1147,17 @@ export async function backspace( onlyWhitespaceLeftOfCursor(doc.selections[0].anchor, cursor) ) { // we are at the beginning of a line, and not inside a string - return backspaceOnWhitespaceEdit(doc, cursor, config); + return backspaceOnWhitespaceEdit(builder, doc, cursor, config); } else { if (['open', 'close'].includes(prevToken.type) && cursor.docIsBalanced()) { doc.selections = [new ModelEditSelection(start - prevToken.raw.length)]; - return new Promise((resolve) => resolve(true)); + return; } else { - return doc.backspace(); + const [left, right] = [Math.max(start - 1, 0), start]; + return doc.model.editNow([new ModelEdit('deleteRange', [left, right - left])], { + builder: builder, + skipFormat: true, + }); } } } diff --git a/src/doc-mirror/index.ts b/src/doc-mirror/index.ts index 5dcd1295b..736860494 100644 --- a/src/doc-mirror/index.ts +++ b/src/doc-mirror/index.ts @@ -19,6 +19,8 @@ const documents = new Map(); export class DocumentModel implements EditableModel { readonly lineEndingLength: number; lineInputModel: LineInputModel; + documentVersion: number; // model reflects this version + staleDocumentVersion: number; // this version is outdated by queued edits constructor(private document: MirroredDocument) { this.lineEndingLength = document.document.eol == vscode.EndOfLine.CRLF ? 2 : 1; @@ -29,27 +31,61 @@ export class DocumentModel implements EditableModel { return this.lineEndingLength == 2 ? '\r\n' : '\n'; } + /** A loggable message if the model is out-of-date with the given document version + * or has been edited beyond that document version */ + stale(editorVersion: number): string { + if (this.documentVersion && this.documentVersion != editorVersion) { + return 'model=' + this.documentVersion + ' vs document=' + editorVersion; + } else if (this.documentVersion && this.documentVersion == this.staleDocumentVersion) { + return 'edited since ' + this.documentVersion; + } else { + return null; + } + } + + private editNowTextOnly( + modelEdits: ModelEdit[], + options: ModelEditOptions + ): void { + const builder = options.builder; + for (const modelEdit of modelEdits) { + switch (modelEdit.editFn) { + case 'insertString': + this.insertEdit.apply(this, [builder, ...modelEdit.args]); + break; + case 'changeRange': + this.replaceEdit.apply(this, [builder, ...modelEdit.args]); + break; + case 'deleteRange': + this.deleteEdit.apply(this, [builder, ...modelEdit.args]); + break; + default: + break; + } + } + this.staleDocumentVersion = this.documentVersion; + } + + editNow(modelEdits: ModelEdit[], options: ModelEditOptions): void { + this.editNowTextOnly(modelEdits, options); + if (options.selections) { + this.document.selections = options.selections; + } + if (!options.skipFormat) { + const editor = utilities.getActiveTextEditor(); + void formatter.scheduleFormatAsType(editor, { + 'format-depth': options.formatDepth ?? 1, + }); + } + } + edit(modelEdits: ModelEdit[], options: ModelEditOptions): Thenable { const editor = utilities.getActiveTextEditor(), undoStopBefore = !!options.undoStopBefore; return editor .edit( (builder) => { - for (const modelEdit of modelEdits) { - switch (modelEdit.editFn) { - case 'insertString': - this.insertEdit.apply(this, [builder, ...modelEdit.args]); - break; - case 'changeRange': - this.replaceEdit.apply(this, [builder, ...modelEdit.args]); - break; - case 'deleteRange': - this.deleteEdit.apply(this, [builder, ...modelEdit.args]); - break; - default: - break; - } - } + this.editNowTextOnly(modelEdits, { builder: builder, ...options }); }, { undoStopBefore, undoStopAfter: false } ) @@ -183,10 +219,6 @@ export class MirroredDocument implements EditableDocument { public delete(): Thenable { return vscode.commands.executeCommand('deleteRight'); } - - public backspace(): Thenable { - return vscode.commands.executeCommand('deleteLeft'); - } } let registered = false; @@ -215,6 +247,9 @@ function processChanges(event: vscode.TextDocumentChangeEvent) { model.lineInputModel.dirtyLines = []; model.lineInputModel.insertedLines.clear(); model.lineInputModel.deletedLines.clear(); + + model.documentVersion = event.document.version; + model.staleDocumentVersion = undefined; } export function tryToGetDocument(doc: vscode.TextDocument) { diff --git a/src/extension-test/unit/cursor-doc/paredit-test.ts b/src/extension-test/unit/cursor-doc/paredit-test.ts index 0273c772f..7ddf10de9 100644 --- a/src/extension-test/unit/cursor-doc/paredit-test.ts +++ b/src/extension-test/unit/cursor-doc/paredit-test.ts @@ -1833,133 +1833,132 @@ describe('paredit', () => { }); describe('Kill character backwards (backspace)', () => { - // TODO: Change to await instead of void - it('Deletes a selected range', async () => { + it('Deletes a selected range', () => { const a = docFromTextNotation('{::foo ()• :|:bar |:foo}'); const b = docFromTextNotation('{::foo ()• :|:foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Leaves closing paren of empty list alone', async () => { + it('Leaves closing paren of empty list alone', () => { const a = docFromTextNotation('{::foo ()|• ::bar :foo}'); const b = docFromTextNotation('{::foo (|)• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes closing paren if unbalance', async () => { + it('Deletes closing paren if unbalance', () => { const a = docFromTextNotation('{::foo )|• ::bar :foo}'); const b = docFromTextNotation('{::foo |• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Leaves opening paren of non-empty list alone', async () => { + it('Leaves opening paren of non-empty list alone', () => { const a = docFromTextNotation('{::foo (|a)• ::bar :foo}'); const b = docFromTextNotation('{::foo |(a)• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Leaves opening quote of non-empty string alone', async () => { + it('Leaves opening quote of non-empty string alone', () => { const a = docFromTextNotation('{::foo "|a"• ::bar :foo}'); const b = docFromTextNotation('{::foo |"a"• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Leaves closing quote of non-empty string alone', async () => { + it('Leaves closing quote of non-empty string alone', () => { const a = docFromTextNotation('{::foo "a"|• ::bar :foo}'); const b = docFromTextNotation('{::foo "a|"• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes contents in strings', async () => { + it('Deletes contents in strings', () => { const a = docFromTextNotation('{::foo "a|"• ::bar :foo}'); const b = docFromTextNotation('{::foo "|"• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes contents in strings 2', async () => { + it('Deletes contents in strings 2', () => { const a = docFromTextNotation('{::foo "a|a"• ::bar :foo}'); const b = docFromTextNotation('{::foo "|a"• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes contents in strings 3', async () => { + it('Deletes contents in strings 3', () => { const a = docFromTextNotation('{::foo "aa|"• ::bar :foo}'); const b = docFromTextNotation('{::foo "a|"• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes quoted quote', async () => { + it('Deletes quoted quote', () => { const a = docFromTextNotation('{::foo \\"|• ::bar :foo}'); const b = docFromTextNotation('{::foo |• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes quoted quote in string', async () => { + it('Deletes quoted quote in string', () => { const a = docFromTextNotation('{::foo "\\"|"• ::bar :foo}'); const b = docFromTextNotation('{::foo "|"• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes contents in list', async () => { + it('Deletes contents in list', () => { const a = docFromTextNotation('{::foo (a|)• ::bar :foo}'); const b = docFromTextNotation('{::foo (|)• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes empty list function', async () => { + it('Deletes empty list function', () => { const a = docFromTextNotation('{::foo (|)• ::bar :foo}'); const b = docFromTextNotation('{::foo |• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes empty set', async () => { + it('Deletes empty set', () => { const a = docFromTextNotation('#{|}'); const b = docFromTextNotation('|'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes empty literal function with trailing newline', async () => { + it('Deletes empty literal function with trailing newline', () => { // https://github.com/BetterThanTomorrow/calva/issues/1079 const a = docFromTextNotation('{::foo #(|)• ::bar :foo}'); const b = docFromTextNotation('{::foo |• ::bar :foo}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes open paren prefix characters', async () => { + it('Deletes open paren prefix characters', () => { // https://github.com/BetterThanTomorrow/calva/issues/1122 const a = docFromTextNotation('#|(foo)'); const b = docFromTextNotation('|(foo)'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes open map curly prefix/ns characters', async () => { + it('Deletes open map curly prefix/ns characters', () => { const a = docFromTextNotation('#:same|{:thing :here}'); const b = docFromTextNotation('#:sam|{:thing :here}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes open set hash characters', async () => { + it('Deletes open set hash characters', () => { // https://github.com/BetterThanTomorrow/calva/issues/1122 const a = docFromTextNotation('#|{:thing :here}'); const b = docFromTextNotation('|{:thing :here}'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Moves cursor past entire open paren, including prefix characters', async () => { + it('Moves cursor past entire open paren, including prefix characters', () => { const a = docFromTextNotation('#(|foo)'); const b = docFromTextNotation('|#(foo)'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes unbalanced bracket', async () => { + it('Deletes unbalanced bracket', () => { // This hangs the structural editing in the real editor // https://github.com/BetterThanTomorrow/calva/issues/1573 const a = docFromTextNotation('([{|)'); const b = docFromTextNotation('([|'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes whitespace to the left of the cursor', async () => { + it('Deletes whitespace to the left of the cursor', () => { const a = docFromTextNotation( ` (if false nil @@ -1967,78 +1966,78 @@ describe('paredit', () => { `.trim() ); const b = docFromTextNotation(`(if false nil |true)`.trim()); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes whitespace to the left of the cursor without crossing multiple lines', async () => { + it('Deletes whitespace to the left of the cursor without crossing multiple lines', () => { const a = docFromTextNotation('[•• |::foo]'); const b = docFromTextNotation('[• |::foo]'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes whitespace to the left and right of the cursor when inside whitespace', async () => { + it('Deletes whitespace to the left and right of the cursor when inside whitespace', () => { const a = docFromTextNotation('[• | ::foo]'); const b = docFromTextNotation('[|::foo]'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes whitespace to the left and inserts a space when arriving at the end of a line', async () => { + it('Deletes whitespace to the left and inserts a space when arriving at the end of a line', () => { const a = docFromTextNotation('(if :foo• |:bar :baz)'); const b = docFromTextNotation('(if :foo |:bar :baz)'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes whitespace to the left and inserts a single space when ending up on a line with trailing whitespace', async () => { + it('Deletes whitespace to the left and inserts a single space when ending up on a line with trailing whitespace', () => { const a = docFromTextNotation('(if :foo • |:bar :baz)'); const b = docFromTextNotation('(if :foo |:bar :baz)'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes whitespace to the left and avoids inserting a space if on a close token', async () => { + it('Deletes whitespace to the left and avoids inserting a space if on a close token', () => { const a = docFromTextNotation('(if :foo• |)'); const b = docFromTextNotation('(if :foo|)'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); // https://github.com/BetterThanTomorrow/calva/issues/2108 - it('Deletes whitespace to the left and avoids inserting indent if at top level', async () => { + it('Deletes whitespace to the left and avoids inserting indent if at top level', () => { const a = docFromTextNotation('a\n\n |b'); const b = docFromTextNotation('a\n\n |b'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes whitespace to the left, inserting indents if at top level inside RFC', async () => { + it('Deletes whitespace to the left, inserting indents if at top level inside RFC', () => { const a = docFromTextNotation('(comment\n a\n\n |b)'); const b = docFromTextNotation('(comment\n a\n |b)'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes a character when inside a token on a blank line', async () => { + it('Deletes a character when inside a token on a blank line', () => { const a = docFromTextNotation('(if• :|foo)'); const b = docFromTextNotation('(if• |foo)'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); // https://github.com/BetterThanTomorrow/calva/issues/2327 - it('Does not delete hash character to the left of a list, inside a list', async () => { + it('Does not delete hash character to the left of a list, inside a list', () => { const a = docFromTextNotation('(#|())'); const b = docFromTextNotation('(|#())'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); - it('Deletes hash character to the left of a vector, inside a list', async () => { + it('Deletes hash character to the left of a vector, inside a list', () => { const a = docFromTextNotation('(#|[])'); const b = docFromTextNotation('(|[])'); - await paredit.backspace(a); + paredit.backspace(a); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); }); diff --git a/src/formatter-config.ts b/src/formatter-config.ts index 4c780c79a..9194a5639 100644 --- a/src/formatter-config.ts +++ b/src/formatter-config.ts @@ -13,8 +13,25 @@ const defaultCljfmtContent = const LSP_CONFIG_KEY = 'CLOJURE-LSP'; let lspFormatConfig: string | undefined; +let lspFormatDate: number = 0; +const lspFormatTTLms: number = 6000; -function configuration(workspaceConfig: vscode.WorkspaceConfiguration, cljfmt: string) { +function configuration(): { + 'format-as-you-type': boolean; + 'keep-comment-forms-trail-paren-on-own-line?': boolean; + 'cljfmt-options-string': string; + 'cljfmt-options': object; +} { + const workspaceConfig = vscode.workspace.getConfiguration('calva.fmt'); + const configPath: string | undefined = getConfigPath(workspaceConfig); + + const cljfmtContent: string | undefined = + configPath === LSP_CONFIG_KEY + ? lspFormatConfig + ? lspFormatConfig + : defaultCljfmtContent + : filesCache.content(configPath); + const cljfmt = cljfmtContent ? cljfmtContent : defaultCljfmtContent; const cljfmtOptions = cljsLib.cljfmtOptionsFromString(cljfmt); return { 'format-as-you-type': !!formatOnTypeEnabled(), @@ -60,25 +77,27 @@ export async function getConfig( console.error( 'Fetching formatting settings from clojure-lsp failed. Check that you are running a version of clojure-lsp that provides "cljfmt-raw" in serverInfo.' ); + } else { + lspFormatDate = Date.now(); } } } - const cljfmtContent: string | undefined = - configPath === LSP_CONFIG_KEY - ? lspFormatConfig - ? lspFormatConfig - : defaultCljfmtContent - : filesCache.content(configPath); - const config = configuration( - workspaceConfig, - cljfmtContent ? cljfmtContent : defaultCljfmtContent - ); - if (config['cljfmt-options']['error']) { - void vscode.window.showErrorMessage( - `Error parsing ${configPath}: ${config['cljfmt-options']['error']}\n\nUsing default formatting configuration.` - ); + return configuration(); +} + +/** + * @param document + * @returns FormatterConfig, possibly with cached or default LSP information + */ +export function getConfigNow( + document: vscode.TextDocument = vscode.window.activeTextEditor?.document +): FormatterConfig { + // Begin async update from LSP if stale: + if (Date.now() - lspFormatDate > lspFormatTTLms) { + void getConfig(document); } - return config; + // Config using cached LSP info: + return configuration(); } export function formatOnTypeEnabled() { diff --git a/src/paredit/extension.ts b/src/paredit/extension.ts index b1b8f8073..0f9cdf615 100644 --- a/src/paredit/extension.ts +++ b/src/paredit/extension.ts @@ -50,6 +50,11 @@ type PareditCommand = { handler: (doc: EditableDocument, arg?: any) => void | Promise | Thenable; }; +type PareditCommandNow = { + command: string; + handlerNow: (doc: EditableDocument, builder?: vscode.TextEditorEdit) => void; +}; + // only grab the custom, additional args after the first doc arg from the given command's handler type CommandArgOf = Parameters[1]; @@ -452,8 +457,8 @@ const pareditCommands = [ }, { command: 'paredit.deleteBackward', - handler: async (doc: EditableDocument) => { - await paredit.backspace(doc, await config.getConfig()); + handlerNow: (doc: EditableDocument, builder: vscode.TextEditorEdit) => { + paredit.backspace(doc, builder, config.getConfigNow()); }, }, { @@ -502,6 +507,28 @@ function wrapPareditCommand(command: C) { }; } +function wrapPareditCommandNow(command: C) { + return (textEditor: vscode.TextEditor, builder: vscode.TextEditorEdit) => { + try { + const mDoc: EditableDocument = docMirror.getDocument(textEditor.document); + if (!enabled || !languages.has(textEditor.document.languageId)) { + return; + } + const model = mDoc.model as docMirror.DocumentModel; + const staleMessage = model.stale(textEditor.document.version); + if (!staleMessage) { + command.handlerNow(mDoc, builder); + } else { + console.warn( + 'paredit is skipping ' + command.command + ' because TextDocumentChangeEvent is overdue' + ); + } + } catch (e) { + console.error(e.message); + } + }; +} + export function getKeyMapConf(): string { const keyMap = workspace.getConfiguration().get('calva.paredit.defaultKeyMap'); return String(keyMap); @@ -540,9 +567,21 @@ export function activate(context: ExtensionContext) { setKeyMapConf(); } }), - ...pareditCommands.map((command) => - commands.registerCommand(command.command, wrapPareditCommand(command)) - ), + ...pareditCommands.map((command) => { + if (command['handler']) { + return commands.registerCommand( + command.command, + wrapPareditCommand(command as PareditCommand) + ); + } else if (command['handlerNow']) { + return commands.registerTextEditorCommand( + command.command, + wrapPareditCommandNow(command as PareditCommandNow) + ); + } else { + return undefined; + } + }), commands.registerCommand('calva.diagnostics.printTextNotationFromDocument', () => { const doc = vscode.window.activeTextEditor?.document; if (doc && doc.languageId === 'clojure') { diff --git a/src/repl-window/repl-doc.ts b/src/repl-window/repl-doc.ts index ff02ebb7f..cfe8d5e86 100644 --- a/src/repl-window/repl-doc.ts +++ b/src/repl-window/repl-doc.ts @@ -299,7 +299,7 @@ export function setNamespaceFromCurrentFile() { output.replWindowAppendPrompt(); } -async function appendFormGrabbingSessionAndNS(topLevel: boolean) { +function appendFormGrabbingSessionAndNS(topLevel: boolean): void { const session = replSession.getSession(); const [ns, _] = namespace.getNamespace( util.tryToGetDocument({}), @@ -311,9 +311,9 @@ async function appendFormGrabbingSessionAndNS(topLevel: boolean) { let code = ''; if (selection.isEmpty) { const formSelection = select.getFormSelection(doc, selection.active, topLevel); - code = await formatCode(doc.getText(formSelection), doc.eol); + code = formatCode(doc.getText(formSelection), doc.eol); } else { - code = await formatCode(doc.getText(selection), doc.eol); + code = formatCode(doc.getText(selection), doc.eol); } if (code != '') { setSession(session, ns); @@ -322,11 +322,11 @@ async function appendFormGrabbingSessionAndNS(topLevel: boolean) { } export function appendCurrentForm() { - void appendFormGrabbingSessionAndNS(false); + appendFormGrabbingSessionAndNS(false); } export function appendCurrentTopLevelForm() { - void appendFormGrabbingSessionAndNS(true); + appendFormGrabbingSessionAndNS(true); } export async function lastLineIsEmpty(): Promise {