Skip to content

Commit 364086e

Browse files
Implement our own JSON file validation like TS server
1 parent c13433c commit 364086e

File tree

2 files changed

+626
-22
lines changed

2 files changed

+626
-22
lines changed

server/src/jsonConfig.ts

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
import * as fs from "fs";
2+
import * as path from "path";
3+
import Ajv from "ajv/dist/2020.js";
4+
import addFormats from "ajv-formats";
5+
import {
6+
Diagnostic,
7+
DiagnosticSeverity,
8+
Position,
9+
Range,
10+
CompletionItem,
11+
CompletionItemKind,
12+
Hover,
13+
MarkupKind,
14+
} from "vscode-languageserver";
15+
import { TextDocument } from "vscode-languageserver-textdocument";
16+
import { findProjectRootOfFile } from "./utils";
17+
import { fileURLToPath } from "url";
18+
19+
const ajv = new Ajv({ allErrors: true });
20+
addFormats(ajv);
21+
22+
interface SchemaInfo {
23+
schema: any;
24+
projectRoot: string;
25+
schemaPath: string;
26+
}
27+
28+
// Cache schemas by project root
29+
const schemaCache = new Map<string, SchemaInfo>();
30+
31+
function loadReScriptSchema(projectRoot: string): SchemaInfo | null {
32+
console.log(`[JSON_CONFIG] Loading schema for project: ${projectRoot}`);
33+
34+
// Check cache first
35+
if (schemaCache.has(projectRoot)) {
36+
console.log(`[JSON_CONFIG] Schema found in cache`);
37+
return schemaCache.get(projectRoot)!;
38+
}
39+
40+
const schemaPath = path.join(
41+
projectRoot,
42+
"node_modules",
43+
"rescript",
44+
"docs",
45+
"docson",
46+
"build-schema.json",
47+
);
48+
49+
console.log(`[JSON_CONFIG] Looking for schema at: ${schemaPath}`);
50+
51+
if (!fs.existsSync(schemaPath)) {
52+
console.log(`[JSON_CONFIG] Schema file does not exist`);
53+
return null;
54+
}
55+
56+
try {
57+
const schemaContent = fs.readFileSync(schemaPath, "utf8");
58+
console.log(
59+
`[JSON_CONFIG] Schema content preview: ${schemaContent.substring(0, 500)}...`,
60+
);
61+
const schema = JSON.parse(schemaContent);
62+
63+
const schemaInfo: SchemaInfo = {
64+
schema,
65+
projectRoot,
66+
schemaPath,
67+
};
68+
69+
schemaCache.set(projectRoot, schemaInfo);
70+
return schemaInfo;
71+
} catch (error) {
72+
console.error(`Failed to load schema from ${schemaPath}:`, error);
73+
return null;
74+
}
75+
}
76+
77+
export function isConfigFile(filePath: string): boolean {
78+
const fileName = path.basename(filePath);
79+
return fileName === "rescript.json" || fileName === "bsconfig.json";
80+
}
81+
82+
export function validateConfig(document: TextDocument): Diagnostic[] {
83+
const filePath = document.uri;
84+
console.log(`[JSON_CONFIG] Validating config: ${filePath}`);
85+
86+
// Convert file URI to filesystem path for project root detection
87+
let fsPath: string;
88+
try {
89+
fsPath = fileURLToPath(filePath);
90+
console.log(`[JSON_CONFIG] Converted to filesystem path: ${fsPath}`);
91+
} catch (error) {
92+
console.log(`[JSON_CONFIG] Failed to convert file URI to path: ${error}`);
93+
return [];
94+
}
95+
96+
const projectRoot = findProjectRootOfFile(fsPath);
97+
console.log(`[JSON_CONFIG] Found project root: ${projectRoot}`);
98+
99+
if (!projectRoot) {
100+
console.log(
101+
`[JSON_CONFIG] No project root found, returning empty diagnostics`,
102+
);
103+
return [];
104+
}
105+
106+
const schemaInfo = loadReScriptSchema(projectRoot);
107+
console.log(
108+
`[JSON_CONFIG] Schema info: ${schemaInfo ? "found" : "not found"}`,
109+
);
110+
111+
if (!schemaInfo) {
112+
console.log(`[JSON_CONFIG] No schema found, returning empty diagnostics`);
113+
return [];
114+
}
115+
116+
try {
117+
const jsonContent = document.getText();
118+
console.log(
119+
`[JSON_CONFIG] JSON content: ${jsonContent.substring(0, 200)}...`,
120+
);
121+
const config = JSON.parse(jsonContent);
122+
123+
let validate;
124+
try {
125+
validate = ajv.compile(schemaInfo.schema);
126+
} catch (schemaError) {
127+
console.error(`[JSON_CONFIG] Failed to compile schema:`, schemaError);
128+
return [];
129+
}
130+
131+
const valid = validate(config);
132+
console.log(`[JSON_CONFIG] Validation result: ${valid}`);
133+
134+
if (!valid && validate.errors) {
135+
console.log(
136+
`[JSON_CONFIG] Validation errors:`,
137+
JSON.stringify(validate.errors, null, 2),
138+
);
139+
}
140+
141+
if (valid) {
142+
console.log(`[JSON_CONFIG] Valid JSON, returning empty diagnostics`);
143+
return [];
144+
}
145+
146+
const diagnostics = (validate.errors || []).map((error) => {
147+
// Convert JSON pointer to line/column
148+
const lines = jsonContent.split("\n");
149+
let line = 0;
150+
let column = 0;
151+
let endColumn = 0;
152+
let propertyName = "";
153+
154+
if (error.instancePath) {
155+
// Simple heuristic to find the location
156+
const path = error.instancePath.slice(1); // Remove leading '/'
157+
const pathParts = path.split("/");
158+
propertyName = pathParts[pathParts.length - 1];
159+
160+
// Find the line containing the property
161+
for (let i = 0; i < lines.length; i++) {
162+
const lineContent = lines[i];
163+
const match = lineContent.match(new RegExp(`"${propertyName}"\\s*:`));
164+
165+
if (match && match.index !== undefined) {
166+
line = i;
167+
column = match.index;
168+
endColumn = column + match[0].length;
169+
console.log(
170+
`[JSON_CONFIG] Found property "${propertyName}" at line ${line}, col ${column}, match: "${match[0]}"`,
171+
);
172+
break;
173+
}
174+
}
175+
} else if (
176+
error.keyword === "additionalProperties" &&
177+
error.params &&
178+
error.params.additionalProperty
179+
) {
180+
// Handle additionalProperties error - extract the invalid property name
181+
propertyName = error.params.additionalProperty;
182+
console.log(
183+
`[JSON_CONFIG] Found additionalProperties error for property: "${propertyName}"`,
184+
);
185+
186+
// Find the line containing the invalid property
187+
for (let i = 0; i < lines.length; i++) {
188+
const lineContent = lines[i];
189+
const match = lineContent.match(new RegExp(`"${propertyName}"\\s*:`));
190+
191+
if (match && match.index !== undefined) {
192+
line = i;
193+
column = match.index;
194+
endColumn = column + match[0].length;
195+
console.log(
196+
`[JSON_CONFIG] Found invalid property "${propertyName}" at line ${line}, col ${column}, match: "${match[0]}"`,
197+
);
198+
break;
199+
}
200+
}
201+
}
202+
203+
// Create a better error message
204+
let message = `${error.keyword}: ${error.message}`;
205+
if (error.keyword === "additionalProperties" && propertyName) {
206+
message = `Property "${propertyName}" is not allowed in the schema`;
207+
}
208+
209+
const diagnostic = {
210+
severity:
211+
error.keyword === "additionalProperties"
212+
? DiagnosticSeverity.Warning
213+
: DiagnosticSeverity.Error,
214+
range: {
215+
start: { line, character: column },
216+
end: { line, character: endColumn },
217+
},
218+
message,
219+
source: "rescript-json-config-schema",
220+
};
221+
console.log(
222+
`[JSON_CONFIG] Created diagnostic for property "${propertyName}":`,
223+
diagnostic,
224+
);
225+
console.log(
226+
`[JSON_CONFIG] Diagnostic details - Line: ${line}, Column: ${column}, EndColumn: ${endColumn}, Message: ${message}`,
227+
);
228+
return diagnostic;
229+
});
230+
231+
console.log(
232+
`[JSON_CONFIG] Total diagnostics created: ${diagnostics.length}`,
233+
);
234+
if (diagnostics.length > 0) {
235+
console.log(`[JSON_CONFIG] First diagnostic:`, diagnostics[0]);
236+
}
237+
238+
return diagnostics;
239+
} catch (error) {
240+
// Handle JSON parsing errors
241+
if (error instanceof SyntaxError) {
242+
const match = error.message.match(/position (\d+)/);
243+
if (match) {
244+
const position = parseInt(match[1]);
245+
const content = document.getText();
246+
const lines = content.substring(0, position).split("\n");
247+
const line = lines.length - 1;
248+
const character = lines[lines.length - 1].length;
249+
250+
return [
251+
{
252+
severity: DiagnosticSeverity.Error,
253+
range: {
254+
start: { line, character },
255+
end: { line, character: character + 1 },
256+
},
257+
message: `JSON syntax error: ${error.message.replace(/.*: /, "")}`,
258+
source: "rescript-json-config-schema",
259+
},
260+
];
261+
} else {
262+
return [
263+
{
264+
severity: DiagnosticSeverity.Error,
265+
range: {
266+
start: { line: 0, character: 0 },
267+
end: { line: 0, character: 1 },
268+
},
269+
message: `JSON syntax error: ${error.message}`,
270+
source: "rescript-json-config-schema",
271+
},
272+
];
273+
}
274+
}
275+
276+
return [
277+
{
278+
severity: DiagnosticSeverity.Error,
279+
range: {
280+
start: { line: 0, character: 0 },
281+
end: { line: 0, character: 1 },
282+
},
283+
message: `Failed to parse JSON: ${error}`,
284+
source: "rescript-json-config-schema",
285+
},
286+
];
287+
}
288+
}
289+
290+
export function getConfigCompletions(document: TextDocument): CompletionItem[] {
291+
const filePath = document.uri;
292+
let fsPath: string;
293+
try {
294+
fsPath = fileURLToPath(filePath);
295+
} catch (error) {
296+
console.log(
297+
`[JSON_CONFIG] Failed to convert file URI to path for completions: ${error}`,
298+
);
299+
return [];
300+
}
301+
const projectRoot = findProjectRootOfFile(fsPath);
302+
303+
if (!projectRoot) {
304+
return [];
305+
}
306+
307+
const schemaInfo = loadReScriptSchema(projectRoot);
308+
if (!schemaInfo?.schema?.properties) {
309+
return [];
310+
}
311+
312+
return Object.entries(schemaInfo.schema.properties).map(
313+
([key, prop]: [string, any]) => {
314+
const item: CompletionItem = {
315+
label: key,
316+
kind: CompletionItemKind.Property,
317+
detail: prop.description || key,
318+
insertText: `"${key}": `,
319+
};
320+
321+
if (prop.type === "boolean") {
322+
item.insertText = `"${key}": ${prop.default !== undefined ? prop.default : false}`;
323+
} else if (prop.type === "array" && prop.items?.enum) {
324+
item.insertText = `"${key}": [\n ${prop.items.enum.map((v: string) => `"${v}"`).join(",\n ")}\n]`;
325+
} else if (prop.enum) {
326+
item.insertText = `"${key}": "${prop.default || prop.enum[0]}"`;
327+
}
328+
329+
return item;
330+
},
331+
);
332+
}
333+
334+
export function getConfigHover(
335+
document: TextDocument,
336+
position: Position,
337+
): Hover | null {
338+
const filePath = document.uri;
339+
let fsPath: string;
340+
try {
341+
fsPath = fileURLToPath(filePath);
342+
} catch (error) {
343+
console.log(
344+
`[JSON_CONFIG] Failed to convert file URI to path for hover: ${error}`,
345+
);
346+
return null;
347+
}
348+
const projectRoot = findProjectRootOfFile(fsPath);
349+
350+
if (!projectRoot) {
351+
return null;
352+
}
353+
354+
const schemaInfo = loadReScriptSchema(projectRoot);
355+
if (!schemaInfo?.schema?.properties) {
356+
return null;
357+
}
358+
359+
const line = document.getText(
360+
Range.create(position.line, 0, position.line, 1000),
361+
);
362+
const match = line.match(/"([^"]+)"/);
363+
364+
if (!match) {
365+
return null;
366+
}
367+
368+
const propertyName = match[1];
369+
const property = schemaInfo.schema.properties[propertyName];
370+
371+
if (property?.description) {
372+
return {
373+
contents: {
374+
kind: MarkupKind.Markdown,
375+
value: property.description,
376+
},
377+
};
378+
}
379+
380+
return null;
381+
}
382+
383+
export function clearSchemaCache(): void {
384+
schemaCache.clear();
385+
}

0 commit comments

Comments
 (0)