Skip to content
This repository was archived by the owner on Dec 25, 2023. It is now read-only.

Commit 884a2d2

Browse files
authored
Add command and setting to format documents (#244)
* Add command and setting to format documents Also, readme update * lint * lint
1 parent ec50795 commit 884a2d2

File tree

10 files changed

+344
-41
lines changed

10 files changed

+344
-41
lines changed

src/applyLSPEdits.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { applyLSPEdits } from "./applyLSPEdits";
2+
3+
jest.mock("./novaUtils");
4+
jest.mock("./lspNovaConversions", () => ({
5+
lspRangeToRange(_: unknown, r: unknown) {
6+
return r;
7+
},
8+
}));
9+
10+
describe("Apply lsp edits", () => {
11+
it("applies changes to files", async () => {
12+
const mockEditor = { edit: jest.fn() };
13+
14+
const edit1 = {
15+
range: {
16+
start: { line: 1, character: 2 },
17+
end: { line: 1, character: 5 },
18+
},
19+
newText: "newText1",
20+
};
21+
const edit2 = {
22+
range: {
23+
start: { line: 4, character: 0 },
24+
end: { line: 5, character: 0 },
25+
},
26+
newText: "newText2",
27+
};
28+
await applyLSPEdits((mockEditor as unknown) as TextEditor, [edit1, edit2]);
29+
30+
// file edit callbacks should apply changes
31+
const editCB = mockEditor.edit.mock.calls[0][0];
32+
const replaceMock = jest.fn();
33+
editCB({ replace: replaceMock });
34+
expect(replaceMock).toBeCalledTimes(2);
35+
// in reverse order
36+
expect(replaceMock).toHaveBeenNthCalledWith(1, edit2.range, edit2.newText);
37+
expect(replaceMock).toHaveBeenNthCalledWith(2, edit1.range, edit1.newText);
38+
});
39+
});

src/applyLSPEdits.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// eslint-disable-next-line no-unused-vars
2+
import type * as lspTypes from "vscode-languageserver-protocol";
3+
import { lspRangeToRange } from "./lspNovaConversions";
4+
5+
export async function applyLSPEdits(
6+
editor: TextEditor,
7+
edits: Array<lspTypes.TextEdit>
8+
) {
9+
editor.edit((textEditorEdit) => {
10+
for (const change of edits.reverse()) {
11+
const range = lspRangeToRange(editor.document, change.range);
12+
textEditorEdit.replace(range, change.newText);
13+
}
14+
});
15+
}

src/applyWorkspaceEdit.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// eslint-disable-next-line no-unused-vars
22
import type * as lspTypes from "vscode-languageserver-protocol";
3-
import { lspRangeToRange } from "./lspNovaConversions";
3+
import { applyLSPEdits } from "./applyLSPEdits";
44
import { openFile } from "./novaUtils";
55

66
// @Deprecated I want to replace this with a call to Nova's client with workspace/applyEdit, but that's currently not possible.
@@ -23,11 +23,7 @@ export async function applyWorkspaceEdit(
2323
nova.workspace.showWarningMessage(`Failed to open ${uri}`);
2424
continue;
2525
}
26-
editor.edit((textEditorEdit) => {
27-
for (const change of changes.reverse()) {
28-
const range = lspRangeToRange(editor.document, change.range);
29-
textEditorEdit.replace(range, change.newText);
30-
}
31-
});
26+
27+
applyLSPEdits(editor, changes);
3228
}
3329
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// eslint-disable-next-line no-unused-vars
2+
import { registerFormatDocument } from "./formatDocument";
3+
4+
class MockRange {
5+
// eslint-disable-next-line no-unused-vars
6+
constructor(readonly start: number, readonly end: number) {}
7+
}
8+
(global as any).Range = MockRange;
9+
10+
describe("formatDocument command", () => {
11+
beforeEach(() => {
12+
(global as any).nova = Object.assign(nova, {
13+
commands: {
14+
register: jest.fn(),
15+
},
16+
workspace: {
17+
showErrorMessage(err: Error) {
18+
throw err;
19+
},
20+
showWarningMessage: jest.fn(),
21+
},
22+
});
23+
24+
mockEditor.edit.mockClear();
25+
mockEditor.scrollToCursorPosition.mockClear();
26+
mockEditor.selectWordsContainingCursors.mockClear();
27+
});
28+
29+
const mockEditor = {
30+
selectedRanges: [new Range(2, 3)],
31+
selectedText: "selectedText",
32+
document: {
33+
length: 10,
34+
uri: "currentDocURI",
35+
path: "/path",
36+
getTextInRange() {
37+
return "";
38+
},
39+
eol: "\n",
40+
},
41+
softTabs: true,
42+
tabLength: 2,
43+
edit: jest.fn(),
44+
selectWordsContainingCursors: jest.fn(),
45+
scrollToCursorPosition: jest.fn(),
46+
};
47+
48+
function getCommand(
49+
languageClient: LanguageClient,
50+
// eslint-disable-next-line no-unused-vars
51+
register: (client: LanguageClient) => Disposable
52+
// eslint-disable-next-line no-unused-vars
53+
): (...args: Array<any>) => Promise<void> {
54+
register(languageClient);
55+
expect(nova.commands.register).toHaveBeenCalledWith(
56+
"apexskier.typescript.commands.formatDocument",
57+
expect.any(Function)
58+
);
59+
const command = (nova.commands.register as jest.Mock).mock.calls[0][1];
60+
(nova.commands.register as jest.Mock).mockClear();
61+
return command;
62+
}
63+
64+
it("applies changes from server to format document", async () => {
65+
const mockLanguageClient = {
66+
sendRequest: jest.fn().mockImplementationOnce(() => []),
67+
};
68+
const command = getCommand(
69+
(mockLanguageClient as any) as LanguageClient,
70+
registerFormatDocument
71+
);
72+
await command(mockEditor);
73+
74+
expect(mockLanguageClient.sendRequest).toBeCalledTimes(1);
75+
expect(mockLanguageClient.sendRequest).toHaveBeenCalledWith(
76+
"textDocument/formatting",
77+
{
78+
textDocument: { uri: "currentDocURI" },
79+
options: {
80+
insertSpaces: true,
81+
tabSize: 2,
82+
},
83+
}
84+
);
85+
expect(mockEditor.edit).toBeCalledTimes(1);
86+
});
87+
});

