Skip to content

Commit fb90eb4

Browse files
authored
Merge pull request #38 from RumbleDB/object-completion
feat: enrich completion intent with object lookup support
2 parents 5b8acb9 + 6413f3b commit fb90eb4

10 files changed

Lines changed: 202 additions & 8 deletions

File tree

.changeset/tasty-mails-grin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"jsoniq-language-server": minor
3+
---
4+
5+
feat: enrich completion intent with object lookup support

packages/language-server/src/completion.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,23 @@ import {
2929
} from "./assets/function-docs.js";
3030
import { collectCompletionIntent } from "./parser/index.js";
3131
import { getDocumentText } from "./parser/utils.js";
32-
import { formatSequenceType } from "./static-typecheck/types.js";
32+
import {
33+
formatSequenceType,
34+
formatTypeDefinition,
35+
type ObjectTypeDefinition,
36+
} from "./static-typecheck/types.js";
37+
import { getTypeAtPositionFromSource } from "./type-at-position/service.js";
3338

3439
const VARIABLE_PREFIX_PATTERN = /\$[A-Za-z0-9_.:-]*$/;
40+
const OBJECT_FIELD_PREFIX_PATTERN = /[A-Za-z_][A-Za-z0-9_:-]*$/;
3541
const GENERIC_BUILTIN_PARAMETER_PREFIX = "$arg";
3642

43+
interface DotCompletionContext {
44+
dotOffset: number;
45+
fieldPrefix: string;
46+
syntheticSource: string;
47+
}
48+
3749
export function findCompletions(document: TextDocument, position: Position): CompletionItem[] {
3850
const source = getDocumentText(document);
3951
const cursorOffset = document.offsetAt(position);
@@ -103,6 +115,85 @@ export function findCompletions(document: TextDocument, position: Position): Com
103115
]);
104116
}
105117

