Skip to content

Commit e14b0e4

Browse files
DaanV2claude
andauthored
feat: add document highlight, folding, rename and inline completion (#584)
Implement LSP features now available with vscode-languageclient and vscode-languageserver v10 (LSP 3.18): - Document highlight: highlights every whole-word occurrence of the identifier under the cursor. - Folding ranges (mcfunction): folds consecutive comment blocks and explicit #region/#endregion markers. - Rename: prepareRename + rename, reusing the references engine to rename identifiers across the workspace. - Inline completion (proposed 3.18 feature): ghost-text suggestions for mcfunction, derived from the existing completion engine. Enabled on the client via registerProposedFeatures(). Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3aa01a1 commit e14b0e4

9 files changed

Lines changed: 490 additions & 0 deletions

File tree

ide/base/client/src/client/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ export function setupClient(context: vscode.ExtensionContext) {
5757
clientOptions,
5858
);
5959

60+
// Enable proposed protocol features (e.g. inline completion) so the matching
61+
// server capabilities are negotiated.
62+
Manager.Client.registerProposedFeatures();
63+
6064
// Start the client. This will also launch the server
6165
Manager.Client.start().then(() => {
6266
vscode.commands.executeCommand('setContext', 'ext:is_active', true);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export namespace ErrorCodes {
22
export const CompletionService = 10_000;
3+
export const RenameService = 20_000;
34
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { TextDocument as VscodeTextDocument } from 'vscode-languageserver-textdocument';
2+
import { TextDocument } from '../documents/text-document';
3+
import { getOccurrences } from './service';
4+
5+
function createTestDoc(content: string): TextDocument {
6+
const vscDoc = VscodeTextDocument.create('file:///test.mcfunction', 'bc-mcfunction', 0, content);
7+
return {
8+
uri: vscDoc.uri,
9+
getText: (range?: any) => vscDoc.getText(range),
10+
positionAt: (offset: number) => vscDoc.positionAt(offset),
11+
offsetAt: (position: any) => vscDoc.offsetAt(position),
12+
} as unknown as TextDocument;
13+
}
14+
15+
describe('getOccurrences', () => {
16+
it('finds every whole-word occurrence', () => {
17+
const doc = createTestDoc('scoreboard players set @s money 1\nscoreboard players add @s money 5');
18+
const result = getOccurrences(doc, 'money');
19+
20+
expect(result).toHaveLength(2);
21+
expect(result[0]?.range.start).toEqual({ line: 0, character: 26 });
22+
expect(result[1]?.range.start).toEqual({ line: 1, character: 26 });
23+
});
24+
25+
it('does not match substrings inside larger words', () => {
26+
const doc = createTestDoc('tag @s add foo\ntag @s add foobar');
27+
const result = getOccurrences(doc, 'foo');
28+
29+
expect(result).toHaveLength(1);
30+
expect(result[0]?.range.start).toEqual({ line: 0, character: 11 });
31+
});
32+
33+
it('returns nothing for an empty word', () => {
34+
const doc = createTestDoc('say hi');
35+
expect(getOccurrences(doc, '')).toEqual([]);
36+
});
37+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import {
2+
CancellationToken,
3+
Connection,
4+
DocumentHighlight,
5+
DocumentHighlightKind,
6+
DocumentHighlightParams,
7+
} from 'vscode-languageserver';
8+
import { Character } from '../../util';
9+
import { TextDocument } from '../documents';
10+
import { ExtensionContext } from '../extension';
11+
import { IExtendedLogger } from '../logger/logger';
12+
import { getCurrentWord } from '../references/function';
13+
import { BaseService } from '../services/base';
14+
import { CapabilityBuilder } from '../services/capabilities';
15+
import { IService } from '../services/service';
16+
17+
export class DocumentHighlightService extends BaseService implements IService {
18+
readonly name: string = 'document-highlight';
19+
20+
constructor(logger: IExtendedLogger, extension: ExtensionContext) {
21+
super(logger.withPrefix('[document-highlight]'), extension);
22+
}
23+
24+
onInitialize(capabilities: CapabilityBuilder): void {
25+
capabilities.set('documentHighlightProvider', {
26+
workDoneProgress: true,
27+
});
28+
}
29+
30+
setupHandlers(connection: Connection): void {
31+
this.addDisposable(connection.onDocumentHighlight(this.onDocumentHighlight.bind(this)));
32+
}
33+
34+
private onDocumentHighlight(
35+
params: DocumentHighlightParams,
36+
_token: CancellationToken,
37+
): DocumentHighlight[] | undefined {
38+
const document = this.extension.documents.get(params.textDocument.uri);
39+
if (!document) return undefined;
40+
41+
const cursor = document.offsetAt(params.position);
42+
const word = getCurrentWord(document, cursor);
43+
if (word.text === '') return undefined;
44+
45+
return getOccurrences(document, word.text);
46+
}
47+
}
48+
49+
/**
50+
* Finds all whole-word occurrences of the given word within the document and returns them as highlights.
51+
* @param document The document to scan
52+
* @param word The word to look for
53+
*/
54+
export function getOccurrences(document: TextDocument, word: string): DocumentHighlight[] {
55+
const out: DocumentHighlight[] = [];
56+
const text = document.getText();
57+
const length = word.length;
58+
if (length === 0) return out;
59+
60+
let index = text.indexOf(word);
61+
while (index >= 0) {
62+
if (isWholeWord(text, index, length)) {
63+
out.push({
64+
kind: DocumentHighlightKind.Text,
65+
range: {
66+
start: document.positionAt(index),
67+
end: document.positionAt(index + length),
68+
},
69+
});
70+
}
71+
72+
index = text.indexOf(word, index + length);
73+
}
74+
75+
return out;
76+
}
77+
78+
function isWholeWord(text: string, start: number, length: number): boolean {
79+
const before = start - 1;
80+
const after = start + length;
81+
82+
if (before >= 0 && isWordCharacter(text.charCodeAt(before))) return false;
83+
if (after < text.length && isWordCharacter(text.charCodeAt(after))) return false;
84+
85+
return true;
86+
}
87+
88+
function isWordCharacter(c: number): boolean {
89+
if (Character.IsLetterCode(c) || Character.IsNumberCode(c)) return true;
90+
91+
return c === Character.Character_underscore || c === Character.Character_dash || c === Character.Character_column;
92+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { FoldingRangeKind } from 'vscode-languageserver';
2+
import { TextDocument as VscodeTextDocument } from 'vscode-languageserver-textdocument';
3+
import { TextDocument } from '../documents/text-document';
4+
import { provideMcfunctionFolding } from './service';
5+
6+
function createTestDoc(content: string): TextDocument {
7+
const vscDoc = VscodeTextDocument.create('file:///test.mcfunction', 'bc-mcfunction', 0, content);
8+
return {
9+
uri: vscDoc.uri,
10+
lineCount: vscDoc.lineCount,
11+
getLine: (line: number) =>
12+
vscDoc.getText({ start: { line, character: 0 }, end: { line, character: Number.MAX_VALUE } }),
13+
} as unknown as TextDocument;
14+
}
15+
16+
describe('provideMcfunctionFolding', () => {
17+
it('folds blocks of consecutive comments', () => {
18+
const doc = createTestDoc('# header\n# more info\nsay hi');
19+
const result = provideMcfunctionFolding(doc);
20+
21+
expect(result).toEqual([
22+
{ startLine: 0, endLine: 1, kind: FoldingRangeKind.Comment },
23+
]);
24+
});
25+
26+
it('does not fold a single comment line', () => {
27+
const doc = createTestDoc('# lonely\nsay hi');
28+
expect(provideMcfunctionFolding(doc)).toEqual([]);
29+
});
30+
31+
it('folds explicit region markers', () => {
32+
const doc = createTestDoc('#region setup\nsay a\nsay b\n#endregion');
33+
const result = provideMcfunctionFolding(doc);
34+
35+
expect(result).toEqual([
36+
{ startLine: 0, endLine: 3, kind: FoldingRangeKind.Region },
37+
]);
38+
});
39+
40+
it('handles nested region markers', () => {
41+
const doc = createTestDoc('#region outer\n#region inner\nsay hi\n#endregion\n#endregion');
42+
const result = provideMcfunctionFolding(doc);
43+
44+
expect(result).toContainEqual({ startLine: 1, endLine: 3, kind: FoldingRangeKind.Region });
45+
expect(result).toContainEqual({ startLine: 0, endLine: 4, kind: FoldingRangeKind.Region });
46+
});
47+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { Languages } from '@blockception/ide-shared';
2+
import {
3+
CancellationToken,
4+
Connection,
5+
FoldingRange,
6+
FoldingRangeKind,
7+
FoldingRangeParams,
8+
} from 'vscode-languageserver';
9+
import { TextDocument } from '../documents';
10+
import { ExtensionContext } from '../extension';
11+
import { IExtendedLogger } from '../logger/logger';
12+
import { BaseService } from '../services/base';
13+
import { CapabilityBuilder } from '../services/capabilities';
14+
import { IService } from '../services/service';
15+
16+
export class FoldingService extends BaseService implements IService {
17+
readonly name: string = 'folding';
18+
19+
constructor(logger: IExtendedLogger, extension: ExtensionContext) {
20+
super(logger.withPrefix('[folding]'), extension);
21+
}
22+
23+
onInitialize(capabilities: CapabilityBuilder): void {
24+
capabilities.set('foldingRangeProvider', {
25+
workDoneProgress: true,
26+
});
27+
}
28+
29+
setupHandlers(connection: Connection): void {
30+
this.addDisposable(connection.onFoldingRanges(this.onFoldingRanges.bind(this)));
31+
}
32+
33+
private onFoldingRanges(params: FoldingRangeParams, _token: CancellationToken): FoldingRange[] | undefined {
34+
const document = this.extension.documents.get(params.textDocument.uri);
35+
if (!document) return undefined;
36+
37+
// JSON folding is provided by the built-in language service, mcfunction is line based.
38+
if (document.languageId !== Languages.McFunctionIdentifier) return undefined;
39+
40+
return provideMcfunctionFolding(document);
41+
}
42+
}
43+
44+
/**
45+
* Provides folding ranges for an mcfunction document: explicit `#region`/`#endregion`
46+
* markers and blocks of consecutive comment lines.
47+
* @param document The document to provide folding for
48+
*/
49+
export function provideMcfunctionFolding(document: TextDocument): FoldingRange[] {
50+
const out: FoldingRange[] = [];
51+
const regions: number[] = [];
52+
53+
let commentStart = -1;
54+
const lineCount = document.lineCount;
55+
56+
const flushComments = (endLine: number) => {
57+
// Only fold comment blocks that span more than a single line.
58+
if (commentStart >= 0 && endLine > commentStart) {
59+
out.push(FoldingRange.create(commentStart, endLine, undefined, undefined, FoldingRangeKind.Comment));
60+
}
61+
commentStart = -1;
62+
};
63+
64+
for (let line = 0; line < lineCount; line++) {
65+
const text = document.getLine(line).trim();
66+
67+
if (isRegionStart(text)) {
68+
flushComments(line - 1);
69+
regions.push(line);
70+
continue;
71+
}
72+
73+
if (isRegionEnd(text)) {
74+
flushComments(line - 1);
75+
const start = regions.pop();
76+
if (start !== undefined && line > start) {
77+
out.push(FoldingRange.create(start, line, undefined, undefined, FoldingRangeKind.Region));
78+
}
79+
continue;
80+
}
81+
82+
if (text.startsWith('#')) {
83+
if (commentStart < 0) commentStart = line;
84+
continue;
85+
}
86+
87+
flushComments(line - 1);
88+
}
89+
90+
flushComments(lineCount - 1);
91+
92+
return out;
93+
}
94+
95+
function isRegionStart(text: string): boolean {
96+
return /^#\s*region\b/i.test(text);
97+
}
98+
99+
function isRegionEnd(text: string): boolean {
100+
return /^#\s*endregion\b/i.test(text);
101+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Languages } from '@blockception/ide-shared';
2+
import {
3+
CancellationToken,
4+
Connection,
5+
InlineCompletionItem,
6+
InlineCompletionParams,
7+
ProposedFeatures,
8+
WorkDoneProgressReporter,
9+
} from 'vscode-languageserver';
10+
import { Context } from '../context/context';
11+
import { ExtensionContext } from '../extension';
12+
import { IExtendedLogger } from '../logger/logger';
13+
import { getCurrentWord } from '../references/function';
14+
import { BaseService } from '../services/base';
15+
import { CapabilityBuilder } from '../services/capabilities';
16+
import { IService } from '../services/service';
17+
import { createBuilder } from '../completion/builder/builder';
18+
import { CompletionContext } from '../completion/context';
19+
import { onCompletionRequest } from '../completion/on-request';
20+
21+
/** The maximum number of inline suggestions to offer at once. */
22+
const maxItems = 5;
23+
24+
export class InlineCompletionService extends BaseService implements IService {
25+
readonly name: string = 'inline-completion';
26+
27+
constructor(logger: IExtendedLogger, extension: ExtensionContext) {
28+
super(logger.withPrefix('[inline-completion]'), extension);
29+
}
30+
31+
onInitialize(capabilities: CapabilityBuilder): void {
32+
capabilities.set('inlineCompletionProvider', {});
33+
}
34+
35+
setupHandlers(connection: Connection): void {
36+
// `inlineCompletion` is a proposed feature, enabled through `ProposedFeatures.all`,
37+
// but it is not part of the base `Connection` type.
38+
const languages = (connection as ProposedFeatures.Connection).languages;
39+
this.addDisposable(languages.inlineCompletion.on(this.onInlineCompletion.bind(this)));
40+
}
41+
42+
private onInlineCompletion(
43+
params: InlineCompletionParams,
44+
token: CancellationToken,
45+
workDoneProgress: WorkDoneProgressReporter,
46+
): InlineCompletionItem[] {
47+
const document = this.extension.documents.get(params.textDocument.uri);
48+
if (!document) return [];
49+
50+
// Inline ghost text is most useful for mcfunction; richer languages are
51+
// already served well by the regular completion list.
52+
if (document.languageId !== Languages.McFunctionIdentifier) return [];
53+
54+
const cursor = document.offsetAt(params.position);
55+
const word = getCurrentWord(document, cursor);
56+
57+
// Only the portion of the word that has been typed before the cursor.
58+
const prefix = word.text.slice(0, cursor - word.offset);
59+
if (prefix === '') return [];
60+
61+
const context = Context.create<CompletionContext>(
62+
this.extension,
63+
{
64+
document,
65+
token,
66+
workDoneProgress,
67+
cursor,
68+
builder: createBuilder(token, workDoneProgress),
69+
textDocument: params.textDocument,
70+
position: params.position,
71+
},
72+
{ logger: this.logger },
73+
);
74+
75+
onCompletionRequest(context);
76+
77+
const range = {
78+
start: document.positionAt(word.offset),
79+
end: params.position,
80+
};
81+
82+
const lowerPrefix = prefix.toLowerCase();
83+
const seen = new Set<string>();
84+
const out: InlineCompletionItem[] = [];
85+
86+
for (const item of context.builder.getItems()) {
87+
const insert = typeof item.insertText === 'string' ? item.insertText : item.label;
88+
if (insert.length <= prefix.length) continue;
89+
if (!insert.toLowerCase().startsWith(lowerPrefix)) continue;
90+
if (seen.has(insert)) continue;
91+
92+
seen.add(insert);
93+
out.push({ insertText: insert, filterText: insert, range });
94+
95+
if (out.length >= maxItems) break;
96+
}
97+
98+
return out;
99+
}
100+
}

0 commit comments

Comments
 (0)