Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

a more robust paredit deleteBackward #2689

Draft
wants to merge 4 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
101 changes: 77 additions & 24 deletions src/calva-fmt/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
): vscode.TextEdit[] | undefined {
const mirrorDoc = getDocument(document);
const startIndex = document.offsetAt(originalRange.start);
const cursor = mirrorDoc.getTokenCursor(startIndex);
Expand All @@ -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
Expand All @@ -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 }));
Expand All @@ -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) {
Expand All @@ -122,7 +130,7 @@ export async function formatPositionInfo(
_convertEolNumToStringNotation(doc.eol),
onType,
{
...(await config.getConfig()),
...config.getConfigNow(),
...extraConfig,
'comment-form?': cursor.getFunctionName() === 'comment',
}
Expand Down Expand Up @@ -206,9 +214,13 @@ export async function formatPosition(
onType: boolean = false,
extraConfig: CljFmtConfig = {}
): Promise<boolean> {
// 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) => {
Expand All @@ -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 = [
Expand All @@ -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) {
Expand All @@ -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']) {
Expand Down
113 changes: 72 additions & 41 deletions src/cursor-doc/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -244,6 +244,7 @@ export type ModelEditOptions = {
formatDepth?: number;
skipFormat?: boolean;
selections?: ModelEditSelection[];
builder?: TextEditorEdit;
};

export interface EditableModel {
Expand All @@ -257,6 +258,15 @@ export interface EditableModel {
*/
edit: (edits: ModelEdit<ModelEditFunction>[], options: ModelEditOptions) => Thenable<boolean>;

/**
* 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<ModelEditFunction>[], options: ModelEditOptions) => void;

getText: (start: number, end: number, mustBeWithin?: boolean) => string;
getLineText: (line: number) => string;
getOffsetForLine: (line: number) => number;
Expand All @@ -276,7 +286,6 @@ export interface EditableDocument {
insertString: (text: string) => void;
getSelectionText: () => string;
delete: () => Thenable<boolean>;
backspace: () => Thenable<boolean>;
}

/** The underlying model for the REPL readline. */
Expand Down Expand Up @@ -527,34 +536,62 @@ export class LineInputModel implements EditableModel {
*/
edit(edits: ModelEdit<ModelEditFunction>[], options: ModelEditOptions): Thenable<boolean> {
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<typeof fn>));
break;
}
case 'changeRange': {
const fn = this.changeRange;
this.changeRange(...(edit.args.slice(0, 5) as Parameters<typeof fn>));
break;
}
case 'deleteRange': {
const fn = this.deleteRange;
this.deleteRange(...(edit.args.slice(0, 5) as Parameters<typeof fn>));
break;
}
default:
break;
}
}
this.editTextNow(edits, options);
if (this.document && options.selections) {
this.document.selections = options.selections;
}
resolve(true);
});
}

editNow(edits: ModelEdit<ModelEditFunction>[], 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<ModelEditFunction>[],
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<typeof fn>)
);
break;
}
case 'changeRange': {
const fn = this.changeRange;
ultimateSelections = this.changeRange(
...(edit.args.slice(0, 5) as Parameters<typeof fn>)
);
break;
}
case 'deleteRange': {
const fn = this.deleteRange;
ultimateSelections = this.deleteRange(
...(edit.args.slice(0, 5) as Parameters<typeof fn>)
);
break;
}
default:
break;
}
}
return ultimateSelections;
}

/**
* Changes the model. Deletes any text between `start` and `end`, and the inserts `text`.
*
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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)];
}

/**
Expand All @@ -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. */
Expand Down Expand Up @@ -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)],
});
}
}
Loading