118+
export async function findCompletionsWithTypeInfo(
119+
document: TextDocument,
120+
position: Position,
121+
): Promise<CompletionItem[]> {
122+
const source = getDocumentText(document);
123+
const cursorOffset = document.offsetAt(position);
124+
const intent = collectCompletionIntent(document, cursorOffset);
125+
const dotContext = getDotCompletionContext(source, cursorOffset);
126+
127+
if (
128+
dotContext !== null &&
129+
intent?.allowObjectLookup &&
130+
intent.objectLookupDotOffset === dotContext.dotOffset
131+
) {
132+
/// Return only dot completions because it's more relevant
133+
return findDotCompletions(document, dotContext);
134+
}
135+
136+
return findCompletions(document, position);
137+
}
138+
139+
async function findDotCompletions(
140+
document: TextDocument,
141+
context: DotCompletionContext,
142+
): Promise<CompletionItem[]> {
143+
const result = await getTypeAtPositionFromSource(
144+
document.uri,
145+
context.syntheticSource,
146+
document.positionAt(context.dotOffset),
147+
);
148+
const objectType = result.sequenceType?.itemType;
149+
150+
if (objectType?.kind !== "object") {
151+
return [];
152+
}
153+
154+
const replaceRange = {
155+
start: document.positionAt(context.dotOffset + 1),
156+
end: document.positionAt(context.dotOffset + 1 + context.fieldPrefix.length),
157+
};
158+
159+
return withSortText(
160+
objectFieldCompletions(objectType, context.fieldPrefix).map(([fieldName, fieldType]) => ({
161+
label: fieldName,
162+
kind: CompletionItemKind.Field,
163+
detail: formatTypeDefinition(fieldType),
164+
textEdit: TextEdit.replace(replaceRange, fieldName),
165+
})),
166+
);
167+
}
168+
169+
function objectFieldCompletions(
170+
objectType: ObjectTypeDefinition,
171+
fieldPrefix: string,
172+
): Array<[string, ObjectTypeDefinition["fields"][string]]> {
173+
return Object.entries(objectType.fields).filter(([fieldName]) =>
174+
fieldName.startsWith(fieldPrefix),
175+
);
176+
}
177+
178+
function getDotCompletionContext(
179+
source: string,
180+
cursorOffset: number,
181+
): DotCompletionContext | null {
182+
const prefix = source.slice(0, cursorOffset);
183+
const fieldPrefix = prefix.match(OBJECT_FIELD_PREFIX_PATTERN)?.[0] ?? "";
184+
const dotOffset = cursorOffset - fieldPrefix.length - 1;
185+
186+
if (dotOffset < 0 || source[dotOffset] !== ".") {
187+
return null;
188+
}
189+
190+
return {
191+
dotOffset,
192+
fieldPrefix,
193+
syntheticSource: source.slice(0, dotOffset) + source.slice(cursorOffset),
194+
};
195+
}
196+
106197
function toCompletionItem(declaration: BaseDefinition): CompletionItem {
107198
const name = definitionNameToString(declaration);
108199
if (isSourceFunctionDefinition(declaration)) {

packages/language-server/src/main.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
type InitializeResult,
99
} from "vscode-languageserver/node";
1010

11-
import { findCompletions } from "./completion.js";
11+
import { findCompletionsWithTypeInfo } from "./completion.js";
1212
import { config, InitializationOptions, mergeConfiguration } from "./config.js";
1313
import { findDefinitionLocation } from "./definitions.js";
1414
import { findHover } from "./hover.js";
@@ -98,7 +98,7 @@ connection.onInitialize((params: InitializeParams): InitializeResult => {
9898
triggerCharacters: ["(", ","],
9999
},
100100
completionProvider: {
101-
triggerCharacters: ["$"],
101+
triggerCharacters: ["$", "."],
102102
},
103103
renameProvider: {
104104
prepareProvider: true,
@@ -202,7 +202,7 @@ connection.onCompletion((params) => {
202202
return [];
203203
}
204204

205-
return findCompletions(document, params.position);
205+
return findCompletionsWithTypeInfo(document, params.position);
206206
});
207207

208208
connection.languages.semanticTokens.on((params) => {

packages/language-server/src/parser/adapters/jsoniq/completion-data.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const PREFERRED_COMPLETION_RULES = new Set([
3232
JsoniqParser.RULE_varRef,
3333
JsoniqParser.RULE_qname,
3434
JsoniqParser.RULE_functionCall,
35+
JsoniqParser.RULE_objectLookup,
3536
]);
3637

3738
export const KEYWORD_COMPLETIONS: LanguageKeywordCompletion[] = [

packages/language-server/src/parser/adapters/jsoniq/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
PREFERRED_COMPLETION_RULES,
1010
} from "./completion-data.js";
1111
import { JsoniqTokenContextAnalyzer } from "./completion-token-context.js";
12+
import { JsoniqLexer } from "./grammar/JsoniqLexer.js";
1213
import { JsoniqParser } from "./grammar/JsoniqParser.js";
1314
import { parseJsoniq } from "./parse.js";
1415

@@ -23,6 +24,8 @@ export const jsoniqParserAdapter: ParserAdapter = {
2324
preferredRules: PREFERRED_COMPLETION_RULES,
2425
languageKeywords: KEYWORD_COMPLETIONS,
2526
isFunctionCallRule: (ruleIndex) => ruleIndex === JsoniqParser.RULE_functionCall,
27+
isObjectLookupRule: (ruleIndex) => ruleIndex === JsoniqParser.RULE_objectLookup,
28+
isObjectLookupDotToken: (tokenType) => tokenType === JsoniqLexer.DOT,
2629
isVariableReferenceRule: (ruleIndex) => ruleIndex === JsoniqParser.RULE_varRef,
2730
isTypeReferenceRule: (ruleIndex) =>
2831
ruleIndex === JsoniqParser.RULE_sequenceType ||

packages/language-server/src/parser/adapters/xquery/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export const xqueryParserAdapter: ParserAdapter = {
2323
preferredRules: PREFERRED_COMPLETION_RULES,
2424
languageKeywords: KEYWORD_COMPLETIONS,
2525
isFunctionCallRule: (ruleIndex) => ruleIndex === XQueryParser.RULE_functionCall,
26+
isObjectLookupRule: () => false,
27+
isObjectLookupDotToken: () => false,
2628
isVariableReferenceRule: (ruleIndex) => ruleIndex === XQueryParser.RULE_varRef,
2729
isTypeReferenceRule: (ruleIndex) =>
2830
ruleIndex === XQueryParser.RULE_sequenceType ||

packages/language-server/src/parser/completion.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type CompletionOptions<T extends TokenContextAnalyzer> = {
2020
preferredRules: Set<number>;
2121
languageKeywords: LanguageKeywordCompletion[];
2222
isFunctionCallRule(ruleIndex: number): boolean;
23+
isObjectLookupRule(ruleIndex: number): boolean;
24+
isObjectLookupDotToken(tokenType: number): boolean;
2325
isVariableReferenceRule(ruleIndex: number): boolean;
2426
isTypeReferenceRule(ruleIndex: number): boolean;
2527
tokenName(tokenType: number): string | number;
@@ -46,6 +48,15 @@ export function getCompletionIntent<T extends TokenContextAnalyzer>(
4648
const allowVariables =
4749
candidates.tokenContext.allowReferences &&
4850
hasCandidateRule(candidates, options.isVariableReferenceRule);
51+
const objectLookupDotOffset = findObjectLookupDotOffset(
52+
parsed.tokens,
53+
cursorOffset,
54+
options.isObjectLookupDotToken,
55+
);
56+
const allowObjectLookup =
57+
candidates.tokenContext.allowReferences &&
58+
hasCandidateRule(candidates, options.isObjectLookupRule) &&
59+
objectLookupDotOffset !== undefined;
4960
const allowTypes =
5061
candidates.tokenContext.allowTypeReferences ||
5162
(candidates.tokenContext.allowReferences &&
@@ -58,8 +69,10 @@ export function getCompletionIntent<T extends TokenContextAnalyzer>(
5869
logger.debug("Completion candidates:", {
5970
allowFunctions,
6071
allowVariables,
72+
allowObjectLookup,
6173
allowTypes,
6274
allowVariableDeclarations,
75+
objectLookupDotOffset,
6376
keywords,
6477
expectedTokens,
6578
expectedRules,
@@ -70,6 +83,8 @@ export function getCompletionIntent<T extends TokenContextAnalyzer>(
7083
allowVariableReferences: allowVariables,
7184
allowVariableDeclarations,
7285
allowFunctions,
86+
allowObjectLookup,
87+
...(objectLookupDotOffset === undefined ? {} : { objectLookupDotOffset }),
7388
allowTypes,
7489
keywords,
7590
};
@@ -86,6 +101,28 @@ function hasCandidateToken(candidates: CompletionCandidates, tokenType: number):
86101
return candidates.tokenTypes.has(tokenType);
87102
}
88103

104+
/// This is used to strip out object lookup dot tokens that are not actually part of an object lookup expression, e.g. in the following example:
105+
/// ```
106+
/// let x = 1;
107+
/// x. // <- cursor here
108+
/// ```
109+
/// We send only the query text up to the dot to the server so it does not give errors
110+
function findObjectLookupDotOffset(
111+
tokens: Token[],
112+
cursorOffset: number,
113+
isObjectLookupDotToken: (tokenType: number) => boolean,
114+
): number | undefined {
115+
return tokens
116+
.filter(
117+
(token) =>
118+
token.type !== Token.EOF &&
119+
isObjectLookupDotToken(token.type) &&
120+
token.start < cursorOffset &&
121+
token.stop < cursorOffset,
122+
)
123+
.at(-1)?.start;
124+
}
125+
89126
function keywordCompletions(
90127
candidates: CompletionCandidates,
91128
languageKeywords: LanguageKeywordCompletion[],

packages/language-server/src/parser/types/completion.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface CompletionIntent {
88
allowVariableDeclarations: boolean;
99
allowFunctions: boolean;
1010
allowTypes: boolean;
11+
allowObjectLookup: boolean;
12+
objectLookupDotOffset?: number;
1113
keywords: KeywordCompletion[];
1214
}
1315

packages/language-server/src/type-at-position/service.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ const EMPTY_RESULT: TypeAtPositionWireResult = {};
1717
export async function getTypeAtPosition(
1818
document: TextDocument,
1919
position: Position,
20+
): Promise<TypeAtPositionWireResult> {
21+
return getTypeAtPositionFromSource(document.uri, getDocumentText(document), position);
22+
}
23+
24+
export async function getTypeAtPositionFromSource(
25+
documentUri: string,
26+
source: string,
27+
position: Position,
2028
): Promise<TypeAtPositionWireResult> {
2129
const client = getWrapperClient();
2230
if (!client.isUsable()) {
@@ -26,15 +34,15 @@ export async function getTypeAtPosition(
2634
try {
2735
const response = await client.sendRequest<TypeAtPositionRequestSpec>({
2836
requestType: REQUEST_TYPE_TYPE_AT_POSITION,
29-
body: Buffer.from(getDocumentText(document), "utf8").toString("base64"),
30-
documentUri: document.uri,
37+
body: Buffer.from(source, "utf8").toString("base64"),
38+
documentUri,
3139
position,
3240
});
3341

3442
return response.body;
3543
} catch (error) {
3644
logger.warn(
37-
`Type-at-position unavailable for ${document.uri}: ${error instanceof Error ? error.message : String(error)}`,
45+
`Type-at-position unavailable for ${documentUri}: ${error instanceof Error ? error.message : String(error)}`,
3846
);
3947
return EMPTY_RESULT;
4048
}

packages/language-server/tests/completion.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { findCompletions } from "server/completion.js";
1+
import { findCompletions, findCompletionsWithTypeInfo } from "server/completion.js";
22
import { describe, expect, it } from "vitest";
33
import { type CompletionItem, type Position } from "vscode-languageserver";
44
import { type TextDocument } from "vscode-languageserver-textdocument";
@@ -278,6 +278,51 @@ describe("JSONiq completion", () => {
278278
expect(labelsAtCursor).toContain("declare function");
279279
expect(labelsAtCursor).toContain("declare variable");
280280
});
281+
282+
it("suggests object fields after the dot operator using real type inference", async () => {
283+
const document = testDocument("completion-dot-object", [
284+
"declare variable $a := {",
285+
' "name": "Ada",',
286+
' "age": 42',
287+
"};",
288+
"",
289+
"$a.",
290+
]);
291+
292+
const items = await findCompletionsWithTypeInfo(document, { line: 5, character: 3 });
293+
294+
expect(labels(items)).toContain("age");
295+
expect(labels(items)).toContain("name");
296+
expect(items.find((item) => item.label === "name")?.textEdit).toEqual({
297+
range: {
298+
start: { line: 5, character: 3 },
299+
end: { line: 5, character: 3 },
300+
},
301+
newText: "name",
302+
});
303+
}, 45_000);
304+
305+
it("filters real object field completions by the prefix typed after dot", async () => {
306+
const document = testDocument("completion-dot-object-prefix", [
307+
"declare variable $a := {",
308+
' "name": "Ada",',
309+
' "age": 42',
310+
"};",
311+
"",
312+
"$a.na",
313+
]);
314+
315+
const items = await findCompletionsWithTypeInfo(document, { line: 5, character: 5 });
316+
317+
expect(labels(items)).toContain("name");
318+
expect(items.find((item) => item.label === "name")?.textEdit).toEqual({
319+
range: {
320+
start: { line: 5, character: 3 },
321+
end: { line: 5, character: 5 },
322+
},
323+
newText: "name",
324+
});
325+
}, 45_000);
281326
});
282327

283328
describe("XQuery completion", () => {

0 commit comments

Comments
 (0)