Skip to content

Commit 30ea575

Browse files
Use jsonc-parser to fix autocompletion
1 parent 3b7cfa3 commit 30ea575

File tree

4 files changed

+202
-2
lines changed

4 files changed

+202
-2
lines changed

server/package-lock.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"dependencies": {
3333
"ajv": "^8.12.0",
3434
"ajv-formats": "^3.0.1",
35+
"jsonc-parser": "^3.3.1",
3536
"semver": "^7.7.2",
3637
"vscode-jsonrpc": "^8.0.1",
3738
"vscode-languageserver": "^9.0.1",

server/src/jsonConfig.ts

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as fs from "fs";
22
import * as path from "path";
33
import Ajv from "ajv/dist/2020.js";
44
import addFormats from "ajv-formats";
5+
import * as jsoncParser from "jsonc-parser";
56
import {
67
Diagnostic,
78
DiagnosticSeverity,
@@ -247,7 +248,10 @@ export function validateConfig(document: TextDocument): Diagnostic[] {
247248
}
248249
}
249250

250-
export function getConfigCompletions(document: TextDocument): CompletionItem[] {
251+
export function getConfigCompletions(
252+
document: TextDocument,
253+
position?: Position,
254+
): CompletionItem[] {
251255
const filePath = document.uri;
252256
let fsPath: string;
253257
try {
@@ -266,6 +270,189 @@ export function getConfigCompletions(document: TextDocument): CompletionItem[] {
266270
return [];
267271
}
268272

273+
// If no position provided, fall back to top-level completions
274+
if (!position) {
275+
return getTopLevelCompletions(schemaInfo);
276+
}
277+
278+
const content = document.getText();
279+
const offset = document.offsetAt(position);
280+
281+
// Parse the document with jsonc-parser (handles incomplete JSON)
282+
const errors: jsoncParser.ParseError[] = [];
283+
const root = jsoncParser.parseTree(content, errors);
284+
if (!root) {
285+
return getTopLevelCompletions(schemaInfo);
286+
}
287+
288+
// Get the location at the cursor
289+
const location = jsoncParser.getLocation(content, offset);
290+
291+
// Find the nearest object node that contains the cursor
292+
const currentObjectNode = findContainingObjectNode(root, offset);
293+
if (!currentObjectNode) {
294+
return getTopLevelCompletions(schemaInfo);
295+
}
296+
297+
// Get the JSON path to this object
298+
const path = getPathToNode(root, currentObjectNode);
299+
if (!path) {
300+
return getTopLevelCompletions(schemaInfo);
301+
}
302+
303+
// Resolve the schema for this path
304+
const schemaAtPath = resolveSchemaForPath(schemaInfo.schema, path);
305+
if (!schemaAtPath || !schemaAtPath.properties) {
306+
return getTopLevelCompletions(schemaInfo);
307+
}
308+
309+
// Get existing keys in the current object
310+
const existingKeys = getExistingKeys(currentObjectNode);
311+
312+
// Build completion items for available properties
313+
const completions = Object.entries(schemaAtPath.properties)
314+
.filter(([key]) => !existingKeys.includes(key))
315+
.map(([key, prop]: [string, any]) => {
316+
const item: CompletionItem = {
317+
label: key,
318+
kind: CompletionItemKind.Property,
319+
detail: prop.description || key,
320+
insertText: `"${key}": `,
321+
};
322+
323+
if (prop.type === "boolean") {
324+
item.insertText = `"${key}": ${prop.default !== undefined ? prop.default : false}`;
325+
} else if (prop.type === "array" && prop.items?.enum) {
326+
item.insertText = `"${key}": [\n ${prop.items.enum.map((v: string) => `"${v}"`).join(",\n ")}\n]`;
327+
} else if (prop.enum) {
328+
item.insertText = `"${key}": "${prop.default || prop.enum[0]}"`;
329+
}
330+
331+
return item;
332+
});
333+
334+
return completions.length > 0 ? completions : getTopLevelCompletions(schemaInfo);
335+
}
336+
337+
// Helper functions for jsonc-parser based completion
338+
339+
function findContainingObjectNode(node: jsoncParser.Node | undefined, offset: number): jsoncParser.Node | undefined {
340+
if (!node) {
341+
return undefined;
342+
}
343+
344+
let bestMatch: jsoncParser.Node | undefined = undefined;
345+
346+
// If this node is an object and contains the offset, it's a potential match
347+
if (node.type === 'object' && node.offset <= offset && node.offset + node.length >= offset) {
348+
bestMatch = node;
349+
}
350+
351+
// If this node has children, search them recursively
352+
if (node.children) {
353+
for (const child of node.children) {
354+
const result = findContainingObjectNode(child, offset);
355+
if (result) {
356+
// Prefer deeper/more specific matches
357+
if (!bestMatch || (result.offset > bestMatch.offset && result.length < bestMatch.length)) {
358+
bestMatch = result;
359+
}
360+
}
361+
}
362+
}
363+
364+
return bestMatch;
365+
}
366+
367+
function getPathToNode(root: jsoncParser.Node, targetNode: jsoncParser.Node): string[] | undefined {
368+
function buildPath(node: jsoncParser.Node, currentPath: string[]): string[] | undefined {
369+
if (node === targetNode) {
370+
return currentPath;
371+
}
372+
373+
if (node.children) {
374+
for (const child of node.children) {
375+
let newPath = [...currentPath];
376+
377+
// If this child is a property node, add its key to the path
378+
if (child.type === 'property' && child.children && child.children.length >= 2) {
379+
const keyNode = child.children[0];
380+
if (keyNode.type === 'string') {
381+
const key = jsoncParser.getNodeValue(keyNode);
382+
if (typeof key === 'string') {
383+
newPath = [...newPath, key];
384+
}
385+
}
386+
}
387+
388+
const result = buildPath(child, newPath);
389+
if (result) {
390+
return result;
391+
}
392+
}
393+
}
394+
395+
return undefined;
396+
}
397+
398+
return buildPath(root, []);
399+
}
400+
401+
function getExistingKeys(objectNode: jsoncParser.Node): string[] {
402+
const keys: string[] = [];
403+
404+
if (objectNode.type === 'object' && objectNode.children) {
405+
for (const child of objectNode.children) {
406+
if (child.type === 'property' && child.children && child.children.length >= 1) {
407+
const keyNode = child.children[0];
408+
if (keyNode.type === 'string') {
409+
const key = jsoncParser.getNodeValue(keyNode);
410+
if (typeof key === 'string') {
411+
keys.push(key);
412+
}
413+
}
414+
}
415+
}
416+
}
417+
418+
return keys;
419+
}
420+
421+
function resolveSchemaForPath(schema: any, path: string[]): any {
422+
let current = schema;
423+
424+
for (const segment of path) {
425+
if (current.properties && current.properties[segment]) {
426+
const prop = current.properties[segment];
427+
428+
// Handle $ref
429+
if (prop.$ref) {
430+
const refPath = prop.$ref.replace("#/", "").split("/");
431+
let resolved = schema;
432+
for (const refSegment of refPath) {
433+
resolved = resolved[refSegment];
434+
if (!resolved) {
435+
return null;
436+
}
437+
}
438+
current = resolved;
439+
} else if (prop.type === "object" && prop.properties) {
440+
current = prop;
441+
} else {
442+
return null;
443+
}
444+
} else {
445+
return null;
446+
}
447+
}
448+
449+
return current;
450+
}
451+
452+
function getTopLevelCompletions(schemaInfo: SchemaInfo): CompletionItem[] {
453+
if (!schemaInfo.schema.properties) {
454+
return [];
455+
}
269456
return Object.entries(schemaInfo.schema.properties).map(
270457
([key, prop]: [string, any]) => {
271458
const item: CompletionItem = {

server/src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,7 @@ async function handleJsonConfigCompletion(
639639
const params = msg.params as p.CompletionParams;
640640
const content = getOpenedFileContent(params.textDocument.uri);
641641
const document = createJsonTextDocument(params.textDocument.uri, content, 1);
642-
const completions = jsonConfig.getConfigCompletions(document);
642+
const completions = jsonConfig.getConfigCompletions(document, params.position);
643643
let response: p.ResponseMessage = {
644644
jsonrpc: c.jsonrpcVersion,
645645
id: msg.id,

0 commit comments

Comments
 (0)