Skip to content

Commit 39d3503

Browse files
committed
Implement document formatting
1 parent 1686c4a commit 39d3503

4 files changed

Lines changed: 285 additions & 1 deletion

File tree

src/formatting.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Facet } from '@codemirror/state';
2+
import type { EditorView } from '@codemirror/view';
3+
import type * as LSP from 'vscode-languageserver-protocol';
4+
5+
import { languageServerPlugin } from './plugin';
6+
import { offsetToPos, posToOffset } from './pos';
7+
8+
export const formattingOptions = Facet.define<
9+
LSP.FormattingOptions,
10+
LSP.FormattingOptions
11+
>({
12+
combine(values) {
13+
return Object.assign(
14+
{ tabSize: 4, insertSpaces: true },
15+
...values,
16+
);
17+
},
18+
});
19+
20+
function getFormattingOptions(view: EditorView): LSP.FormattingOptions {
21+
const opts = view.state.facet(formattingOptions);
22+
return {
23+
...opts,
24+
tabSize: opts.tabSize ?? view.state.tabSize,
25+
};
26+
}
27+
28+
function applyTextEdits(view: EditorView, edits: LSP.TextEdit[]) {
29+
const changes = edits.map((edit) => ({
30+
from: posToOffset(view.state.doc, edit.range.start),
31+
to: posToOffset(view.state.doc, edit.range.end),
32+
insert: edit.newText,
33+
}));
34+
view.dispatch(view.state.update({ changes }));
35+
}
36+
37+
export function formatDocument(view: EditorView): boolean {
38+
const plugin = view.plugin(languageServerPlugin);
39+
if (!plugin) return false;
40+
41+
if (
42+
!plugin.client.ready ||
43+
!plugin.client.capabilities?.documentFormattingProvider
44+
) {
45+
return false;
46+
}
47+
48+
plugin.client
49+
.textDocumentFormatting({
50+
textDocument: { uri: plugin.documentUri },
51+
options: getFormattingOptions(view),
52+
})
53+
.then((edits) => {
54+
if (edits && edits.length > 0) {
55+
applyTextEdits(view, edits);
56+
}
57+
});
58+
59+
return true;
60+
}
61+
62+
export function formatSelection(view: EditorView): boolean {
63+
const plugin = view.plugin(languageServerPlugin);
64+
if (!plugin) return false;
65+
66+
if (
67+
!plugin.client.ready ||
68+
!plugin.client.capabilities?.documentRangeFormattingProvider
69+
) {
70+
return false;
71+
}
72+
73+
const { from, to } = view.state.selection.main;
74+
75+
plugin.client
76+
.textDocumentRangeFormatting({
77+
textDocument: { uri: plugin.documentUri },
78+
range: {
79+
start: offsetToPos(view.state.doc, from),
80+
end: offsetToPos(view.state.doc, to),
81+
},
82+
options: getFormattingOptions(view),
83+
})
84+
.then((edits) => {
85+
if (edits && edits.length > 0) {
86+
applyTextEdits(view, edits);
87+
}
88+
});
89+
90+
return true;
91+
}

