From 6ab5467992164e7d5da1fb78ebe87fadfdd05a41 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 29 Sep 2025 17:49:17 +0200 Subject: [PATCH 1/7] Some upgrades and detect rescript.json files --- client/package-lock.json | 64 ++++++++++---------- client/package.json | 2 +- client/src/extension.ts | 6 +- package-lock.json | 10 ++-- package.json | 19 +++--- server/package-lock.json | 125 ++++++++++++++++++++++++++++++++++++++- server/package.json | 5 +- server/tsconfig.json | 4 +- 8 files changed, 180 insertions(+), 55 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6a1264109..fcf1dc45d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "vscode-languageclient": "8.1.0-next.5" + "vscode-languageclient": "9.0.1" } }, "node_modules/balanced-match": { @@ -62,39 +62,39 @@ } }, "node_modules/vscode-jsonrpc": { - "version": "8.1.0-next.6", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.1.0-next.6.tgz", - "integrity": "sha512-AahQokGczPwXKo1Qhnn3aqkZgwUJ0rjVwhWWKW5I5LEWRoqfnWkQp7haVIV6GJRX0oyGL2ezVy7IhwgP5u/3xw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", "engines": { "node": ">=14.0.0" } }, "node_modules/vscode-languageclient": { - "version": "8.1.0-next.5", - "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-8.1.0-next.5.tgz", - "integrity": "sha512-RbL68ENqp2uPDs1rsPiD8IfhPWzUP8e12ONdtpmSlR6kmj6FLOd8fEaC1pQMGDtfPfiFCpLav8YytH25QOYwRQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz", + "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==", "dependencies": { "minimatch": "^5.1.0", "semver": "^7.3.7", - "vscode-languageserver-protocol": "3.17.3-next.5" + "vscode-languageserver-protocol": "3.17.5" }, "engines": { - "vscode": "^1.67.0" + "vscode": "^1.82.0" } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.17.3-next.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.3-next.5.tgz", - "integrity": "sha512-9HafkatRVhBVpWQrODes4JaoSu7ozHQUOzYiTmfMmxeFOUYgsSqyODp+j/c+SovcsuwABjuqnsQ9RiFkXCbeMA==", + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", "dependencies": { - "vscode-jsonrpc": "8.1.0-next.6", - "vscode-languageserver-types": "3.17.3-next.2" + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" } }, "node_modules/vscode-languageserver-types": { - "version": "3.17.3-next.2", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.3-next.2.tgz", - "integrity": "sha512-3kkNSCycNKUalSJIrjIptGeY9UTJr1Nk5HT/aT00jjIwiCvIUNbRdK90av2Y3j1Jityot68dBVc3YYdwwH3zOQ==" + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" }, "node_modules/yallist": { "version": "4.0.0", @@ -141,33 +141,33 @@ } }, "vscode-jsonrpc": { - "version": "8.1.0-next.6", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.1.0-next.6.tgz", - "integrity": "sha512-AahQokGczPwXKo1Qhnn3aqkZgwUJ0rjVwhWWKW5I5LEWRoqfnWkQp7haVIV6GJRX0oyGL2ezVy7IhwgP5u/3xw==" + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==" }, "vscode-languageclient": { - "version": "8.1.0-next.5", - "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-8.1.0-next.5.tgz", - "integrity": "sha512-RbL68ENqp2uPDs1rsPiD8IfhPWzUP8e12ONdtpmSlR6kmj6FLOd8fEaC1pQMGDtfPfiFCpLav8YytH25QOYwRQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz", + "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==", "requires": { "minimatch": "^5.1.0", "semver": "^7.3.7", - "vscode-languageserver-protocol": "3.17.3-next.5" + "vscode-languageserver-protocol": "3.17.5" } }, "vscode-languageserver-protocol": { - "version": "3.17.3-next.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.3-next.5.tgz", - "integrity": "sha512-9HafkatRVhBVpWQrODes4JaoSu7ozHQUOzYiTmfMmxeFOUYgsSqyODp+j/c+SovcsuwABjuqnsQ9RiFkXCbeMA==", + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", "requires": { - "vscode-jsonrpc": "8.1.0-next.6", - "vscode-languageserver-types": "3.17.3-next.2" + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" } }, "vscode-languageserver-types": { - "version": "3.17.3-next.2", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.3-next.2.tgz", - "integrity": "sha512-3kkNSCycNKUalSJIrjIptGeY9UTJr1Nk5HT/aT00jjIwiCvIUNbRdK90av2Y3j1Jityot68dBVc3YYdwwH3zOQ==" + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" }, "yallist": { "version": "4.0.0", diff --git a/client/package.json b/client/package.json index 6f5dc88b8..701208e5d 100644 --- a/client/package.json +++ b/client/package.json @@ -7,6 +7,6 @@ "author": "ReScript Team", "license": "MIT", "dependencies": { - "vscode-languageclient": "8.1.0-next.5" + "vscode-languageclient": "9.0.1" } } diff --git a/client/src/extension.ts b/client/src/extension.ts index 9f17147a8..d9feb8d3b 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -113,7 +113,11 @@ export function activate(context: ExtensionContext) { // Options to control the language client let clientOptions: LanguageClientOptions = { - documentSelector: [{ scheme: "file", language: "rescript" }], + documentSelector: [ + { scheme: "file", language: "rescript" }, + { scheme: "file", pattern: "**/rescript.json" }, + { scheme: "file", pattern: "**/bsconfig.json" }, + ], // We'll send the initial configuration in here, but this might be // problematic because every consumer of the LS will need to mimic this. // We'll leave it like this for now, but might be worth revisiting later on. diff --git a/package-lock.json b/package-lock.json index 82495d5a2..63f4e48eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,13 +15,13 @@ "devDependencies": { "@types/node": "^20.19.13", "@types/semver": "^7.7.0", - "@types/vscode": "1.68.0", + "@types/vscode": "1.74.0", "esbuild": "^0.20.1", "prettier": "^3.6.2", "typescript": "^5.8.3" }, "engines": { - "vscode": "^1.68.0" + "vscode": "^1.74.0" } }, "node_modules/@esbuild/darwin-arm64": { @@ -58,9 +58,9 @@ "license": "MIT" }, "node_modules/@types/vscode": { - "version": "1.68.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.68.0.tgz", - "integrity": "sha512-duBwEK5ta/eBBMJMQ7ECMEsMvlE3XJdRGh3xoS1uOO4jl2Z4LPBl5vx8WvBP10ERAgDRmIt/FaSD4RHyBGbChw==", + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.74.0.tgz", + "integrity": "sha512-LyeCIU3jb9d38w0MXFwta9r0Jx23ugujkAxdwLTNCyspdZTKUc43t7ppPbCiPoQ/Ivd/pnDFZrb4hWd45wrsgA==", "dev": true }, "node_modules/esbuild": { diff --git a/package.json b/package.json index 3ad3fcaff..de08e2b1e 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,14 @@ "language-server" ], "engines": { - "vscode": "^1.68.0" + "vscode": "^1.74.0" }, "activationEvents": [ - "onLanguage:rescript" + "onLanguage:rescript", + "onFile:**/rescript.json", + "onFile:**/bsconfig.json", + "workspaceContains:**/rescript.json", + "workspaceContains:**/bsconfig.json" ], "main": "./client/out/extension", "contributes": { @@ -45,15 +49,6 @@ } } ], - "jsonValidation": [ - { - "fileMatch": [ - "bsconfig.json", - "rescript.json" - ], - "url": "https://raw.githubusercontent.com/rescript-lang/rescript-compiler/master/docs/docson/build-schema.json" - } - ], "commands": [ { "command": "rescript-vscode.create_interface", @@ -273,7 +268,7 @@ "devDependencies": { "@types/node": "^20.19.13", "@types/semver": "^7.7.0", - "@types/vscode": "1.68.0", + "@types/vscode": "1.74.0", "esbuild": "^0.20.1", "prettier": "^3.6.2", "typescript": "^5.8.3" diff --git a/server/package-lock.json b/server/package-lock.json index 16d6068a4..ac5c346f0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,10 +9,13 @@ "version": "1.66.0", "license": "MIT", "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", "semver": "^7.7.2", "vscode-jsonrpc": "^8.0.1", "vscode-languageserver": "^9.0.1", - "vscode-languageserver-protocol": "^3.17.1" + "vscode-languageserver-protocol": "^3.17.1", + "vscode-languageserver-textdocument": "^1.0.11" }, "bin": { "rescript-language-server": "out/cli.js" @@ -21,6 +24,76 @@ "node": "*" } }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -64,6 +137,12 @@ "vscode-languageserver-types": "3.17.5" } }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, "node_modules/vscode-languageserver-types": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", @@ -72,6 +151,45 @@ } }, "dependencies": { + "ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "requires": { + "ajv": "^8.0.0" + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==" + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, "semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -99,6 +217,11 @@ "vscode-languageserver-types": "3.17.5" } }, + "vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==" + }, "vscode-languageserver-types": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", diff --git a/server/package.json b/server/package.json index 08e988ab8..e6c8983a1 100644 --- a/server/package.json +++ b/server/package.json @@ -30,9 +30,12 @@ "url": "https://github.com/rescript-lang/rescript-vscode/issues" }, "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", "semver": "^7.7.2", "vscode-jsonrpc": "^8.0.1", "vscode-languageserver": "^9.0.1", - "vscode-languageserver-protocol": "^3.17.1" + "vscode-languageserver-protocol": "^3.17.1", + "vscode-languageserver-textdocument": "^1.0.11" } } diff --git a/server/tsconfig.json b/server/tsconfig.json index 5dab877e0..26b414e07 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "es2019", - "lib": ["ES2019"], + "target": "es2022", + "lib": ["ES2022"], "module": "commonjs", "moduleResolution": "node", "sourceMap": true, From 8ffa1fc54552c55c49c65ca78cfda8c866920bb7 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 29 Sep 2025 17:50:33 +0200 Subject: [PATCH 2/7] Implement our own JSON file validation like TS server --- server/src/jsonConfig.ts | 385 +++++++++++++++++++++++++++++++++++++++ server/src/server.ts | 263 +++++++++++++++++++++++--- 2 files changed, 626 insertions(+), 22 deletions(-) create mode 100644 server/src/jsonConfig.ts diff --git a/server/src/jsonConfig.ts b/server/src/jsonConfig.ts new file mode 100644 index 000000000..b3c4ec3c1 --- /dev/null +++ b/server/src/jsonConfig.ts @@ -0,0 +1,385 @@ +import * as fs from "fs"; +import * as path from "path"; +import Ajv from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { + Diagnostic, + DiagnosticSeverity, + Position, + Range, + CompletionItem, + CompletionItemKind, + Hover, + MarkupKind, +} from "vscode-languageserver"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { findProjectRootOfFile } from "./utils"; +import { fileURLToPath } from "url"; + +const ajv = new Ajv({ allErrors: true }); +addFormats(ajv); + +interface SchemaInfo { + schema: any; + projectRoot: string; + schemaPath: string; +} + +// Cache schemas by project root +const schemaCache = new Map(); + +function loadReScriptSchema(projectRoot: string): SchemaInfo | null { + console.log(`[JSON_CONFIG] Loading schema for project: ${projectRoot}`); + + // Check cache first + if (schemaCache.has(projectRoot)) { + console.log(`[JSON_CONFIG] Schema found in cache`); + return schemaCache.get(projectRoot)!; + } + + const schemaPath = path.join( + projectRoot, + "node_modules", + "rescript", + "docs", + "docson", + "build-schema.json", + ); + + console.log(`[JSON_CONFIG] Looking for schema at: ${schemaPath}`); + + if (!fs.existsSync(schemaPath)) { + console.log(`[JSON_CONFIG] Schema file does not exist`); + return null; + } + + try { + const schemaContent = fs.readFileSync(schemaPath, "utf8"); + console.log( + `[JSON_CONFIG] Schema content preview: ${schemaContent.substring(0, 500)}...`, + ); + const schema = JSON.parse(schemaContent); + + const schemaInfo: SchemaInfo = { + schema, + projectRoot, + schemaPath, + }; + + schemaCache.set(projectRoot, schemaInfo); + return schemaInfo; + } catch (error) { + console.error(`Failed to load schema from ${schemaPath}:`, error); + return null; + } +} + +export function isConfigFile(filePath: string): boolean { + const fileName = path.basename(filePath); + return fileName === "rescript.json" || fileName === "bsconfig.json"; +} + +export function validateConfig(document: TextDocument): Diagnostic[] { + const filePath = document.uri; + console.log(`[JSON_CONFIG] Validating config: ${filePath}`); + + // Convert file URI to filesystem path for project root detection + let fsPath: string; + try { + fsPath = fileURLToPath(filePath); + console.log(`[JSON_CONFIG] Converted to filesystem path: ${fsPath}`); + } catch (error) { + console.log(`[JSON_CONFIG] Failed to convert file URI to path: ${error}`); + return []; + } + + const projectRoot = findProjectRootOfFile(fsPath); + console.log(`[JSON_CONFIG] Found project root: ${projectRoot}`); + + if (!projectRoot) { + console.log( + `[JSON_CONFIG] No project root found, returning empty diagnostics`, + ); + return []; + } + + const schemaInfo = loadReScriptSchema(projectRoot); + console.log( + `[JSON_CONFIG] Schema info: ${schemaInfo ? "found" : "not found"}`, + ); + + if (!schemaInfo) { + console.log(`[JSON_CONFIG] No schema found, returning empty diagnostics`); + return []; + } + + try { + const jsonContent = document.getText(); + console.log( + `[JSON_CONFIG] JSON content: ${jsonContent.substring(0, 200)}...`, + ); + const config = JSON.parse(jsonContent); + + let validate; + try { + validate = ajv.compile(schemaInfo.schema); + } catch (schemaError) { + console.error(`[JSON_CONFIG] Failed to compile schema:`, schemaError); + return []; + } + + const valid = validate(config); + console.log(`[JSON_CONFIG] Validation result: ${valid}`); + + if (!valid && validate.errors) { + console.log( + `[JSON_CONFIG] Validation errors:`, + JSON.stringify(validate.errors, null, 2), + ); + } + + if (valid) { + console.log(`[JSON_CONFIG] Valid JSON, returning empty diagnostics`); + return []; + } + + const diagnostics = (validate.errors || []).map((error) => { + // Convert JSON pointer to line/column + const lines = jsonContent.split("\n"); + let line = 0; + let column = 0; + let endColumn = 0; + let propertyName = ""; + + if (error.instancePath) { + // Simple heuristic to find the location + const path = error.instancePath.slice(1); // Remove leading '/' + const pathParts = path.split("/"); + propertyName = pathParts[pathParts.length - 1]; + + // Find the line containing the property + for (let i = 0; i < lines.length; i++) { + const lineContent = lines[i]; + const match = lineContent.match(new RegExp(`"${propertyName}"\\s*:`)); + + if (match && match.index !== undefined) { + line = i; + column = match.index; + endColumn = column + match[0].length; + console.log( + `[JSON_CONFIG] Found property "${propertyName}" at line ${line}, col ${column}, match: "${match[0]}"`, + ); + break; + } + } + } else if ( + error.keyword === "additionalProperties" && + error.params && + error.params.additionalProperty + ) { + // Handle additionalProperties error - extract the invalid property name + propertyName = error.params.additionalProperty; + console.log( + `[JSON_CONFIG] Found additionalProperties error for property: "${propertyName}"`, + ); + + // Find the line containing the invalid property + for (let i = 0; i < lines.length; i++) { + const lineContent = lines[i]; + const match = lineContent.match(new RegExp(`"${propertyName}"\\s*:`)); + + if (match && match.index !== undefined) { + line = i; + column = match.index; + endColumn = column + match[0].length; + console.log( + `[JSON_CONFIG] Found invalid property "${propertyName}" at line ${line}, col ${column}, match: "${match[0]}"`, + ); + break; + } + } + } + + // Create a better error message + let message = `${error.keyword}: ${error.message}`; + if (error.keyword === "additionalProperties" && propertyName) { + message = `Property "${propertyName}" is not allowed in the schema`; + } + + const diagnostic = { + severity: + error.keyword === "additionalProperties" + ? DiagnosticSeverity.Warning + : DiagnosticSeverity.Error, + range: { + start: { line, character: column }, + end: { line, character: endColumn }, + }, + message, + source: "rescript-json-config-schema", + }; + console.log( + `[JSON_CONFIG] Created diagnostic for property "${propertyName}":`, + diagnostic, + ); + console.log( + `[JSON_CONFIG] Diagnostic details - Line: ${line}, Column: ${column}, EndColumn: ${endColumn}, Message: ${message}`, + ); + return diagnostic; + }); + + console.log( + `[JSON_CONFIG] Total diagnostics created: ${diagnostics.length}`, + ); + if (diagnostics.length > 0) { + console.log(`[JSON_CONFIG] First diagnostic:`, diagnostics[0]); + } + + return diagnostics; + } catch (error) { + // Handle JSON parsing errors + if (error instanceof SyntaxError) { + const match = error.message.match(/position (\d+)/); + if (match) { + const position = parseInt(match[1]); + const content = document.getText(); + const lines = content.substring(0, position).split("\n"); + const line = lines.length - 1; + const character = lines[lines.length - 1].length; + + return [ + { + severity: DiagnosticSeverity.Error, + range: { + start: { line, character }, + end: { line, character: character + 1 }, + }, + message: `JSON syntax error: ${error.message.replace(/.*: /, "")}`, + source: "rescript-json-config-schema", + }, + ]; + } else { + return [ + { + severity: DiagnosticSeverity.Error, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }, + message: `JSON syntax error: ${error.message}`, + source: "rescript-json-config-schema", + }, + ]; + } + } + + return [ + { + severity: DiagnosticSeverity.Error, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }, + message: `Failed to parse JSON: ${error}`, + source: "rescript-json-config-schema", + }, + ]; + } +} + +export function getConfigCompletions(document: TextDocument): CompletionItem[] { + const filePath = document.uri; + let fsPath: string; + try { + fsPath = fileURLToPath(filePath); + } catch (error) { + console.log( + `[JSON_CONFIG] Failed to convert file URI to path for completions: ${error}`, + ); + return []; + } + const projectRoot = findProjectRootOfFile(fsPath); + + if (!projectRoot) { + return []; + } + + const schemaInfo = loadReScriptSchema(projectRoot); + if (!schemaInfo?.schema?.properties) { + return []; + } + + return Object.entries(schemaInfo.schema.properties).map( + ([key, prop]: [string, any]) => { + const item: CompletionItem = { + label: key, + kind: CompletionItemKind.Property, + detail: prop.description || key, + insertText: `"${key}": `, + }; + + if (prop.type === "boolean") { + item.insertText = `"${key}": ${prop.default !== undefined ? prop.default : false}`; + } else if (prop.type === "array" && prop.items?.enum) { + item.insertText = `"${key}": [\n ${prop.items.enum.map((v: string) => `"${v}"`).join(",\n ")}\n]`; + } else if (prop.enum) { + item.insertText = `"${key}": "${prop.default || prop.enum[0]}"`; + } + + return item; + }, + ); +} + +export function getConfigHover( + document: TextDocument, + position: Position, +): Hover | null { + const filePath = document.uri; + let fsPath: string; + try { + fsPath = fileURLToPath(filePath); + } catch (error) { + console.log( + `[JSON_CONFIG] Failed to convert file URI to path for hover: ${error}`, + ); + return null; + } + const projectRoot = findProjectRootOfFile(fsPath); + + if (!projectRoot) { + return null; + } + + const schemaInfo = loadReScriptSchema(projectRoot); + if (!schemaInfo?.schema?.properties) { + return null; + } + + const line = document.getText( + Range.create(position.line, 0, position.line, 1000), + ); + const match = line.match(/"([^"]+)"/); + + if (!match) { + return null; + } + + const propertyName = match[1]; + const property = schemaInfo.schema.properties[propertyName]; + + if (property?.description) { + return { + contents: { + kind: MarkupKind.Markdown, + value: property.description, + }, + }; + } + + return null; +} + +export function clearSchemaCache(): void { + schemaCache.clear(); +} diff --git a/server/src/server.ts b/server/src/server.ts index 16515b415..6c759730c 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -28,6 +28,7 @@ import { onErrorReported } from "./errorReporter"; import * as ic from "./incrementalCompilation"; import config, { extensionConfiguration } from "./config"; import { projectsFiles } from "./projectFiles"; +import * as jsonConfig from "./jsonConfig"; // Absolute paths to all the workspace folders // Configured during the initialize request @@ -1264,34 +1265,164 @@ async function onMessage(msg: p.Message) { await onWorkspaceDidChangeWatchedFiles(params); } else if (msg.method === DidOpenTextDocumentNotification.method) { let params = msg.params as p.DidOpenTextDocumentParams; - await openedFile(params.textDocument.uri, params.textDocument.text); - await sendUpdatedDiagnostics(); - await updateDiagnosticSyntax( - params.textDocument.uri, - params.textDocument.text, - ); + let filePath = fileURLToPath(params.textDocument.uri); + + // Handle JSON config files + if (jsonConfig.isConfigFile(filePath)) { + // Cache JSON config content + stupidFileContentCache.set(filePath, params.textDocument.text); + // Validate JSON config on open + const document = { + uri: params.textDocument.uri, + languageId: "json", + version: params.textDocument.version, + getText: () => params.textDocument.text, + positionAt: (offset: number) => { + const text = params.textDocument.text; + const lines = text.split('\n'); + let currentOffset = 0; + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + 1; // +1 for newline + if (offset < currentOffset + lineLength) { + return { line: i, character: offset - currentOffset }; + } + currentOffset += lineLength; + } + return { line: lines.length - 1, character: lines[lines.length - 1].length }; + }, + offsetAt: (position: any) => { + const text = params.textDocument.text; + const lines = text.split('\n'); + let offset = 0; + for (let i = 0; i < position.line; i++) { + offset += lines[i].length + 1; // +1 for newline + } + return offset + position.character; + }, + lineCount: params.textDocument.text.split('\n').length, + }; + + // Debug: log that we're handling a JSON config file + console.log(`[DEBUG] Handling JSON config file: ${filePath}`); + + const diagnostics = jsonConfig.validateConfig(document); + console.log(`[DEBUG] Validation produced ${diagnostics.length} diagnostics`); + + // Always send diagnostics (even empty array) to clear previous errors + let notification: p.NotificationMessage = { + jsonrpc: c.jsonrpcVersion, + method: "textDocument/publishDiagnostics", + params: { + uri: params.textDocument.uri, + diagnostics, + }, + }; + send(notification); + console.log(`[DEBUG] Sent diagnostics for ${params.textDocument.uri}`); + } else { + // Handle ReScript files + await openedFile(params.textDocument.uri, params.textDocument.text); + await sendUpdatedDiagnostics(); + await updateDiagnosticSyntax( + params.textDocument.uri, + params.textDocument.text, + ); + } } else if (msg.method === DidChangeTextDocumentNotification.method) { let params = msg.params as p.DidChangeTextDocumentParams; - let extName = path.extname(params.textDocument.uri); - if (extName === c.resExt || extName === c.resiExt) { + let filePath = fileURLToPath(params.textDocument.uri); + + // Handle JSON config files + if (jsonConfig.isConfigFile(filePath)) { let changes = params.contentChanges; - if (changes.length === 0) { - // no change? - } else { + if (changes.length > 0) { // we currently only support full changes - updateOpenedFile( - params.textDocument.uri, - changes[changes.length - 1].text, - ); - await updateDiagnosticSyntax( - params.textDocument.uri, - changes[changes.length - 1].text, - ); + const newContent = changes[changes.length - 1].text; + stupidFileContentCache.set(filePath, newContent); + const document = { + uri: params.textDocument.uri, + languageId: "json", + version: params.textDocument.version, + getText: () => newContent, + positionAt: (offset: number) => { + const text = newContent; + const lines = text.split('\n'); + let currentOffset = 0; + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + 1; // +1 for newline + if (offset < currentOffset + lineLength) { + return { line: i, character: offset - currentOffset }; + } + currentOffset += lineLength; + } + return { line: lines.length - 1, character: lines[lines.length - 1].length }; + }, + offsetAt: (position: any) => { + const text = newContent; + const lines = text.split('\n'); + let offset = 0; + for (let i = 0; i < position.line; i++) { + offset += lines[i].length + 1; // +1 for newline + } + return offset + position.character; + }, + lineCount: newContent.split('\n').length, + }; + const diagnostics = jsonConfig.validateConfig(document); + console.log(`[DEBUG] Change validation produced ${diagnostics.length} diagnostics for ${filePath}`); + + // Always send diagnostics (even empty array) to clear previous errors + let notification: p.NotificationMessage = { + jsonrpc: c.jsonrpcVersion, + method: "textDocument/publishDiagnostics", + params: { + uri: params.textDocument.uri, + diagnostics, + }, + }; + send(notification); + console.log(`[DEBUG] Sent change diagnostics for ${params.textDocument.uri}`); + } + } else { + // Handle ReScript files + let extName = path.extname(params.textDocument.uri); + if (extName === c.resExt || extName === c.resiExt) { + let changes = params.contentChanges; + if (changes.length === 0) { + // no change? + } else { + // we currently only support full changes + updateOpenedFile( + params.textDocument.uri, + changes[changes.length - 1].text, + ); + await updateDiagnosticSyntax( + params.textDocument.uri, + changes[changes.length - 1].text, + ); + } } } } else if (msg.method === DidCloseTextDocumentNotification.method) { let params = msg.params as p.DidCloseTextDocumentParams; - await closedFile(params.textDocument.uri); + let filePath = fileURLToPath(params.textDocument.uri); + + // Handle JSON config files + if (jsonConfig.isConfigFile(filePath)) { + stupidFileContentCache.delete(filePath); + // Clear diagnostics for JSON config files + let notification: p.NotificationMessage = { + jsonrpc: c.jsonrpcVersion, + method: "textDocument/publishDiagnostics", + params: { + uri: params.textDocument.uri, + diagnostics: [], + }, + }; + send(notification); + } else { + await closedFile(params.textDocument.uri); + } } else if (msg.method === DidChangeConfigurationNotification.type.method) { // Can't seem to get this notification to trigger, but if it does this will be here and ensure we're synced up at the server. askForAllCurrentConfiguration(); @@ -1439,7 +1570,51 @@ async function onMessage(msg: p.Message) { send(response); } } else if (msg.method === p.HoverRequest.method) { - send(await hover(msg)); + let params = msg.params as p.HoverParams; + let filePath = fileURLToPath(params.textDocument.uri); + + // Handle JSON config files + if (jsonConfig.isConfigFile(filePath)) { + const content = getOpenedFileContent(params.textDocument.uri); + const document = { + uri: params.textDocument.uri, + languageId: "json", + version: 1, + getText: () => content, + positionAt: (offset: number) => { + const text = content; + const lines = text.split('\n'); + let currentOffset = 0; + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + 1; // +1 for newline + if (offset < currentOffset + lineLength) { + return { line: i, character: offset - currentOffset }; + } + currentOffset += lineLength; + } + return { line: lines.length - 1, character: lines[lines.length - 1].length }; + }, + offsetAt: (position: any) => { + const text = content; + const lines = text.split('\n'); + let offset = 0; + for (let i = 0; i < position.line; i++) { + offset += lines[i].length + 1; // +1 for newline + } + return offset + position.character; + }, + lineCount: content.split('\n').length, + }; + const hoverResult = jsonConfig.getConfigHover(document, params.position); + let response: p.ResponseMessage = { + jsonrpc: c.jsonrpcVersion, + id: msg.id, + result: hoverResult, + }; + send(response); + } else { + send(await hover(msg)); + } } else if (msg.method === p.DefinitionRequest.method) { send(await definition(msg)); } else if (msg.method === p.TypeDefinitionRequest.method) { @@ -1453,7 +1628,51 @@ async function onMessage(msg: p.Message) { } else if (msg.method === p.DocumentSymbolRequest.method) { send(await documentSymbol(msg)); } else if (msg.method === p.CompletionRequest.method) { - send(await completion(msg)); + let params = msg.params as p.CompletionParams; + let filePath = fileURLToPath(params.textDocument.uri); + + // Handle JSON config files + if (jsonConfig.isConfigFile(filePath)) { + const content = getOpenedFileContent(params.textDocument.uri); + const document = { + uri: params.textDocument.uri, + languageId: "json", + version: 1, + getText: () => content, + positionAt: (offset: number) => { + const text = content; + const lines = text.split('\n'); + let currentOffset = 0; + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + 1; // +1 for newline + if (offset < currentOffset + lineLength) { + return { line: i, character: offset - currentOffset }; + } + currentOffset += lineLength; + } + return { line: lines.length - 1, character: lines[lines.length - 1].length }; + }, + offsetAt: (position: any) => { + const text = content; + const lines = text.split('\n'); + let offset = 0; + for (let i = 0; i < position.line; i++) { + offset += lines[i].length + 1; // +1 for newline + } + return offset + position.character; + }, + lineCount: content.split('\n').length, + }; + const completions = jsonConfig.getConfigCompletions(document); + let response: p.ResponseMessage = { + jsonrpc: c.jsonrpcVersion, + id: msg.id, + result: completions, + }; + send(response); + } else { + send(await completion(msg)); + } } else if (msg.method === p.CompletionResolveRequest.method) { send(await completionResolve(msg)); } else if (msg.method === p.SemanticTokensRequest.method) { From 775eabb4aef5ab444fcb2561d0465baa88b42b22 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 29 Sep 2025 18:23:17 +0200 Subject: [PATCH 3/7] Refactor JSON handling branches into their own functions --- server/src/jsonConfig.ts | 57 +------ server/src/server.ts | 320 ++++++++++++++++----------------------- 2 files changed, 133 insertions(+), 244 deletions(-) diff --git a/server/src/jsonConfig.ts b/server/src/jsonConfig.ts index b3c4ec3c1..40719562f 100644 --- a/server/src/jsonConfig.ts +++ b/server/src/jsonConfig.ts @@ -29,11 +29,8 @@ interface SchemaInfo { const schemaCache = new Map(); function loadReScriptSchema(projectRoot: string): SchemaInfo | null { - console.log(`[JSON_CONFIG] Loading schema for project: ${projectRoot}`); - // Check cache first if (schemaCache.has(projectRoot)) { - console.log(`[JSON_CONFIG] Schema found in cache`); return schemaCache.get(projectRoot)!; } @@ -46,18 +43,12 @@ function loadReScriptSchema(projectRoot: string): SchemaInfo | null { "build-schema.json", ); - console.log(`[JSON_CONFIG] Looking for schema at: ${schemaPath}`); - if (!fs.existsSync(schemaPath)) { - console.log(`[JSON_CONFIG] Schema file does not exist`); return null; } try { const schemaContent = fs.readFileSync(schemaPath, "utf8"); - console.log( - `[JSON_CONFIG] Schema content preview: ${schemaContent.substring(0, 500)}...`, - ); const schema = JSON.parse(schemaContent); const schemaInfo: SchemaInfo = { @@ -81,43 +72,30 @@ export function isConfigFile(filePath: string): boolean { export function validateConfig(document: TextDocument): Diagnostic[] { const filePath = document.uri; - console.log(`[JSON_CONFIG] Validating config: ${filePath}`); // Convert file URI to filesystem path for project root detection let fsPath: string; try { fsPath = fileURLToPath(filePath); - console.log(`[JSON_CONFIG] Converted to filesystem path: ${fsPath}`); } catch (error) { - console.log(`[JSON_CONFIG] Failed to convert file URI to path: ${error}`); + console.error(`[JSON_CONFIG] Failed to convert file URI to path: ${error}`); return []; } const projectRoot = findProjectRootOfFile(fsPath); - console.log(`[JSON_CONFIG] Found project root: ${projectRoot}`); if (!projectRoot) { - console.log( - `[JSON_CONFIG] No project root found, returning empty diagnostics`, - ); return []; } const schemaInfo = loadReScriptSchema(projectRoot); - console.log( - `[JSON_CONFIG] Schema info: ${schemaInfo ? "found" : "not found"}`, - ); if (!schemaInfo) { - console.log(`[JSON_CONFIG] No schema found, returning empty diagnostics`); return []; } try { const jsonContent = document.getText(); - console.log( - `[JSON_CONFIG] JSON content: ${jsonContent.substring(0, 200)}...`, - ); const config = JSON.parse(jsonContent); let validate; @@ -129,17 +107,15 @@ export function validateConfig(document: TextDocument): Diagnostic[] { } const valid = validate(config); - console.log(`[JSON_CONFIG] Validation result: ${valid}`); if (!valid && validate.errors) { - console.log( + console.error( `[JSON_CONFIG] Validation errors:`, JSON.stringify(validate.errors, null, 2), ); } if (valid) { - console.log(`[JSON_CONFIG] Valid JSON, returning empty diagnostics`); return []; } @@ -166,9 +142,6 @@ export function validateConfig(document: TextDocument): Diagnostic[] { line = i; column = match.index; endColumn = column + match[0].length; - console.log( - `[JSON_CONFIG] Found property "${propertyName}" at line ${line}, col ${column}, match: "${match[0]}"`, - ); break; } } @@ -179,9 +152,6 @@ export function validateConfig(document: TextDocument): Diagnostic[] { ) { // Handle additionalProperties error - extract the invalid property name propertyName = error.params.additionalProperty; - console.log( - `[JSON_CONFIG] Found additionalProperties error for property: "${propertyName}"`, - ); // Find the line containing the invalid property for (let i = 0; i < lines.length; i++) { @@ -192,9 +162,6 @@ export function validateConfig(document: TextDocument): Diagnostic[] { line = i; column = match.index; endColumn = column + match[0].length; - console.log( - `[JSON_CONFIG] Found invalid property "${propertyName}" at line ${line}, col ${column}, match: "${match[0]}"`, - ); break; } } @@ -218,23 +185,9 @@ export function validateConfig(document: TextDocument): Diagnostic[] { message, source: "rescript-json-config-schema", }; - console.log( - `[JSON_CONFIG] Created diagnostic for property "${propertyName}":`, - diagnostic, - ); - console.log( - `[JSON_CONFIG] Diagnostic details - Line: ${line}, Column: ${column}, EndColumn: ${endColumn}, Message: ${message}`, - ); return diagnostic; }); - console.log( - `[JSON_CONFIG] Total diagnostics created: ${diagnostics.length}`, - ); - if (diagnostics.length > 0) { - console.log(`[JSON_CONFIG] First diagnostic:`, diagnostics[0]); - } - return diagnostics; } catch (error) { // Handle JSON parsing errors @@ -293,9 +246,6 @@ export function getConfigCompletions(document: TextDocument): CompletionItem[] { try { fsPath = fileURLToPath(filePath); } catch (error) { - console.log( - `[JSON_CONFIG] Failed to convert file URI to path for completions: ${error}`, - ); return []; } const projectRoot = findProjectRootOfFile(fsPath); @@ -340,9 +290,6 @@ export function getConfigHover( try { fsPath = fileURLToPath(filePath); } catch (error) { - console.log( - `[JSON_CONFIG] Failed to convert file URI to path for hover: ${error}`, - ); return null; } const projectRoot = findProjectRootOfFile(fsPath); diff --git a/server/src/server.ts b/server/src/server.ts index 6c759730c..83d722c2e 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -527,6 +527,127 @@ let getOpenedFileContent = (fileUri: string) => { return content; }; +// Helper functions for JSON config handling +function createJsonTextDocument( + uri: string, + content: string, + version: number = 1, +) { + return { + uri, + languageId: "json", + version, + getText: () => content, + positionAt: (offset: number) => { + const text = content; + const lines = text.split("\n"); + let currentOffset = 0; + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + 1; // +1 for newline + if (offset < currentOffset + lineLength) { + return { line: i, character: offset - currentOffset }; + } + currentOffset += lineLength; + } + return { + line: lines.length - 1, + character: lines[lines.length - 1].length, + }; + }, + offsetAt: (position: any) => { + const text = content; + const lines = text.split("\n"); + let offset = 0; + for (let i = 0; i < position.line; i++) { + offset += lines[i].length + 1; // +1 for newline + } + return offset + position.character; + }, + lineCount: content.split("\n").length, + }; +} + +function sendJsonDiagnostics(uri: string, diagnostics: any[]): void { + let notification: p.NotificationMessage = { + jsonrpc: c.jsonrpcVersion, + method: "textDocument/publishDiagnostics", + params: { + uri, + diagnostics, + }, + }; + send(notification); +} + +async function handleJsonConfigOpen( + params: p.DidOpenTextDocumentParams, +): Promise { + const filePath = fileURLToPath(params.textDocument.uri); + stupidFileContentCache.set(filePath, params.textDocument.text); + + const document = createJsonTextDocument( + params.textDocument.uri, + params.textDocument.text, + params.textDocument.version, + ); + const diagnostics = jsonConfig.validateConfig(document); + sendJsonDiagnostics(params.textDocument.uri, diagnostics); +} + +async function handleJsonConfigChange( + params: p.DidChangeTextDocumentParams, +): Promise { + const filePath = fileURLToPath(params.textDocument.uri); + let changes = params.contentChanges; + if (changes.length > 0) { + const newContent = changes[changes.length - 1].text; + stupidFileContentCache.set(filePath, newContent); + const document = createJsonTextDocument( + params.textDocument.uri, + newContent, + params.textDocument.version, + ); + const diagnostics = jsonConfig.validateConfig(document); + sendJsonDiagnostics(params.textDocument.uri, diagnostics); + } +} + +async function handleJsonConfigClose( + params: p.DidCloseTextDocumentParams, +): Promise { + const filePath = fileURLToPath(params.textDocument.uri); + stupidFileContentCache.delete(filePath); + sendJsonDiagnostics(params.textDocument.uri, []); +} + +async function handleJsonConfigHover(msg: p.RequestMessage): Promise { + const params = msg.params as p.HoverParams; + const content = getOpenedFileContent(params.textDocument.uri); + const document = createJsonTextDocument(params.textDocument.uri, content, 1); + const hoverResult = jsonConfig.getConfigHover(document, params.position); + let response: p.ResponseMessage = { + jsonrpc: c.jsonrpcVersion, + id: msg.id, + result: hoverResult, + }; + send(response); +} + +async function handleJsonConfigCompletion( + msg: p.RequestMessage, +): Promise { + const params = msg.params as p.CompletionParams; + const content = getOpenedFileContent(params.textDocument.uri); + const document = createJsonTextDocument(params.textDocument.uri, content, 1); + const completions = jsonConfig.getConfigCompletions(document); + let response: p.ResponseMessage = { + jsonrpc: c.jsonrpcVersion, + id: msg.id, + result: completions, + }; + send(response); +} + export default function listen(useStdio = false) { // Start listening now! // We support two modes: the regular node RPC mode for VSCode, and the --stdio @@ -1266,59 +1387,10 @@ async function onMessage(msg: p.Message) { } else if (msg.method === DidOpenTextDocumentNotification.method) { let params = msg.params as p.DidOpenTextDocumentParams; let filePath = fileURLToPath(params.textDocument.uri); - + // Handle JSON config files if (jsonConfig.isConfigFile(filePath)) { - // Cache JSON config content - stupidFileContentCache.set(filePath, params.textDocument.text); - // Validate JSON config on open - const document = { - uri: params.textDocument.uri, - languageId: "json", - version: params.textDocument.version, - getText: () => params.textDocument.text, - positionAt: (offset: number) => { - const text = params.textDocument.text; - const lines = text.split('\n'); - let currentOffset = 0; - for (let i = 0; i < lines.length; i++) { - const lineLength = lines[i].length + 1; // +1 for newline - if (offset < currentOffset + lineLength) { - return { line: i, character: offset - currentOffset }; - } - currentOffset += lineLength; - } - return { line: lines.length - 1, character: lines[lines.length - 1].length }; - }, - offsetAt: (position: any) => { - const text = params.textDocument.text; - const lines = text.split('\n'); - let offset = 0; - for (let i = 0; i < position.line; i++) { - offset += lines[i].length + 1; // +1 for newline - } - return offset + position.character; - }, - lineCount: params.textDocument.text.split('\n').length, - }; - - // Debug: log that we're handling a JSON config file - console.log(`[DEBUG] Handling JSON config file: ${filePath}`); - - const diagnostics = jsonConfig.validateConfig(document); - console.log(`[DEBUG] Validation produced ${diagnostics.length} diagnostics`); - - // Always send diagnostics (even empty array) to clear previous errors - let notification: p.NotificationMessage = { - jsonrpc: c.jsonrpcVersion, - method: "textDocument/publishDiagnostics", - params: { - uri: params.textDocument.uri, - diagnostics, - }, - }; - send(notification); - console.log(`[DEBUG] Sent diagnostics for ${params.textDocument.uri}`); + await handleJsonConfigOpen(params); } else { // Handle ReScript files await openedFile(params.textDocument.uri, params.textDocument.text); @@ -1331,58 +1403,10 @@ async function onMessage(msg: p.Message) { } else if (msg.method === DidChangeTextDocumentNotification.method) { let params = msg.params as p.DidChangeTextDocumentParams; let filePath = fileURLToPath(params.textDocument.uri); - + // Handle JSON config files if (jsonConfig.isConfigFile(filePath)) { - let changes = params.contentChanges; - if (changes.length > 0) { - // we currently only support full changes - const newContent = changes[changes.length - 1].text; - stupidFileContentCache.set(filePath, newContent); - const document = { - uri: params.textDocument.uri, - languageId: "json", - version: params.textDocument.version, - getText: () => newContent, - positionAt: (offset: number) => { - const text = newContent; - const lines = text.split('\n'); - let currentOffset = 0; - for (let i = 0; i < lines.length; i++) { - const lineLength = lines[i].length + 1; // +1 for newline - if (offset < currentOffset + lineLength) { - return { line: i, character: offset - currentOffset }; - } - currentOffset += lineLength; - } - return { line: lines.length - 1, character: lines[lines.length - 1].length }; - }, - offsetAt: (position: any) => { - const text = newContent; - const lines = text.split('\n'); - let offset = 0; - for (let i = 0; i < position.line; i++) { - offset += lines[i].length + 1; // +1 for newline - } - return offset + position.character; - }, - lineCount: newContent.split('\n').length, - }; - const diagnostics = jsonConfig.validateConfig(document); - console.log(`[DEBUG] Change validation produced ${diagnostics.length} diagnostics for ${filePath}`); - - // Always send diagnostics (even empty array) to clear previous errors - let notification: p.NotificationMessage = { - jsonrpc: c.jsonrpcVersion, - method: "textDocument/publishDiagnostics", - params: { - uri: params.textDocument.uri, - diagnostics, - }, - }; - send(notification); - console.log(`[DEBUG] Sent change diagnostics for ${params.textDocument.uri}`); - } + await handleJsonConfigChange(params); } else { // Handle ReScript files let extName = path.extname(params.textDocument.uri); @@ -1406,20 +1430,10 @@ async function onMessage(msg: p.Message) { } else if (msg.method === DidCloseTextDocumentNotification.method) { let params = msg.params as p.DidCloseTextDocumentParams; let filePath = fileURLToPath(params.textDocument.uri); - + // Handle JSON config files if (jsonConfig.isConfigFile(filePath)) { - stupidFileContentCache.delete(filePath); - // Clear diagnostics for JSON config files - let notification: p.NotificationMessage = { - jsonrpc: c.jsonrpcVersion, - method: "textDocument/publishDiagnostics", - params: { - uri: params.textDocument.uri, - diagnostics: [], - }, - }; - send(notification); + await handleJsonConfigClose(params); } else { await closedFile(params.textDocument.uri); } @@ -1572,46 +1586,10 @@ async function onMessage(msg: p.Message) { } else if (msg.method === p.HoverRequest.method) { let params = msg.params as p.HoverParams; let filePath = fileURLToPath(params.textDocument.uri); - + // Handle JSON config files if (jsonConfig.isConfigFile(filePath)) { - const content = getOpenedFileContent(params.textDocument.uri); - const document = { - uri: params.textDocument.uri, - languageId: "json", - version: 1, - getText: () => content, - positionAt: (offset: number) => { - const text = content; - const lines = text.split('\n'); - let currentOffset = 0; - for (let i = 0; i < lines.length; i++) { - const lineLength = lines[i].length + 1; // +1 for newline - if (offset < currentOffset + lineLength) { - return { line: i, character: offset - currentOffset }; - } - currentOffset += lineLength; - } - return { line: lines.length - 1, character: lines[lines.length - 1].length }; - }, - offsetAt: (position: any) => { - const text = content; - const lines = text.split('\n'); - let offset = 0; - for (let i = 0; i < position.line; i++) { - offset += lines[i].length + 1; // +1 for newline - } - return offset + position.character; - }, - lineCount: content.split('\n').length, - }; - const hoverResult = jsonConfig.getConfigHover(document, params.position); - let response: p.ResponseMessage = { - jsonrpc: c.jsonrpcVersion, - id: msg.id, - result: hoverResult, - }; - send(response); + await handleJsonConfigHover(msg); } else { send(await hover(msg)); } @@ -1630,46 +1608,10 @@ async function onMessage(msg: p.Message) { } else if (msg.method === p.CompletionRequest.method) { let params = msg.params as p.CompletionParams; let filePath = fileURLToPath(params.textDocument.uri); - + // Handle JSON config files if (jsonConfig.isConfigFile(filePath)) { - const content = getOpenedFileContent(params.textDocument.uri); - const document = { - uri: params.textDocument.uri, - languageId: "json", - version: 1, - getText: () => content, - positionAt: (offset: number) => { - const text = content; - const lines = text.split('\n'); - let currentOffset = 0; - for (let i = 0; i < lines.length; i++) { - const lineLength = lines[i].length + 1; // +1 for newline - if (offset < currentOffset + lineLength) { - return { line: i, character: offset - currentOffset }; - } - currentOffset += lineLength; - } - return { line: lines.length - 1, character: lines[lines.length - 1].length }; - }, - offsetAt: (position: any) => { - const text = content; - const lines = text.split('\n'); - let offset = 0; - for (let i = 0; i < position.line; i++) { - offset += lines[i].length + 1; // +1 for newline - } - return offset + position.character; - }, - lineCount: content.split('\n').length, - }; - const completions = jsonConfig.getConfigCompletions(document); - let response: p.ResponseMessage = { - jsonrpc: c.jsonrpcVersion, - id: msg.id, - result: completions, - }; - send(response); + await handleJsonConfigCompletion(msg); } else { send(await completion(msg)); } From 59fcc544e56fe8a52f79e6e8e1edecd2f48e7b2a Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 29 Sep 2025 23:12:04 +0200 Subject: [PATCH 4/7] Get rescript.json hover info to work --- server/src/jsonConfig.ts | 92 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 6 deletions(-) diff --git a/server/src/jsonConfig.ts b/server/src/jsonConfig.ts index 40719562f..a91eb5301 100644 --- a/server/src/jsonConfig.ts +++ b/server/src/jsonConfig.ts @@ -100,7 +100,14 @@ export function validateConfig(document: TextDocument): Diagnostic[] { let validate; try { - validate = ajv.compile(schemaInfo.schema); + // Create a copy of the schema and modify $ref to use Draft 07 instead of Draft 04 + const modifiedSchema = JSON.parse(JSON.stringify(schemaInfo.schema)); + if ( + modifiedSchema.$schema === "http://json-schema.org/draft-04/schema#" + ) { + modifiedSchema.$schema = "http://json-schema.org/draft-07/schema#"; + } + validate = ajv.compile(modifiedSchema); } catch (schemaError) { console.error(`[JSON_CONFIG] Failed to compile schema:`, schemaError); return []; @@ -281,6 +288,33 @@ export function getConfigCompletions(document: TextDocument): CompletionItem[] { ); } +// Helper function to detect if JSON is minified (single line without meaningful whitespace) +function isMinifiedJson(jsonContent: string): boolean { + const lineCount = jsonContent.split("\n").length; + const trimmed = jsonContent.trim(); + + // Consider it minified if it's just one line, or if it's very compact + return lineCount === 1 || (lineCount <= 3 && trimmed.length < 200); +} + +// Helper function to normalize JSON content for position calculations +// Only formats if the JSON appears to be truly minified (single line) +function normalizeJsonContent(jsonContent: string): string { + try { + // Only format if it's clearly minified (single line with no meaningful line breaks) + if (isMinifiedJson(jsonContent)) { + const parsed = JSON.parse(jsonContent); + return JSON.stringify(parsed, null, 2); + } + + // Otherwise, use original content to preserve manual formatting + return jsonContent; + } catch { + // If parsing fails, return original content + return jsonContent; + } +} + export function getConfigHover( document: TextDocument, position: Position, @@ -303,16 +337,62 @@ export function getConfigHover( return null; } - const line = document.getText( - Range.create(position.line, 0, position.line, 1000), + // Normalize the JSON content for position calculations + const originalContent = document.getText(); + const normalizedContent = normalizeJsonContent(originalContent); + + // Split into lines for position calculation + const formattedLines = normalizedContent.split("\n"); + + // Make sure the position is valid + if (position.line >= formattedLines.length) { + return null; + } + + const line = formattedLines[position.line]; + + // Find all quoted strings on the line with their positions + const quotes = []; + const regex = /"([^"]+)"/g; + let match; + while ((match = regex.exec(line)) !== null) { + quotes.push({ + text: match[0], + value: match[1], + start: match.index, + end: match.index + match[0].length, + }); + } + + // Find which quote contains the cursor position + const cursorQuote = quotes.find( + (q) => position.character >= q.start && position.character <= q.end, ); - const match = line.match(/"([^"]+)"/); - if (!match) { + if (!cursorQuote) { return null; } - const propertyName = match[1]; + // Check if this is a property key (followed by colon) or property value + const isPropertyKey = line.substring(cursorQuote.end).trim().startsWith(":"); + + let propertyName; + if (isPropertyKey) { + // This is a property key, use it directly + propertyName = cursorQuote.value; + } else { + // This is a property value, try to find the corresponding key + // Look backwards to find the property key + const beforeCursor = line.substring(0, cursorQuote.start); + const keyMatch = beforeCursor.match(/"([^"]+)"\s*:\s*[^:]*$/); + if (keyMatch) { + propertyName = keyMatch[1]; + } else { + // If we can't find the key, this might be an array value or nested property + return null; + } + } + const property = schemaInfo.schema.properties[propertyName]; if (property?.description) { From 3b7cfa3d8dc58ed5c38f1e1f816f4f1b718b97e1 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 29 Sep 2025 23:24:17 +0200 Subject: [PATCH 5/7] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22364ab4d..38941e192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ #### :rocket: New Feature - Add status bar item tracking compilation state. https://github.com/rescript-lang/rescript-vscode/pull/1119 +- Add our own `rescript.json` validation that understands which compiler version is actually in use. https://github.com/rescript-lang/rescript-vscode/pull/1131 #### :house: Internal From 30ea5755a5c041772f95c46511b25168407eac36 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Thu, 2 Oct 2025 12:05:29 +0200 Subject: [PATCH 6/7] Use jsonc-parser to fix autocompletion --- server/package-lock.json | 12 +++ server/package.json | 1 + server/src/jsonConfig.ts | 189 ++++++++++++++++++++++++++++++++++++++- server/src/server.ts | 2 +- 4 files changed, 202 insertions(+), 2 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index ac5c346f0..7bc2d5aa6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", + "jsonc-parser": "^3.3.1", "semver": "^7.7.2", "vscode-jsonrpc": "^8.0.1", "vscode-languageserver": "^9.0.1", @@ -85,6 +86,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -185,6 +192,11 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" + }, "require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/server/package.json b/server/package.json index e6c8983a1..6a0ae9cf1 100644 --- a/server/package.json +++ b/server/package.json @@ -32,6 +32,7 @@ "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", + "jsonc-parser": "^3.3.1", "semver": "^7.7.2", "vscode-jsonrpc": "^8.0.1", "vscode-languageserver": "^9.0.1", diff --git a/server/src/jsonConfig.ts b/server/src/jsonConfig.ts index a91eb5301..0ebbbfb1f 100644 --- a/server/src/jsonConfig.ts +++ b/server/src/jsonConfig.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import * as path from "path"; import Ajv from "ajv/dist/2020.js"; import addFormats from "ajv-formats"; +import * as jsoncParser from "jsonc-parser"; import { Diagnostic, DiagnosticSeverity, @@ -247,7 +248,10 @@ export function validateConfig(document: TextDocument): Diagnostic[] { } } -export function getConfigCompletions(document: TextDocument): CompletionItem[] { +export function getConfigCompletions( + document: TextDocument, + position?: Position, +): CompletionItem[] { const filePath = document.uri; let fsPath: string; try { @@ -266,6 +270,189 @@ export function getConfigCompletions(document: TextDocument): CompletionItem[] { return []; } + // If no position provided, fall back to top-level completions + if (!position) { + return getTopLevelCompletions(schemaInfo); + } + + const content = document.getText(); + const offset = document.offsetAt(position); + + // Parse the document with jsonc-parser (handles incomplete JSON) + const errors: jsoncParser.ParseError[] = []; + const root = jsoncParser.parseTree(content, errors); + if (!root) { + return getTopLevelCompletions(schemaInfo); + } + + // Get the location at the cursor + const location = jsoncParser.getLocation(content, offset); + + // Find the nearest object node that contains the cursor + const currentObjectNode = findContainingObjectNode(root, offset); + if (!currentObjectNode) { + return getTopLevelCompletions(schemaInfo); + } + + // Get the JSON path to this object + const path = getPathToNode(root, currentObjectNode); + if (!path) { + return getTopLevelCompletions(schemaInfo); + } + + // Resolve the schema for this path + const schemaAtPath = resolveSchemaForPath(schemaInfo.schema, path); + if (!schemaAtPath || !schemaAtPath.properties) { + return getTopLevelCompletions(schemaInfo); + } + + // Get existing keys in the current object + const existingKeys = getExistingKeys(currentObjectNode); + + // Build completion items for available properties + const completions = Object.entries(schemaAtPath.properties) + .filter(([key]) => !existingKeys.includes(key)) + .map(([key, prop]: [string, any]) => { + const item: CompletionItem = { + label: key, + kind: CompletionItemKind.Property, + detail: prop.description || key, + insertText: `"${key}": `, + }; + + if (prop.type === "boolean") { + item.insertText = `"${key}": ${prop.default !== undefined ? prop.default : false}`; + } else if (prop.type === "array" && prop.items?.enum) { + item.insertText = `"${key}": [\n ${prop.items.enum.map((v: string) => `"${v}"`).join(",\n ")}\n]`; + } else if (prop.enum) { + item.insertText = `"${key}": "${prop.default || prop.enum[0]}"`; + } + + return item; + }); + + return completions.length > 0 ? completions : getTopLevelCompletions(schemaInfo); +} + +// Helper functions for jsonc-parser based completion + +function findContainingObjectNode(node: jsoncParser.Node | undefined, offset: number): jsoncParser.Node | undefined { + if (!node) { + return undefined; + } + + let bestMatch: jsoncParser.Node | undefined = undefined; + + // If this node is an object and contains the offset, it's a potential match + if (node.type === 'object' && node.offset <= offset && node.offset + node.length >= offset) { + bestMatch = node; + } + + // If this node has children, search them recursively + if (node.children) { + for (const child of node.children) { + const result = findContainingObjectNode(child, offset); + if (result) { + // Prefer deeper/more specific matches + if (!bestMatch || (result.offset > bestMatch.offset && result.length < bestMatch.length)) { + bestMatch = result; + } + } + } + } + + return bestMatch; +} + +function getPathToNode(root: jsoncParser.Node, targetNode: jsoncParser.Node): string[] | undefined { + function buildPath(node: jsoncParser.Node, currentPath: string[]): string[] | undefined { + if (node === targetNode) { + return currentPath; + } + + if (node.children) { + for (const child of node.children) { + let newPath = [...currentPath]; + + // If this child is a property node, add its key to the path + if (child.type === 'property' && child.children && child.children.length >= 2) { + const keyNode = child.children[0]; + if (keyNode.type === 'string') { + const key = jsoncParser.getNodeValue(keyNode); + if (typeof key === 'string') { + newPath = [...newPath, key]; + } + } + } + + const result = buildPath(child, newPath); + if (result) { + return result; + } + } + } + + return undefined; + } + + return buildPath(root, []); +} + +function getExistingKeys(objectNode: jsoncParser.Node): string[] { + const keys: string[] = []; + + if (objectNode.type === 'object' && objectNode.children) { + for (const child of objectNode.children) { + if (child.type === 'property' && child.children && child.children.length >= 1) { + const keyNode = child.children[0]; + if (keyNode.type === 'string') { + const key = jsoncParser.getNodeValue(keyNode); + if (typeof key === 'string') { + keys.push(key); + } + } + } + } + } + + return keys; +} + +function resolveSchemaForPath(schema: any, path: string[]): any { + let current = schema; + + for (const segment of path) { + if (current.properties && current.properties[segment]) { + const prop = current.properties[segment]; + + // Handle $ref + if (prop.$ref) { + const refPath = prop.$ref.replace("#/", "").split("/"); + let resolved = schema; + for (const refSegment of refPath) { + resolved = resolved[refSegment]; + if (!resolved) { + return null; + } + } + current = resolved; + } else if (prop.type === "object" && prop.properties) { + current = prop; + } else { + return null; + } + } else { + return null; + } + } + + return current; +} + +function getTopLevelCompletions(schemaInfo: SchemaInfo): CompletionItem[] { + if (!schemaInfo.schema.properties) { + return []; + } return Object.entries(schemaInfo.schema.properties).map( ([key, prop]: [string, any]) => { const item: CompletionItem = { diff --git a/server/src/server.ts b/server/src/server.ts index 83d722c2e..e7c700d1a 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -639,7 +639,7 @@ async function handleJsonConfigCompletion( const params = msg.params as p.CompletionParams; const content = getOpenedFileContent(params.textDocument.uri); const document = createJsonTextDocument(params.textDocument.uri, content, 1); - const completions = jsonConfig.getConfigCompletions(document); + const completions = jsonConfig.getConfigCompletions(document, params.position); let response: p.ResponseMessage = { jsonrpc: c.jsonrpcVersion, id: msg.id, From ca0d0736a123406c650fe77c2f5e43a9c30dbba2 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Thu, 2 Oct 2025 12:55:30 +0200 Subject: [PATCH 7/7] Format --- server/src/jsonConfig.ts | 66 ++++++++++++++++++++++++++++------------ server/src/server.ts | 5 ++- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/server/src/jsonConfig.ts b/server/src/jsonConfig.ts index 0ebbbfb1f..25f47ff24 100644 --- a/server/src/jsonConfig.ts +++ b/server/src/jsonConfig.ts @@ -277,7 +277,7 @@ export function getConfigCompletions( const content = document.getText(); const offset = document.offsetAt(position); - + // Parse the document with jsonc-parser (handles incomplete JSON) const errors: jsoncParser.ParseError[] = []; const root = jsoncParser.parseTree(content, errors); @@ -287,7 +287,7 @@ export function getConfigCompletions( // Get the location at the cursor const location = jsoncParser.getLocation(content, offset); - + // Find the nearest object node that contains the cursor const currentObjectNode = findContainingObjectNode(root, offset); if (!currentObjectNode) { @@ -331,12 +331,17 @@ export function getConfigCompletions( return item; }); - return completions.length > 0 ? completions : getTopLevelCompletions(schemaInfo); + return completions.length > 0 + ? completions + : getTopLevelCompletions(schemaInfo); } // Helper functions for jsonc-parser based completion -function findContainingObjectNode(node: jsoncParser.Node | undefined, offset: number): jsoncParser.Node | undefined { +function findContainingObjectNode( + node: jsoncParser.Node | undefined, + offset: number, +): jsoncParser.Node | undefined { if (!node) { return undefined; } @@ -344,7 +349,11 @@ function findContainingObjectNode(node: jsoncParser.Node | undefined, offset: nu let bestMatch: jsoncParser.Node | undefined = undefined; // If this node is an object and contains the offset, it's a potential match - if (node.type === 'object' && node.offset <= offset && node.offset + node.length >= offset) { + if ( + node.type === "object" && + node.offset <= offset && + node.offset + node.length >= offset + ) { bestMatch = node; } @@ -354,7 +363,10 @@ function findContainingObjectNode(node: jsoncParser.Node | undefined, offset: nu const result = findContainingObjectNode(child, offset); if (result) { // Prefer deeper/more specific matches - if (!bestMatch || (result.offset > bestMatch.offset && result.length < bestMatch.length)) { + if ( + !bestMatch || + (result.offset > bestMatch.offset && result.length < bestMatch.length) + ) { bestMatch = result; } } @@ -364,8 +376,14 @@ function findContainingObjectNode(node: jsoncParser.Node | undefined, offset: nu return bestMatch; } -function getPathToNode(root: jsoncParser.Node, targetNode: jsoncParser.Node): string[] | undefined { - function buildPath(node: jsoncParser.Node, currentPath: string[]): string[] | undefined { +function getPathToNode( + root: jsoncParser.Node, + targetNode: jsoncParser.Node, +): string[] | undefined { + function buildPath( + node: jsoncParser.Node, + currentPath: string[], + ): string[] | undefined { if (node === targetNode) { return currentPath; } @@ -373,13 +391,17 @@ function getPathToNode(root: jsoncParser.Node, targetNode: jsoncParser.Node): st if (node.children) { for (const child of node.children) { let newPath = [...currentPath]; - + // If this child is a property node, add its key to the path - if (child.type === 'property' && child.children && child.children.length >= 2) { + if ( + child.type === "property" && + child.children && + child.children.length >= 2 + ) { const keyNode = child.children[0]; - if (keyNode.type === 'string') { + if (keyNode.type === "string") { const key = jsoncParser.getNodeValue(keyNode); - if (typeof key === 'string') { + if (typeof key === "string") { newPath = [...newPath, key]; } } @@ -400,14 +422,18 @@ function getPathToNode(root: jsoncParser.Node, targetNode: jsoncParser.Node): st function getExistingKeys(objectNode: jsoncParser.Node): string[] { const keys: string[] = []; - - if (objectNode.type === 'object' && objectNode.children) { + + if (objectNode.type === "object" && objectNode.children) { for (const child of objectNode.children) { - if (child.type === 'property' && child.children && child.children.length >= 1) { + if ( + child.type === "property" && + child.children && + child.children.length >= 1 + ) { const keyNode = child.children[0]; - if (keyNode.type === 'string') { + if (keyNode.type === "string") { const key = jsoncParser.getNodeValue(keyNode); - if (typeof key === 'string') { + if (typeof key === "string") { keys.push(key); } } @@ -420,11 +446,11 @@ function getExistingKeys(objectNode: jsoncParser.Node): string[] { function resolveSchemaForPath(schema: any, path: string[]): any { let current = schema; - + for (const segment of path) { if (current.properties && current.properties[segment]) { const prop = current.properties[segment]; - + // Handle $ref if (prop.$ref) { const refPath = prop.$ref.replace("#/", "").split("/"); @@ -445,7 +471,7 @@ function resolveSchemaForPath(schema: any, path: string[]): any { return null; } } - + return current; } diff --git a/server/src/server.ts b/server/src/server.ts index e7c700d1a..970c2ca66 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -639,7 +639,10 @@ async function handleJsonConfigCompletion( const params = msg.params as p.CompletionParams; const content = getOpenedFileContent(params.textDocument.uri); const document = createJsonTextDocument(params.textDocument.uri, content, 1); - const completions = jsonConfig.getConfigCompletions(document, params.position); + const completions = jsonConfig.getConfigCompletions( + document, + params.position, + ); let response: p.ResponseMessage = { jsonrpc: c.jsonrpcVersion, id: msg.id,