src/commands/formatDocument.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// eslint-disable-next-line no-unused-vars
2+
import type * as lspTypes from "vscode-languageserver-protocol";
3+
import { applyLSPEdits } from "../applyLSPEdits";
4+
import { wrapCommand } from "../novaUtils";
5+
6+
export function registerFormatDocument(client: LanguageClient) {
7+
return nova.commands.register(
8+
"apexskier.typescript.commands.formatDocument",
9+
wrapCommand(formatDocument)
10+
);
11+
12+
// eslint-disable-next-line no-unused-vars
13+
async function formatDocument(editor: TextEditor): Promise<void>;
14+
async function formatDocument(
15+
// eslint-disable-next-line no-unused-vars
16+
workspace: Workspace,
17+
// eslint-disable-next-line no-unused-vars
18+
editor: TextEditor
19+
): Promise<void>;
20+
async function formatDocument(
21+
editorOrWorkspace: TextEditor | Workspace,
22+
maybeEditor?: TextEditor
23+
) {
24+
const editor: TextEditor = maybeEditor ?? (editorOrWorkspace as TextEditor);
25+
26+
const documentFormatting: lspTypes.DocumentFormattingParams = {
27+
textDocument: { uri: editor.document.uri },
28+
options: {
29+
insertSpaces: editor.softTabs,
30+
tabSize: editor.tabLength,
31+
},
32+
};
33+
const changes = (await client.sendRequest(
34+
"textDocument/formatting",
35+
documentFormatting
36+
)) as null | Array<lspTypes.TextEdit>;
37+
38+
if (!changes) {
39+
return;
40+
}
41+
42+
applyLSPEdits(editor, changes);
43+
}
44+
}

