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 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..7bc2d5aa6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,10 +9,14 @@ "version": "1.66.0", "license": "MIT", "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", - "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 +25,82 @@ "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/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", + "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 +144,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 +158,50 @@ } }, "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==" + }, + "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", + "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 +229,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..6a0ae9cf1 100644 --- a/server/package.json +++ b/server/package.json @@ -30,9 +30,13 @@ "url": "https://github.com/rescript-lang/rescript-vscode/issues" }, "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", - "vscode-languageserver-protocol": "^3.17.1" + "vscode-languageserver-protocol": "^3.17.1", + "vscode-languageserver-textdocument": "^1.0.11" } } diff --git a/server/src/jsonConfig.ts b/server/src/jsonConfig.ts new file mode 100644 index 000000000..25f47ff24 --- /dev/null +++ b/server/src/jsonConfig.ts @@ -0,0 +1,625 @@ +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, + 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 { + // Check cache first + if (schemaCache.has(projectRoot)) { + return schemaCache.get(projectRoot)!; + } + + const schemaPath = path.join( + projectRoot, + "node_modules", + "rescript", + "docs", + "docson", + "build-schema.json", + ); + + if (!fs.existsSync(schemaPath)) { + return null; + } + + try { + const schemaContent = fs.readFileSync(schemaPath, "utf8"); + 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; + + // Convert file URI to filesystem path for project root detection + let fsPath: string; + try { + fsPath = fileURLToPath(filePath); + } catch (error) { + console.error(`[JSON_CONFIG] Failed to convert file URI to path: ${error}`); + return []; + } + + const projectRoot = findProjectRootOfFile(fsPath); + + if (!projectRoot) { + return []; + } + + const schemaInfo = loadReScriptSchema(projectRoot); + + if (!schemaInfo) { + return []; + } + + try { + const jsonContent = document.getText(); + const config = JSON.parse(jsonContent); + + let validate; + try { + // 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 []; + } + + const valid = validate(config); + + if (!valid && validate.errors) { + console.error( + `[JSON_CONFIG] Validation errors:`, + JSON.stringify(validate.errors, null, 2), + ); + } + + if (valid) { + 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; + break; + } + } + } else if ( + error.keyword === "additionalProperties" && + error.params && + error.params.additionalProperty + ) { + // Handle additionalProperties error - extract the invalid property name + propertyName = error.params.additionalProperty; + + // 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; + 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", + }; + return diagnostic; + }); + + 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, + position?: Position, +): CompletionItem[] { + const filePath = document.uri; + let fsPath: string; + try { + fsPath = fileURLToPath(filePath); + } catch (error) { + return []; + } + const projectRoot = findProjectRootOfFile(fsPath); + + if (!projectRoot) { + return []; + } + + const schemaInfo = loadReScriptSchema(projectRoot); + if (!schemaInfo?.schema?.properties) { + 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 = { + 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; + }, + ); +} + +// 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, +): Hover | null { + const filePath = document.uri; + let fsPath: string; + try { + fsPath = fileURLToPath(filePath); + } catch (error) { + return null; + } + const projectRoot = findProjectRootOfFile(fsPath); + + if (!projectRoot) { + return null; + } + + const schemaInfo = loadReScriptSchema(projectRoot); + if (!schemaInfo?.schema?.properties) { + return null; + } + + // 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, + ); + + if (!cursorQuote) { + return null; + } + + // 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) { + 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..970c2ca66 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 @@ -526,6 +527,130 @@ 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, + params.position, + ); + 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 @@ -1264,34 +1389,57 @@ 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)) { + await handleJsonConfigOpen(params); + } 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 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, - ); + let filePath = fileURLToPath(params.textDocument.uri); + + // Handle JSON config files + if (jsonConfig.isConfigFile(filePath)) { + await handleJsonConfigChange(params); + } 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)) { + await handleJsonConfigClose(params); + } 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 +1587,15 @@ 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)) { + await handleJsonConfigHover(msg); + } 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 +1609,15 @@ 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)) { + await handleJsonConfigCompletion(msg); + } else { + send(await completion(msg)); + } } else if (msg.method === p.CompletionResolveRequest.method) { send(await completionResolve(msg)); } else if (msg.method === p.SemanticTokensRequest.method) { 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,