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

Commit 174546a

Browse files
authored
Add an organize import command (#129)
* Add an organize import command Resolves #104 (partially) * lint * Add eslint prettier config * Allow organizing imports on save * changelog * bump nova-extension-utils * Optimize handler * lint
2 parents 63a35d0 + 5a6c6c0 commit 174546a

File tree

11 files changed

+343
-81
lines changed

11 files changed

+343
-81
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module.exports = {
77
extends: [
88
"eslint:recommended",
99
"plugin:@typescript-eslint/eslint-recommended",
10+
"prettier",
1011
],
1112
parser: "@typescript-eslint/parser",
1213
parserOptions: {

.nova/Configuration.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"apexskier.typescript.config.organizeImportsOnSave": "true",
23
"editor.default_syntax": "typescript",
34
"workspace.name": "TypeScript Extension"
45
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@
3535
"@typescript-eslint/parser": "^3.10.1",
3636
"concurrently": "^5.3.0",
3737
"eslint": "^7.13.0",
38+
"eslint-config-prettier": "^6.15.0",
3839
"eslint-plugin-nova": "^1.2.0",
3940
"jest": "^26.6.3",
40-
"nova-extension-utils": "^1.2.2",
41+
"nova-extension-utils": "^1.3.0",
4142
"onchange": "^7.1.0",
4243
"prettier": "^2.1.2",
4344
"rollup": "^2.33.1",

rollup.config.main.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import typescript from "rollup-plugin-typescript2";
21
import commonjs from "@rollup/plugin-commonjs";
32
import resolve from "@rollup/plugin-node-resolve";
3+
import typescript from "rollup-plugin-typescript2";
44

55
export default {
66
input: "src/main.ts",
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// eslint-disable-next-line no-unused-vars
2+
import type * as lspTypes from "vscode-languageserver-protocol";
3+
import { registerOrganizeImports } from "./organizeImports";
4+
5+
class MockRange {
6+
// eslint-disable-next-line no-unused-vars
7+
constructor(readonly start: number, readonly end: number) {}
8+
}
9+
(global as any).Range = MockRange;
10+
11+
describe("organizeImports command", () => {
12+
beforeEach(() => {
13+
(global as any).nova = Object.assign(nova, {
14+
commands: {
15+
register: jest.fn(),
16+
},
17+
workspace: {
18+
showErrorMessage(err: Error) {
19+
throw err;
20+
},
21+
showWarningMessage: jest.fn(),
22+
},
23+
});
24+
25+
mockEditor.edit.mockClear();
26+
mockEditor.scrollToCursorPosition.mockClear();
27+
mockEditor.selectWordsContainingCursors.mockClear();
28+
});
29+
30+
const mockEditor = {
31+
selectedRanges: [new Range(2, 3)],
32+
selectedText: "selectedText",
33+
document: {
34+
length: 10,
35+
uri: "currentDocURI",
36+
path: "/path",
37+
getTextInRange() {
38+
return "";
39+
},
40+
eol: "\n",
41+
},
42+
edit: jest.fn(),
43+
selectWordsContainingCursors: jest.fn(),
44+
scrollToCursorPosition: jest.fn(),
45+
};
46+
47+
function getCommand(
48+
languageClient: LanguageClient,
49+
register: (client: LanguageClient) => Disposable
50+
): (...args: Array<any>) => Promise<void> {
51+
register(languageClient);
52+
expect(nova.commands.register).toHaveBeenCalledWith(
53+
"apexskier.typescript.commands.organizeImports",
54+
expect.any(Function)
55+
);
56+
const command = (nova.commands.register as jest.Mock).mock.calls[0][1];
57+
(nova.commands.register as jest.Mock).mockClear();
58+
return command;
59+
}
60+
61+
it("asks the server to organize imports, then resets your selection", async () => {
62+
const mockLanguageClient = {
63+
sendRequest: jest.fn().mockImplementationOnce(() => {
64+
mockEditor.document.length = 14;
65+
}),
66+
};
67+
const command = getCommand(
68+
(mockLanguageClient as any) as LanguageClient,
69+
registerOrganizeImports
70+
);
71+
expect(mockEditor.selectedRanges).toEqual([new Range(2, 3)]);
72+
await command(mockEditor);
73+
74+
expect(mockLanguageClient.sendRequest).toBeCalledTimes(1);
75+
expect(mockLanguageClient.sendRequest).toHaveBeenCalledWith(
76+
"workspace/executeCommand",
77+
{
78+
arguments: ["/path"],
79+
command: "_typescript.organizeImports",
80+
}
81+
);
82+
expect(mockEditor.selectedRanges).toEqual([new Range(6, 7)]);
83+
expect(mockEditor.scrollToCursorPosition).toBeCalledTimes(1);
84+
});
85+
86+
it("doesn't reset scroll to a negative value", async () => {
87+
const mockLanguageClient = {
88+
sendRequest: jest.fn().mockImplementationOnce(() => {
89+
mockEditor.document.length = 0;
90+
}),
91+
};
92+
const command = getCommand(
93+
(mockLanguageClient as any) as LanguageClient,
94+
registerOrganizeImports
95+
);
96+
await command(mockEditor);
97+
expect(mockEditor.selectedRanges).toEqual([new Range(0, 0)]);
98+
expect(mockEditor.scrollToCursorPosition).toBeCalledTimes(1);
99+
});
100+
101+
it("warns if the document isn't saved", async () => {
102+
const mockLanguageClient = {
103+
sendRequest: jest.fn().mockReturnValueOnce(Promise.resolve(null)),
104+
};
105+
mockEditor.document.path = "";
106+
const command = getCommand(
107+
(mockLanguageClient as any) as LanguageClient,
108+
registerOrganizeImports
109+
);
110+
await command(mockEditor);
111+
112+
expect(nova.workspace.showWarningMessage).toBeCalledTimes(1);
113+
expect(nova.workspace.showWarningMessage).toHaveBeenCalledWith(
114+
"Please save this document before organizing imports."
115+
);
116+
});
117+
});

src/commands/organizeImports.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// eslint-disable-next-line no-unused-vars
2+
import type * as lspTypes from "vscode-languageserver-protocol";
3+
import { wrapCommand } from "../novaUtils";
4+
5+
// NOTE: this is explicitly built for the typescript-language-server; it directly invokes the specific command it uses.
6+
// In order to decouple and become LSP generic, we'd need to first send a code action request for only
7+
// lspTypes.CodeActionKind.SourceOrganizeImports, then process the response's code action or commands.
8+
// That would mean reimplementing that processing in the extension, which I don't like.
9+
// Related conversation at https://devforum.nova.app/t/ability-to-send-lsp-messages-to-nova/466
10+
11+
export function registerOrganizeImports(client: LanguageClient) {
12+
return nova.commands.register(
13+
"apexskier.typescript.commands.organizeImports",
14+
wrapCommand(organizeImports)
15+
);
16+
17+
async function organizeImports(editor: TextEditor): Promise<void>;
18+
async function organizeImports(
19+
workspace: Workspace,
20+
editor: TextEditor
21+
): Promise<void>;
22+
async function organizeImports(
23+
editorOrWorkspace: TextEditor | Workspace,
24+
maybeEditor?: TextEditor
25+
) {
26+
const editor: TextEditor = maybeEditor ?? (editorOrWorkspace as TextEditor);
27+
28+
const originalSelections = editor.selectedRanges;
29+
const originalLength = editor.document.length;
30+
31+
if (!editor.document.path) {
32+
nova.workspace.showWarningMessage(
33+
"Please save this document before organizing imports."
34+
);
35+
return;
36+
}
37+
38+
const organizeImportsCommand: lspTypes.ExecuteCommandParams = {
39+
command: "_typescript.organizeImports",
40+
arguments: [editor.document.path],
41+
};
42+
await client.sendRequest(
43+
"workspace/executeCommand",
44+
organizeImportsCommand
45+
);
46+
47+
// Move selection/cursor back to where it was
48+
// NOTE: this isn't fully perfect, since it doesn't know where the changes were made.
49+
// If your cursor is above the imports it won't be returned properly.
50+
// I'm okay with this for now. To fully fix it we'd need to do the import organization manually
51+
// based on an explicit code action, which isn't worth it.
52+
const newLength = editor.document.length;
53+
const lengthChange = originalLength - newLength;
54+
editor.selectedRanges = originalSelections.map(
55+
(r) =>
56+
new Range(
57+
Math.max(0, r.start - lengthChange),
58+
Math.max(0, r.end - lengthChange)
59+
)
60+
);
61+
editor.scrollToCursorPosition();
62+
}
63+
}

src/main.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ jest.useFakeTimers();
1818
},
1919
workspace: {
2020
path: "/workspace",
21+
onDidAddTextEditor: jest.fn(),
2122
},
2223
extension: {
2324
path: "/extension",
@@ -69,6 +70,7 @@ describe("test suite", () => {
6970
.mockImplementation(() => Promise.resolve());
7071
nova.fs.access = jest.fn().mockReturnValue(true);
7172
(nova.commands.register as jest.Mock).mockReset();
73+
(nova.commands.invoke as jest.Mock).mockReset();
7274
LanguageClientMock.mockReset().mockImplementation(() => ({
7375
onRequest: jest.fn(),
7476
onNotification: jest.fn(),
@@ -86,6 +88,7 @@ describe("test suite", () => {
8688
start: jest.fn(),
8789
}));
8890
(informationViewModule.InformationView as jest.Mock).mockReset();
91+
(nova.workspace.onDidAddTextEditor as jest.Mock).mockReset();
8992
}
9093

9194
const reload = (nova.commands.register as jest.Mock).mock.calls.find(
@@ -115,7 +118,7 @@ describe("test suite", () => {
115118
});
116119

117120
function assertActivationBehavior() {
118-
expect(nova.commands.register).toBeCalledTimes(3);
121+
expect(nova.commands.register).toBeCalledTimes(4);
119122
expect(nova.commands.register).toBeCalledWith(
120123
"apexskier.typescript.rename",
121124
expect.any(Function)
@@ -128,6 +131,10 @@ describe("test suite", () => {
128131
"apexskier.typescript.findReferences",
129132
expect.any(Function)
130133
);
134+
expect(nova.commands.register).toBeCalledWith(
135+
"apexskier.typescript.commands.organizeImports",
136+
expect.any(Function)
137+
);
131138

132139
// installs dependencies
133140

@@ -287,5 +294,42 @@ describe("test suite", () => {
287294

288295
assertActivationBehavior();
289296
});
297+
298+
test("watches files for import organization", async () => {
299+
resetMocks();
300+
301+
await activate();
302+
303+
(nova as any).config = { onDidChange: jest.fn() };
304+
(nova as any).workspace.config = { onDidChange: jest.fn() };
305+
306+
expect(nova.workspace.onDidAddTextEditor).toBeCalledTimes(1);
307+
const setupWatcher = (nova.workspace.onDidAddTextEditor as jest.Mock).mock
308+
.calls[0][0];
309+
const mockEditor = {
310+
onWillSave: jest.fn(),
311+
onDidDestroy: jest.fn(),
312+
document: {
313+
syntax: "typescript",
314+
onDidChangeSyntax: jest.fn(),
315+
},
316+
};
317+
setupWatcher(mockEditor);
318+
expect(mockEditor.onWillSave).toBeCalledTimes(0);
319+
320+
require("nova-extension-utils").preferences.getOverridableBoolean.mockReturnValue(
321+
true
322+
);
323+
setupWatcher(mockEditor);
324+
expect(mockEditor.onWillSave).toBeCalledTimes(1);
325+
326+
const saveHandler = (mockEditor.onWillSave as jest.Mock).mock.calls[0][0];
327+
await saveHandler(mockEditor);
328+
expect(nova.commands.invoke).toHaveBeenNthCalledWith(
329+
1,
330+
"apexskier.typescript.commands.organizeImports",
331+
mockEditor
332+
);
333+
});
290334
});
291335
});

src/main.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import { dependencyManagement } from "nova-extension-utils";
1+
import { dependencyManagement, preferences } from "nova-extension-utils";
22
import { registerFindReferences } from "./commands/findReferences";
33
import { registerFindSymbol } from "./commands/findSymbol";
4+
import { registerOrganizeImports } from "./commands/organizeImports";
45
import { registerRename } from "./commands/rename";
56
import { registerSignatureHelp } from "./commands/signatureHelp";
6-
import { wrapCommand } from "./novaUtils";
77
import { InformationView } from "./informationView";
8-
import { getTsLibPath } from "./tsLibPath";
98
import { isEnabledForJavascript } from "./isEnabledForJavascript";
9+
import { wrapCommand } from "./novaUtils";
10+
import { getTsLibPath } from "./tsLibPath";
11+
12+
const organizeImportsOnSaveKey =
13+
"apexskier.typescript.config.organizeImportsOnSave";
1014

1115
nova.commands.register(
1216
"apexskier.typescript.openWorkspaceConfig",
@@ -154,6 +158,7 @@ async function asyncActivate() {
154158
compositeDisposable.add(registerFindReferences(client));
155159
compositeDisposable.add(registerFindSymbol(client));
156160
compositeDisposable.add(registerRename(client));
161+
compositeDisposable.add(registerOrganizeImports(client));
157162
if (nova.inDevMode()) {
158163
compositeDisposable.add(registerSignatureHelp(client));
159164
}
@@ -186,6 +191,56 @@ async function asyncActivate() {
186191

187192
client.start();
188193

194+
// auto-organize imports on save
195+
compositeDisposable.add(
196+
nova.workspace.onDidAddTextEditor((editor) => {
197+
const editorDisposable = new CompositeDisposable();
198+
compositeDisposable.add(editorDisposable);
199+
compositeDisposable.add(
200+
editor.onDidDestroy(() => editorDisposable.dispose())
201+
);
202+
203+
// watch things that might change if this needs to happen or not
204+
editorDisposable.add(editor.document.onDidChangeSyntax(refreshListener));
205+
editorDisposable.add(
206+
nova.config.onDidChange(organizeImportsOnSaveKey, refreshListener)
207+
);
208+
editorDisposable.add(
209+
nova.workspace.config.onDidChange(
210+
organizeImportsOnSaveKey,
211+
refreshListener
212+
)
213+
);
214+
215+
let willSaveListener = setupListener();
216+
compositeDisposable.add({
217+
dispose() {
218+
willSaveListener?.dispose();
219+
},
220+
});
221+
222+
function refreshListener() {
223+
willSaveListener?.dispose();
224+
willSaveListener = setupListener();
225+
}
226+
227+
function setupListener() {
228+
if (
229+
(syntaxes as Array<string | null>).includes(editor.document.syntax) &&
230+
preferences.getOverridableBoolean(organizeImportsOnSaveKey)
231+
) {
232+
return editor.onWillSave(async (editor) =>
233+
nova.commands.invoke(
234+
"apexskier.typescript.commands.organizeImports",
235+
editor
236+
)
237+
);
238+
}
239+
return null;
240+
}
241+
})
242+
);
243+
189244
getTsVersion(tslibPath).then((version) => {
190245
informationView.tsVersion = version;
191246
});

0 commit comments

Comments
 (0)