src/main.test.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ describe("test suite", () => {
118118
});
119119

120120
function assertActivationBehavior() {
121-
expect(nova.commands.register).toBeCalledTimes(4);
121+
expect(nova.commands.register).toBeCalledTimes(5);
122122
expect(nova.commands.register).toBeCalledWith(
123123
"apexskier.typescript.rename",
124124
expect.any(Function)
@@ -135,6 +135,10 @@ describe("test suite", () => {
135135
"apexskier.typescript.commands.organizeImports",
136136
expect.any(Function)
137137
);
138+
expect(nova.commands.register).toBeCalledWith(
139+
"apexskier.typescript.commands.formatDocument",
140+
expect.any(Function)
141+
);
138142

139143
// installs dependencies
140144

@@ -292,7 +296,7 @@ describe("test suite", () => {
292296
assertActivationBehavior();
293297
});
294298

295-
test("watches files for import organization", async () => {
299+
test("watches files to apply post-save actions", async () => {
296300
resetMocks();
297301

298302
await activate();
@@ -312,21 +316,71 @@ describe("test suite", () => {
312316
},
313317
};
314318
setupWatcher(mockEditor);
319+
315320
expect(mockEditor.onWillSave).toBeCalledTimes(0);
321+
const refreshListener = (nova.config.onDidChange as jest.Mock).mock
322+
.calls[0][1];
323+
324+
const getBoolMock: jest.Mock = require("nova-extension-utils").preferences
325+
.getOverridableBoolean;
326+
327+
getBoolMock.mockReturnValue(false);
328+
refreshListener();
316329

317-
require("nova-extension-utils").preferences.getOverridableBoolean.mockReturnValue(
318-
true
330+
// eslint-disable-next-line no-unused-vars
331+
let saveHandler: (editor: unknown) => Promise<unknown>;
332+
333+
getBoolMock.mockReset().mockReturnValue(true);
334+
refreshListener();
335+
saveHandler = mockEditor.onWillSave.mock.calls[0][0];
336+
await saveHandler(mockEditor);
337+
expect(nova.commands.invoke).toBeCalledTimes(2);
338+
expect(nova.commands.invoke).toHaveBeenNthCalledWith(
339+
1,
340+
"apexskier.typescript.commands.organizeImports",
341+
mockEditor
342+
);
343+
expect(nova.commands.invoke).toHaveBeenNthCalledWith(
344+
2,
345+
"apexskier.typescript.commands.formatDocument",
346+
mockEditor
319347
);
320-
setupWatcher(mockEditor);
321-
expect(mockEditor.onWillSave).toBeCalledTimes(1);
322348

323-
const saveHandler = (mockEditor.onWillSave as jest.Mock).mock.calls[0][0];
349+
mockEditor.onWillSave.mockReset();
350+
(nova.commands.invoke as jest.Mock).mockReset();
351+
getBoolMock
352+
.mockReset()
353+
.mockImplementation(
354+
(test: string) =>
355+
test == "apexskier.typescript.config.organizeImportsOnSave"
356+
);
357+
refreshListener();
358+
saveHandler = mockEditor.onWillSave.mock.calls[0][0];
324359
await saveHandler(mockEditor);
360+
expect(nova.commands.invoke).toBeCalledTimes(1);
325361
expect(nova.commands.invoke).toHaveBeenNthCalledWith(
326362
1,
327363
"apexskier.typescript.commands.organizeImports",
328364
mockEditor
329365
);
366+
367+
mockEditor.onWillSave.mockReset();
368+
(nova.commands.invoke as jest.Mock).mockReset();
369+
getBoolMock
370+
.mockReset()
371+
.mockImplementation(
372+
(test: string) =>
373+
test == "apexskier.typescript.config.formatDocumentOnSave"
374+
);
375+
refreshListener();
376+
saveHandler = mockEditor.onWillSave.mock.calls[0][0];
377+
await saveHandler(mockEditor);
378+
expect(nova.commands.invoke).toBeCalledTimes(1);
379+
expect(nova.commands.invoke).toHaveBeenNthCalledWith(
380+
1,
381+
"apexskier.typescript.commands.formatDocument",
382+
mockEditor
383+
);
330384
});
331385
});
332386
});

src/main.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { dependencyManagement, preferences } from "nova-extension-utils";
22
import { registerFindReferences } from "./commands/findReferences";
33
import { registerFindSymbol } from "./commands/findSymbol";
44
import { registerOrganizeImports } from "./commands/organizeImports";
5+
import { registerFormatDocument } from "./commands/formatDocument";
56
import { registerRename } from "./commands/rename";
67
import { registerSignatureHelp } from "./commands/signatureHelp";
78
import { InformationView } from "./informationView";
@@ -11,6 +12,7 @@ import { getTsLibPath } from "./tsLibPath";
1112

1213
const organizeImportsOnSaveKey =
1314
"apexskier.typescript.config.organizeImportsOnSave";
15+
const formatOnSaveKey = "apexskier.typescript.config.formatDocumentOnSave";
1416

1517
nova.commands.register(
1618
"apexskier.typescript.openWorkspaceConfig",
@@ -159,6 +161,7 @@ async function asyncActivate() {
159161
compositeDisposable.add(registerFindSymbol(client));
160162
compositeDisposable.add(registerRename(client));
161163
compositeDisposable.add(registerOrganizeImports(client));
164+
compositeDisposable.add(registerFormatDocument(client));
162165
if (nova.inDevMode()) {
163166
compositeDisposable.add(registerSignatureHelp(client));
164167
}
@@ -211,6 +214,12 @@ async function asyncActivate() {
211214
refreshListener
212215
)
213216
);
217+
editorDisposable.add(
218+
nova.config.onDidChange(formatOnSaveKey, refreshListener)
219+
);
220+
editorDisposable.add(
221+
nova.workspace.config.onDidChange(formatOnSaveKey, refreshListener)
222+
);
214223

215224
let willSaveListener = setupListener();
216225
compositeDisposable.add({
@@ -226,18 +235,33 @@ async function asyncActivate() {
226235

227236
function setupListener() {
228237
if (
229-
(syntaxes as Array<string | null>).includes(editor.document.syntax) &&
230-
preferences.getOverridableBoolean(organizeImportsOnSaveKey)
238+
!(syntaxes as Array<string | null>).includes(editor.document.syntax)
231239
) {
232-
return editor.onWillSave(
233-
async (editor) =>
234-
nova.commands.invoke(
235-
"apexskier.typescript.commands.organizeImports",
236-
editor
237-
) as Promise<void>
238-
);
240+
return;
239241
}
240-
return null;
242+
const organizeImportsOnSave = preferences.getOverridableBoolean(
243+
organizeImportsOnSaveKey
244+
);
245+
const formatDocumentOnSave = preferences.getOverridableBoolean(
246+
formatOnSaveKey
247+
);
248+
if (!organizeImportsOnSave && !formatDocumentOnSave) {
249+
return;
250+
}
251+
return editor.onWillSave(async (editor) => {
252+
if (organizeImportsOnSave) {
253+
await nova.commands.invoke(
254+
"apexskier.typescript.commands.organizeImports",
255+
editor
256+
);
257+
}
258+
if (formatDocumentOnSave) {
259+
await nova.commands.invoke(
260+
"apexskier.typescript.commands.formatDocument",
261+
editor
262+
);
263+
}
264+
});
241265
}
242266
})
243267
);

0 commit comments

Comments
 (0)