src/highlight.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Extension, StateField } from '@codemirror/state';
2+
import {
3+
Decoration,
4+
DecorationSet,
5+
EditorView,
6+
ViewPlugin,
7+
ViewUpdate,
8+
} from '@codemirror/view';
9+
import { DocumentHighlightKind } from 'vscode-languageserver-protocol';
10+
11+
import { languageServerPlugin } from './plugin';
12+
import { offsetToPos, posToOffset } from './pos';
13+
14+
const highlightField = StateField.define<DecorationSet>({
15+
create() {
16+
return Decoration.none;
17+
},
18+
update(decorations, tr) {
19+
for (const effect of tr.effects) {
20+
if (effect.is(setHighlightsEffect)) {
21+
return effect.value;
22+
}
23+
}
24+
if (tr.docChanged) {
25+
return Decoration.none;
26+
}
27+
return decorations;
28+
},
29+
provide: (field) => EditorView.decorations.from(field),
30+
});
31+
32+
import { StateEffect } from '@codemirror/state';
33+
34+
const setHighlightsEffect = StateEffect.define<DecorationSet>();
35+
36+
const textDecoration = Decoration.mark({
37+
class: 'cm-lsp-highlight-text',
38+
});
39+
const readDecoration = Decoration.mark({
40+
class: 'cm-lsp-highlight-read',
41+
});
42+
const writeDecoration = Decoration.mark({
43+
class: 'cm-lsp-highlight-write',
44+
});
45+
46+
function decorationForKind(kind?: DocumentHighlightKind) {
47+
switch (kind) {
48+
case DocumentHighlightKind.Read:
49+
return readDecoration;
50+
case DocumentHighlightKind.Write:
51+
return writeDecoration;
52+
default:
53+
return textDecoration;
54+
}
55+
}
56+
57+
const highlightPlugin = ViewPlugin.fromClass(
58+
class {
59+
private pending: number = -1;
60+
61+
update(update: ViewUpdate) {
62+
if (!update.selectionSet && !update.docChanged) {
63+
return;
64+
}
65+
66+
if (update.docChanged) {
67+
return;
68+
}
69+
70+
clearTimeout(this.pending);
71+
this.pending = setTimeout(
72+
() => this.requestHighlights(update.view),
73+
100,
74+
) as any;
75+
}
76+
77+
async requestHighlights(view: EditorView) {
78+
const plugin = view.plugin(languageServerPlugin);
79+
if (!plugin) return;
80+
81+
const { state } = view;
82+
const pos = state.selection.main.head;
83+
84+
if (
85+
!plugin.client.ready ||
86+
!plugin.client.capabilities?.documentHighlightProvider
87+
) {
88+
view.dispatch({ effects: setHighlightsEffect.of(Decoration.none) });
89+
return;
90+
}
91+
92+
const result =
93+
await plugin.client.textDocumentDocumentHighlight({
94+
textDocument: { uri: plugin.documentUri },
95+
position: offsetToPos(state.doc, pos),
96+
});
97+
98+
if (view.state.selection.main.head !== pos) {
99+
return;
100+
}
101+
102+
if (!result || result.length === 0) {
103+
view.dispatch({ effects: setHighlightsEffect.of(Decoration.none) });
104+
return;
105+
}
106+
107+
const decorations = result
108+
.map((highlight) => {
109+
const from = posToOffset(
110+
view.state.doc,
111+
highlight.range.start,
112+
);
113+
const to = posToOffset(
114+
view.state.doc,
115+
highlight.range.end,
116+
);
117+
if (from == null || to == null) return null;
118+
return decorationForKind(highlight.kind).range(from, to);
119+
})
120+
.filter((d) => d != null)
121+
.sort((a, b) => a.from - b.from);
122+
123+
view.dispatch({
124+
effects: setHighlightsEffect.of(Decoration.set(decorations)),
125+
});
126+
}
127+
128+
destroy() {
129+
clearTimeout(this.pending);
130+
}
131+
},
132+
);
133+
134+
export const documentHighlight = (): Extension => [
135+
highlightField,
136+
highlightPlugin,
137+
];

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ export {
1010
jumpToDefinitionPos,
1111
jumpToDefinitionKeymap,
1212
} from './definition';
13+
export {
14+
formatDocument,
15+
formatSelection,
16+
formattingOptions,
17+
} from './formatting';
1318
export {
1419
PyrightInitializationOptions,
1520
RustAnalyzerInitializationOptions,
@@ -34,6 +39,7 @@ import type {
3439
import { jumpToDefinitionKeymap } from './definition';
3540
import { hoverTooltip } from './hover';
3641
import { autocompletion } from './completion';
42+
import { documentHighlight } from './highlight';
3743
import { mouseHandler } from './mouse';
3844

3945
interface LanguageServerOptions<InitializationOptions = unknown>
@@ -78,6 +84,7 @@ export function languageServerWithTransport<InitializationOptions = unknown>(
7884
}),
7985
hoverTooltip(),
8086
autocompletion(),
87+
documentHighlight(),
8188
keymap.of([...jumpToDefinitionKeymap]),
8289
mouseHandler(),
8390
];

src/plugin.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ interface LSPRequestMap {
6161
LSP.TypeDefinitionParams,
6262
LSP.Location | LSP.Location[] | LSP.LocationLink[] | null,
6363
];
64+
'textDocument/documentHighlight': [
65+
LSP.DocumentHighlightParams,
66+
LSP.DocumentHighlight[] | null,
67+
];
68+
'textDocument/formatting': [
69+
LSP.DocumentFormattingParams,
70+
LSP.TextEdit[] | null,
71+
];
72+
'textDocument/rangeFormatting': [
73+
LSP.DocumentRangeFormattingParams,
74+
LSP.TextEdit[] | null,
75+
];
6476
'completionItem/resolve': [LSP.CompletionItem, LSP.CompletionItem];
6577
}
6678

@@ -191,6 +203,15 @@ export class LanguageServerClient<InitializationOptions = unknown> {
191203
dynamicRegistration: true,
192204
linkSupport: true,
193205
},
206+
documentHighlight: {
207+
dynamicRegistration: true,
208+
},
209+
formatting: {
210+
dynamicRegistration: true,
211+
},
212+
rangeFormatting: {
213+
dynamicRegistration: true,
214+
},
194215
},
195216
workspace: {
196217
didChangeConfiguration: {
@@ -253,6 +274,34 @@ export class LanguageServerClient<InitializationOptions = unknown> {
253274
);
254275
}
255276

277+
public async textDocumentDocumentHighlight(
278+
params: LSP.DocumentHighlightParams,
279+
) {
280+
return await this.request(
281+
'textDocument/documentHighlight',
282+
params,
283+
timeout,
284+
);
285+
}
286+
287+
public async textDocumentFormatting(params: LSP.DocumentFormattingParams) {
288+
return await this.request(
289+
'textDocument/formatting',
290+
params,
291+
timeout,
292+
);
293+
}
294+
295+
public async textDocumentRangeFormatting(
296+
params: LSP.DocumentRangeFormattingParams,
297+
) {
298+
return await this.request(
299+
'textDocument/rangeFormatting',
300+
params,
301+
timeout,
302+
);
303+
}
304+
256305
public async completionItemResolve(
257306
params: LSP.CompletionItem,
258307
): Promise<LSP.CompletionItem> {
@@ -304,7 +353,7 @@ export enum SynchronizationMethod {
304353
export class LanguageServerPlugin implements PluginValue {
305354
public client: LanguageServerClient;
306355

307-
private documentUri: string;
356+
public documentUri: string;
308357
private languageId: string;
309358
private documentVersion: number;
310359
private allowHTMLContent: boolean;

0 commit comments

Comments
 (0)