diff --git a/language-server/package-lock.json b/language-server/package-lock.json index 0fa35c7..249b440 100644 --- a/language-server/package-lock.json +++ b/language-server/package-lock.json @@ -36,9 +36,9 @@ } }, "node_modules/@hyperjump/browser": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.4.0.tgz", - "integrity": "sha512-AbtynPyALR2wkN3ngYIiMEc21cZhP170zb5y3YIje4kTZ0J515GDgxeCK8D+PjyMhER7idU2Q3d0Zk0z/W1QLQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.5.0.tgz", + "integrity": "sha512-h+fGXfmMOAxYjd2aZ4Y5jpP2WRi0TAkBmKNtoNJKcJLOahNrurwHEUgd1/jO2Hs2j/b/G1EhX0edX8ntXkH2MA==", "license": "MIT", "dependencies": { "@hyperjump/json-pointer": "^1.1.0", @@ -89,7 +89,7 @@ }, "node_modules/@hyperjump/json-schema-errors": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/hyperjump-io/json-schema-errors.git#de67d923a896f1b5d0a2a3f1213b7880a39287fa", + "resolved": "git+ssh://git@github.com/hyperjump-io/json-schema-errors.git#a37f02978b09f021f2079a5dfcb476ccdaa0b730", "license": "MIT", "dependencies": { "@fluent/bundle": "^0.19.1", diff --git a/language-server/src/build-server.ts b/language-server/src/build-server.ts index 5238f67..b68dc1c 100644 --- a/language-server/src/build-server.ts +++ b/language-server/src/build-server.ts @@ -6,8 +6,6 @@ import { Diagnostics } from "./features/Diagnostics.ts"; import { SyntaxValidation } from "./features/SyntaxValidation.ts"; import { SchemaValidation } from "./features/SchemaValidation.ts"; import { Formatting } from "./features/Formatting.ts"; -import { addMediaTypePlugin, removeUriSchemePlugin } from "@hyperjump/browser"; -import { buildSchemaDocument } from "@hyperjump/json-schema/experimental"; import { Hover } from "./features/Hover.ts"; import { Completion } from "./features/Completion.ts"; import { FoldingRanges } from "./features/FoldingRanges.ts"; @@ -23,16 +21,6 @@ import type { Connection } from "vscode-languageserver"; export type LanguageServerSettings = { }; -addMediaTypePlugin("application/json", { - parse: async (response) => { - return buildSchemaDocument(await response.json(), response.url); - }, - fileMatcher: async (path) => path.endsWith(".json") -}); - -removeUriSchemePlugin("http"); -removeUriSchemePlugin("https"); - export const buildServer = (connection: Connection): Server => { const server = new Server(connection); diff --git a/language-server/src/features/Completion.ts b/language-server/src/features/Completion.ts index 255cd7f..7965d30 100644 --- a/language-server/src/features/Completion.ts +++ b/language-server/src/features/Completion.ts @@ -1,8 +1,8 @@ -import { CompletionItemKind, ServerCapabilities } from "vscode-languageserver"; +import { CompletionItemKind } from "vscode-languageserver"; import { JsonDocuments } from "../services/JsonDocuments.ts"; import type { Server } from "../services/Server.ts"; -import type { CompletionItem } from "vscode-languageserver"; +import type { CompletionItem, ServerCapabilities } from "vscode-languageserver"; export class Completion { constructor(server: Server, jsonDocuments: JsonDocuments) { diff --git a/language-server/src/features/Hover.test.ts b/language-server/src/features/Hover.test.ts index 733021e..102fd18 100644 --- a/language-server/src/features/Hover.test.ts +++ b/language-server/src/features/Hover.test.ts @@ -1,7 +1,6 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { HoverRequest, PublishDiagnosticsNotification } from "vscode-languageserver"; import { TestClient } from "../test/TestClient.ts"; -import { unregisterSchema } from "@hyperjump/json-schema"; describe("Hover", () => { let client: TestClient; @@ -16,10 +15,6 @@ describe("Hover", () => { await client.stop(); }); - afterEach(() => { - unregisterSchema(fixtureSchemaUri); - }); - test("should return title and description on hover over a property value", async () => { const diagnostics: Promise = new Promise((resolve) => { client.onNotification(PublishDiagnosticsNotification.type, () => { diff --git a/language-server/src/protocol/hyperjump-findFiles.ts b/language-server/src/protocol/hyperjump-findFiles.ts new file mode 100644 index 0000000..c564d7f --- /dev/null +++ b/language-server/src/protocol/hyperjump-findFiles.ts @@ -0,0 +1,14 @@ +import { CM, MessageDirection, ProtocolRequestType } from "vscode-languageserver"; + +export type FindFilesParams = { + include: string; + exclude?: string; + maxResults?: number; +}; + +export const FindFilesRequest = { + method: "hyperjump/findFiles" as const, + messageDirection: MessageDirection.serverToClient, + type: new ProtocolRequestType("hyperjump/findFiles"), + capabilities: CM.create("hyperjump.findFiles", undefined) +}; diff --git a/language-server/src/protocol/hyperjump-readFile.ts b/language-server/src/protocol/hyperjump-readFile.ts new file mode 100644 index 0000000..6955130 --- /dev/null +++ b/language-server/src/protocol/hyperjump-readFile.ts @@ -0,0 +1,12 @@ +import { CM, MessageDirection, ProtocolRequestType } from "vscode-languageserver"; + +export type ReadFileParams = { + uri: string; +}; + +export const ReadFileRequest = { + method: "hyperjump/readFile" as const, + messageDirection: MessageDirection.serverToClient, + type: new ProtocolRequestType("hyperjump/readFile"), + capabilities: CM.create("hyperjump.readFile", undefined) +}; diff --git a/language-server/src/services/SchemaStore.ts b/language-server/src/services/SchemaStore.ts index 1e928fd..45f583f 100644 --- a/language-server/src/services/SchemaStore.ts +++ b/language-server/src/services/SchemaStore.ts @@ -1,11 +1,8 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { compile, getSchema, getKeywordName } from "@hyperjump/json-schema/experimental"; +import { compile, getSchema, getKeywordName, buildSchemaDocument } from "@hyperjump/json-schema/experimental"; import { registerSchema, unregisterSchema } from "@hyperjump/json-schema"; import { evaluateCompiledSchema } from "@hyperjump/json-schema-errors"; -import { addUriSchemePlugin, httpSchemePlugin } from "@hyperjump/browser"; -import { normalizeIri, resolveIri, toAbsoluteIri } from "@hyperjump/uri"; +import { addMediaTypePlugin, addUriSchemePlugin, getFileMediaType, httpSchemePlugin } from "@hyperjump/browser"; +import { normalizeIri, parseIri, resolveIri, toAbsoluteIri, toRelativeIri } from "@hyperjump/uri"; import * as jsonc from "jsonc-parser"; import * as Pact from "@hyperjump/pact"; import ignore from "ignore"; @@ -54,7 +51,11 @@ export class SchemaStore { this.scanCompleted = new Promise((resolve) => { server.onInitialized(async () => { - await this.scanWorkspace(); + this.server.console.log("Scanning workspace for self-identifying schemas..."); + for (const fileUri of await this.workspace.findFiles("**/*.{json,jsonc}")) { + await this.processWorkspaceSchemaFile(fileUri); + } + this.server.console.log("Scanning completed"); resolve(); }); }); @@ -67,6 +68,13 @@ export class SchemaStore { ); }); + addMediaTypePlugin("application/json", { + parse: async (response) => { + return buildSchemaDocument(await response.json(), response.url); + }, + fileMatcher: async (path) => path.endsWith(".json") + }); + const uriSchemePlugin: UriSchemePlugin = { async retrieve(uri: string) { if (!(await schemaAllowList).has(uri) && !uri.startsWith("https://json.schemastore.org")) { @@ -76,10 +84,33 @@ export class SchemaStore { return httpSchemePlugin.retrieve(uri); } }; - addUriSchemePlugin("http", uriSchemePlugin); addUriSchemePlugin("https", uriSchemePlugin); + addUriSchemePlugin("file", { + async retrieve(uri, baseUri) { + if (baseUri) { + const { scheme } = parseIri(baseUri); + + if (scheme !== "file") { + throw Error(`Accessing a file (${uri}) from a non-filesystem context (${baseUri}) is not allowed`); + } + } + + let responseUri = toAbsoluteIri(uri); + + const contentType = await getFileMediaType(responseUri); + const file = await workspace.readFile(uri); + const stream = new Blob([file]).stream(); + const response = new Response(stream, { + headers: { "Content-Type": contentType } + }); + Object.defineProperty(response, "url", { value: responseUri }); + + return response; + } + }); + workspace.onDidChangeWatchedFiles(async (params) => { for (const change of params.changes) { const changedSchemaUri = normalizeIri(change.uri); @@ -92,22 +123,18 @@ export class SchemaStore { } async getSchemaUri(fileUri: string) { - const filePath = fileURLToPath(fileUri); - - for (const schema of await this.catalog) { - const { fileMatch, url } = schema; + for (const { fileMatch, url } of await this.catalog) { if (!fileMatch) { continue; } const ig = ignore().add(fileMatch); for (const workspaceUri of this.workspace.workspaceFolders) { - const workspacePath = fileURLToPath(workspaceUri); - if (!filePath.startsWith(workspacePath)) { + if (!fileUri.startsWith(workspaceUri)) { continue; } - const relativePath = path.relative(workspacePath, filePath); + const relativePath = toRelativeIri(workspaceUri, fileUri); if (ig.ignores(relativePath)) { return url; } @@ -172,36 +199,9 @@ export class SchemaStore { return dependentSchemas; } - private async scanWorkspace() { - this.server.console.log("Scanning workspace for self-identifying schemas..."); - for (const folderUri of this.workspace.workspaceFolders) { - const dirPath = fileURLToPath(folderUri); - - const ig = ignore(); - try { - const gitignorePath = path.join(dirPath, ".gitignore"); - const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); - ig.add(gitignoreContent); - } catch { - // Ignore if .gitignore does not exist - } - - for await (const entry of fs.glob("**/*.{json,jsonc}", { cwd: dirPath, exclude: [".git/"] })) { - if (ig.ignores(entry)) { - continue; - } - const fullPath = path.join(dirPath, entry); - const fileUri = pathToFileURL(fullPath).toString(); - await this.processWorkspaceSchemaFile(fileUri); - } - } - this.server.console.log("Scanning completed"); - } - private async processWorkspaceSchemaFile(fileUri: string) { - const filePath = fileURLToPath(fileUri); try { - const text = await fs.readFile(filePath, "utf-8"); + const text = await this.workspace.readFile(fileUri); const schema = jsonc.parse(text); if (typeof schema?.["$schema"] === "string") { diff --git a/language-server/src/services/Workspace.ts b/language-server/src/services/Workspace.ts index f84e73d..1aa14c4 100644 --- a/language-server/src/services/Workspace.ts +++ b/language-server/src/services/Workspace.ts @@ -1,17 +1,22 @@ -import { - DidChangeWatchedFilesNotification, +import { DidChangeWatchedFilesNotification, Disposable } from "vscode-languageserver"; +import { Server } from "./Server.ts"; +import { ReadFileRequest } from "../protocol/hyperjump-readFile.ts"; +import { FindFilesRequest } from "../protocol/hyperjump-findFiles.ts"; + +import type { DidChangeWatchedFilesParams, - Disposable, NotificationHandler, ServerCapabilities } from "vscode-languageserver"; -import { Server } from "./Server.ts"; export class Workspace { + private server: Server; private _workspaceFolders: Set = new Set(); private didChangeWatchedFilesHandlers: Set>; constructor(server: Server) { + this.server = server; + let hasWorkspaceWatchCapability = false; let hasWorkspaceFolderCapability = false; @@ -82,4 +87,12 @@ export class Workspace { } }; } + + async readFile(uri: string) { + return await this.server.sendRequest(ReadFileRequest.type, { uri }); + } + + async findFiles(include: string, exclude?: string, maxResults?: number) { + return await this.server.sendRequest(FindFilesRequest.type, { include, exclude, maxResults }); + } } diff --git a/language-server/src/test/TestClient.ts b/language-server/src/test/TestClient.ts index 2b8793c..832e6a1 100644 --- a/language-server/src/test/TestClient.ts +++ b/language-server/src/test/TestClient.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { access, glob, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { Duplex } from "node:stream"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; @@ -18,10 +18,14 @@ import { ShutdownRequest } from "vscode-languageserver"; import { createConnection } from "vscode-languageserver/node"; -import { resolveIri } from "@hyperjump/uri"; +import { normalizeIri, resolveIri } from "@hyperjump/uri"; import { merge } from "merge-anything"; import { MockAgent, setGlobalDispatcher } from "undici"; -import { buildServer, LanguageServerSettings } from "../build-server.js"; +import ignore from "ignore"; +import * as Pact from "@hyperjump/pact"; +import { buildServer } from "../build-server.js"; +import { FindFilesRequest } from "../protocol/hyperjump-findFiles.ts"; +import { ReadFileRequest } from "../protocol/hyperjump-readFile.ts"; import type { Connection, @@ -29,6 +33,7 @@ import type { InitializeParams, ServerCapabilities } from "vscode-languageserver"; +import type { LanguageServerSettings } from "../build-server.js"; export class TestClient { private client: Connection; @@ -40,6 +45,7 @@ export class TestClient { private openDocuments: Set; private workspaceFolder: Promise; private ready: Promise; + private gitignore: Promise; onRequest: Connection["onRequest"]; sendRequest: Connection["sendRequest"]; @@ -54,7 +60,15 @@ export class TestClient { this.watchEnabled = false; this.openDocuments = new Set(); this.workspaceFolder = mkdtemp(join(tmpdir(), "test-workspace-")) - .then((path) => pathToFileURL(path) + "/"); + .then((path) => normalizeIri(pathToFileURL(path) + "/")); + this.gitignore = this.workspaceFolder.then(async (rootPath) => { + const gitignorePath = join(rootPath, ".gitignore"); + try { + return await readFile(gitignorePath, "utf8"); + } catch { + return ""; + } + }); this.mockAgent = new MockAgent(); this.mockAgent.disableNetConnect(); @@ -102,6 +116,28 @@ export class TestClient { }); }); + this.client.onRequest(ReadFileRequest.type, async (params) => { + const path = fileURLToPath(params.uri); + return await readFile(path, "utf8"); + }); + + this.client.onRequest(FindFilesRequest.type, async (params) => { + const ig = ignore() + .add(await this.gitignore) + .add(params.exclude ?? ""); + + const workspacePath = fileURLToPath(await this.workspaceFolder); + + return await Pact.pipe( + glob(params.include, { cwd: workspacePath }), + Pact.asyncFilter((file) => !ig.ignores(file)), + Pact.asyncMap((relativePath: string) => join(workspacePath, relativePath)), + Pact.asyncMap((fullPath: string) => pathToFileURL(fullPath).toString()), + Pact.asyncTake(params.maxResults ?? Number.MAX_SAFE_INTEGER), + Pact.asyncCollectArray + ); + }); + this.client.listen(); } diff --git a/language-server/tsconfig.json b/language-server/tsconfig.json index 5fe9982..49174f4 100644 --- a/language-server/tsconfig.json +++ b/language-server/tsconfig.json @@ -9,6 +9,8 @@ "checkJs": true, "skipLibCheck": true, "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, "noEmit": true, "types": ["node"] } diff --git a/neovim/README.md b/neovim/README.md index 246d2e7..f57f6e3 100644 --- a/neovim/README.md +++ b/neovim/README.md @@ -5,23 +5,7 @@ add it to [lspconfig](https://github.com/neovim/nvim-lspconfig) and [Mason](https://github.com/williamboman/mason.nvim) to make setup as easy as possible. For now, installation is manual. -First checkout this repo locally and run `npm install`. Then you can configure -Neovim to use that checkout to run the server. The following is my -configuration, yours may vary slightly depending on your environment. - -`~/.config/nvim/ftplugin/json.lua` -```lua -local root_files = { ".git", "package.json" } -local paths = vim.fs.find(root_files, { stop = vim.env.HOME }) - -vim.lsp.start({ - name = "hyperjump-json-language-server", - cmd = { "npx", "/path/to/json-language-server/language-server/src/server.ts", "--stdio" }, - root_dir = vim.fs.dirname(paths[1]), - settings = { - jsonLanguageServer = { - -- Put any settings here - } - } -}) -``` +First checkout this repo locally and run `npm install`. This configuration +requires [plenary.nvim](https://github.com/nvim-lua/plenary.nvim). Then you can +configure Neovim to use that checkout to run the server. See +[`neovim/ftplugin/json.lua`](ftplugin/json.lua) for the configuration. diff --git a/neovim/ftplugin/json.lua b/neovim/ftplugin/json.lua new file mode 100644 index 0000000..9bfb515 --- /dev/null +++ b/neovim/ftplugin/json.lua @@ -0,0 +1,42 @@ +local root_files = { ".git", "package.json" } +local paths = vim.fs.find(root_files, { stop = vim.env.HOME }) + +vim.lsp.start({ + name = "hyperjump-json-language-server", + cmd = { "node", "../json-language-server/language-server/src/server.ts", "--stdio" }, + root_dir = vim.fs.dirname(paths[1]), + settings = { + jsonLanguageServer = { + -- Put any settings here + } + } +}) + +local ok, scandir = pcall(require, "plenary.scandir") + +vim.lsp.handlers["hyperjump/readFile"] = function(_, params, _) + local f = assert(io.open(vim.uri_to_fname(params.uri), "rb"), "File not found") + local content = f:read("*a") + f:close() + return content +end + +vim.lsp.handlers["hyperjump/findFiles"] = function(_, params, _) + assert(ok, "hyperjump/findFiles requires plenary.nvim (https://github.com/nvim-lua/plenary.nvim). Install it and restart Neovim.") + local root = assert(vim.uv.cwd(), "hyperjump/findFiles: cwd is not set") + + local include = vim.glob.to_lpeg(params.include) + local exclude = params.exclude and vim.glob.to_lpeg(params.exclude) + + local matches = scandir.scan_dir(root, { + respect_gitignore = true, + hidden = true, + silent = true, + search_pattern = function(entry) + local rel = vim.fs.relpath(root, entry) + return rel ~= nil and not vim.startswith(rel, ".git/") and include:match(rel) ~= nil and (exclude == nil or exclude:match(rel) == nil) + end, + }) + + return vim.tbl_map(vim.uri_from_fname, vim.list_slice(matches, 1, params.maxResults)) +end diff --git a/vscode/package-lock.json b/vscode/package-lock.json index e94d61c..426fa4d 100644 --- a/vscode/package-lock.json +++ b/vscode/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "ignore": "^7.0.6", "vscode-languageclient": "^10.0.0" }, "devDependencies": { @@ -2426,7 +2427,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" diff --git a/vscode/package.json b/vscode/package.json index 291f00e..eb50023 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -26,6 +26,7 @@ "esbuild": "^0.28.0" }, "dependencies": { + "ignore": "^7.0.6", "vscode-languageclient": "^10.0.0" }, "engines": { diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 82b5d45..c832482 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -1,8 +1,20 @@ import * as path from "node:path"; import { LanguageClient, TransportKind } from "vscode-languageclient/node"; +import { Uri, workspace } from "vscode"; +import ignore from "ignore"; import type { ExtensionContext } from "vscode"; +type FindFilesRequest = { + include: string; + exclude?: string; + maxResults?: number; +}; + +type ReadFileRequest = { + uri: string; +}; + let client: LanguageClient | undefined; export const activate = async (context: ExtensionContext) => { @@ -29,6 +41,29 @@ export const activate = async (context: ExtensionContext) => { }; client = new LanguageClient("hyperjumpJsonLanguageServer", "Hyperjump - JSON Language Server", serverOptions, clientOptions); + + const ig = ignore(); + try { + for (const workspaceFolder of workspace.workspaceFolders ?? []) { + const gitignoreUri = Uri.joinPath(workspaceFolder.uri, ".gitignore"); + const bytes = await workspace.fs.readFile(gitignoreUri); + const gitignore = new TextDecoder().decode(bytes); + ig.add(gitignore); + } + } catch { + } + + client.onRequest("hyperjump/findFiles", async (params: FindFilesRequest) => { + return (await workspace.findFiles(params.include, params.exclude, params.maxResults)) + .map((uri) => uri.toString()) + .filter((uri) => !ig.ignores(workspace.asRelativePath(uri))); + }); + + client.onRequest("hyperjump/readFile", async (params: ReadFileRequest) => { + const bytes = await workspace.fs.readFile(Uri.parse(params.uri)); + return new TextDecoder().decode(bytes); + }); + await client.start(); };