correctly 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath, pathToFileURL } from "node:url"
3
+ import path from "node:path"
4
+ import {
5
+ createConnection,
6
+ TextDocuments,
7
+ TextDocumentSyncKind,
8
+ DidChangeWatchedFilesNotification,
9
+ ResponseError,
10
+ LSPErrorCodes,
11
+ type CancellationToken,
12
+ } from "vscode-languageserver/node"
13
+ import { TextDocument } from "vscode-languageserver-textdocument"
14
+ import { Workspace } from "./workspace.ts"
15
+ import { Hints } from "./hints.ts"
16
+ import type { FileResult } from "../core/types.ts"
17
+ import { diagnostic } from "../core/parse.ts"
18
+ import { diagnosticViews, diagnosticDetails } from "../core/diagnostics.ts"
19
+
20
+ export function lspDiagnostics(result: FileResult, text: string) {
21
+ return diagnosticViews([
22
+ ...result.diagnostics,
23
+ ...result.failures
24
+ .filter(
25
+ (f) =>
26
+ !result.diagnostics.some(
27
+ (d) => d.code === f.code && d.message === f.message,
28
+ ),
29
+ )
30
+ .map((f) => diagnostic(text, f.code, f.message)),
31
+ ]).map((d) => ({
32
+ range: d.range,
33
+ message: d.message,
34
+ code: d.code,
35
+ severity: 1 as const,
36
+ source: "correctly",
37
+ data: { pointer: d.pointer },
38
+ ...(d.branches.length
39
+ ? {
40
+ relatedInformation: diagnosticDetails(d).map(
41
+ ({ label, diagnostic }) => ({
42
+ location: {
43
+ uri: pathToFileURL(result.file).href,
44
+ range: diagnostic.range,
45
+ },
46
+ message: `${label}: ${diagnostic.message}`,
47
+ }),
48
+ ),
49
+ }
50
+ : {}),
51
+ }))
52
+ }
53
+
54
+ export function startServer() {
55
+ const connection = createConnection()
56
+ const documents = new TextDocuments(TextDocument)
57
+ const workspace = new Workspace([])
58
+ const hints = new Hints()
59
+ const jobs = new Map<
60
+ string,
61
+ { controller: AbortController; timer: ReturnType<typeof setTimeout> }
62
+ >()
63
+ let dynamicWatch = false
64
+ const watchedSchemas = new Set<string>()
65
+ workspace.onSchema = (uri) => {
66
+ if (!dynamicWatch || !uri.startsWith("file:") || watchedSchemas.has(uri))
67
+ return
68
+ watchedSchemas.add(uri)
69
+ const file = fileURLToPath(uri)
70
+ void connection.client
71
+ .register(DidChangeWatchedFilesNotification.type, {
72
+ watchers: [
73
+ {
74
+ globPattern: {
75
+ baseUri: pathToFileURL(path.dirname(file)).href,
76
+ pattern: path.basename(file),
77
+ },
78
+ },
79
+ ],
80
+ })
81
+ .catch((error: unknown) => connection.console.error(String(error)))
82
+ }
83
+ connection.onInitialize((params) => {
84
+ workspace.roots = (
85
+ params.workspaceFolders?.map((f) => f.uri) ??
86
+ (params.rootUri ? [params.rootUri] : [])
87
+ )
88
+ .filter((uri) => uri.startsWith("file:"))
89
+ .map((uri) => fileURLToPath(uri))
90
+ dynamicWatch =
91
+ params.capabilities.workspace?.didChangeWatchedFiles
92
+ ?.dynamicRegistration ?? false
93
+ return {
94
+ capabilities: {
95
+ textDocumentSync: TextDocumentSyncKind.Incremental,
96
+ completionProvider: { triggerCharacters: ['"', ":"] },
97
+ hoverProvider: true,
98
+ workspace: {
99
+ workspaceFolders: { supported: true, changeNotifications: true },
100
+ },
101
+ },
102
+ serverInfo: { name: "correctly", version: "0.0.0" },
103
+ }
104
+ })
105
+ connection.onInitialized(() => {
106
+ if (dynamicWatch)
107
+ void connection.client
108
+ .register(DidChangeWatchedFilesNotification.type, {
109
+ watchers: [{ globPattern: "**/*.{json,jsonc}" }],
110
+ })
111
+ .catch((error: unknown) => connection.console.error(String(error)))
112
+ connection.workspace.onDidChangeWorkspaceFolders((event) => {
113
+ const removed = new Set(event.removed.map((f) => fileURLToPath(f.uri)))
114
+ workspace.roots = [
115
+ ...workspace.roots.filter((r) => !removed.has(r)),
116
+ ...event.added.map((f) => fileURLToPath(f.uri)),
117
+ ]
118
+ refresh()
119
+ })
120
+ })
121
+ function schedule(document: TextDocument) {
122
+ if (!document.uri.startsWith("file:")) return
123
+ const previous = jobs.get(document.uri)
124
+ if (previous) {
125
+ clearTimeout(previous.timer)
126
+ previous.controller.abort()
127
+ }
128
+ const controller = new AbortController()
129
+ const generation = workspace.generation
130
+ const timer = setTimeout(() => {
131
+ void workspace
132
+ .validate(document)
133
+ .then((result) => {
134
+ if (
135
+ controller.signal.aborted ||
136
+ workspace.generation !== generation ||
137
+ documents.get(document.uri)?.version !== document.version
138
+ )
139
+ return
140
+ return connection.sendDiagnostics({
141
+ uri: document.uri,
142
+ version: document.version,
143
+ diagnostics: lspDiagnostics(result, document.getText()),
144
+ })
145
+ })
146
+ .catch((error: unknown) => connection.console.error(String(error)))
147
+ }, 40)
148
+ jobs.set(document.uri, { controller, timer })
149
+ }
150
+ function scheduleAll() {
151
+ for (const document of documents.all()) schedule(document)
152
+ }
153
+ function refresh() {
154
+ workspace.invalidate()
155
+ scheduleAll()
156
+ }
157
+ documents.onDidChangeContent(({ document }) => {
158
+ if (!document.uri.startsWith("file:")) return
159
+ const resourceChanged = workspace.open(document)
160
+ if (resourceChanged) scheduleAll()
161
+ else schedule(document)
162
+ })
163
+ documents.onDidClose(async ({ document }) => {
164
+ const job = jobs.get(document.uri)
165
+ if (job) {
166
+ clearTimeout(job.timer)
167
+ job.controller.abort()
168
+ jobs.delete(document.uri)
169
+ }
170
+ await connection.sendDiagnostics({ uri: document.uri, diagnostics: [] })
171
+ if (document.uri.startsWith("file:") && workspace.close(document.uri))
172
+ scheduleAll()
173
+ })
174
+ connection.onDidChangeWatchedFiles((params) => {
175
+ // Cache writes and unrelated JSON files must not trigger schema reloads.
176
+ if (params.changes.some((change) => workspace.resourceChanged(change.uri)))
177
+ refresh()
178
+ })
179
+ connection.onDidChangeConfiguration(refresh)
180
+ async function request<T>(
181
+ uri: string,
182
+ token: CancellationToken,
183
+ action: (
184
+ engine: Awaited<ReturnType<Workspace["engineFor"]>>,
185
+ document: TextDocument,
186
+ ) => Promise<T>,
187
+ ): Promise<T | null> {
188
+ const document = documents.get(uri)
189
+ if (!document || !uri.startsWith("file:")) return null
190
+ const generation = workspace.generation
191
+ const cancelled = () =>
192
+ token.isCancellationRequested ||
193
+ workspace.generation !== generation ||
194
+ documents.get(uri)?.version !== document.version
195
+ if (cancelled())
196
+ throw new ResponseError(
197
+ LSPErrorCodes.RequestCancelled,
198
+ "Request cancelled",
199
+ )
200
+ const operation = (async () => {
201
+ try {
202
+ const engine = await workspace.engineFor(uri)
203
+ if (cancelled())
204
+ throw new ResponseError(
205
+ LSPErrorCodes.RequestCancelled,
206
+ "Request cancelled",
207
+ )
208
+ const result = await action(engine, document)
209
+ if (cancelled())
210
+ throw new ResponseError(
211
+ LSPErrorCodes.RequestCancelled,
212
+ "Request cancelled",
213
+ )
214
+ return result
215
+ } catch (error) {
216
+ if (error instanceof ResponseError) throw error
217
+ if (cancelled())
218
+ throw new ResponseError(
219
+ LSPErrorCodes.RequestCancelled,
220
+ "Request cancelled",
221
+ )
222
+ connection.console.error(String(error))
223
+ return null
224
+ }
225
+ })()
226
+ let listener: { dispose(): void } | undefined
227
+ const cancellation = new Promise<never>((_resolve, reject) => {
228
+ listener = token.onCancellationRequested(() =>
229
+ reject(
230
+ new ResponseError(
231
+ LSPErrorCodes.RequestCancelled,
232
+ "Request cancelled",
233
+ ),
234
+ ),
235
+ )
236
+ })
237
+ try {
238
+ return await Promise.race([operation, cancellation])
239
+ } finally {
240
+ listener?.dispose()
241
+ }
242
+ }
243
+ connection.onCompletion((params, token) =>
244
+ request(params.textDocument.uri, token, (engine, document) =>
245
+ hints.complete(engine, document, params.position),
246
+ ),
247
+ )
248
+ connection.onHover((params, token) =>
249
+ request(params.textDocument.uri, token, (engine, document) =>
250
+ hints.hover(engine, document, params.position),
251
+ ),
252
+ )
253
+ connection.onShutdown(() => {
254
+ workspace.invalidate()
255
+ for (const job of jobs.values()) {
256
+ clearTimeout(job.timer)
257
+ job.controller.abort()
258
+ }
259
+ jobs.clear()
260
+ })
261
+ documents.listen(connection)
262
+ connection.listen()
263
+ }
264
+
265
+ startServer()
@@ -0,0 +1,132 @@
1
+ import path from "node:path"
2
+ import { fileURLToPath, pathToFileURL } from "node:url"
3
+ import { TextDocument } from "vscode-languageserver-textdocument"
4
+ import {
5
+ contains,
6
+ discoverConfig,
7
+ loadProject,
8
+ readText,
9
+ type ReadText,
10
+ } from "../core/config.ts"
11
+ import { Engine } from "../core/engine.ts"
12
+ import { CorrectlyError, failure, type FileResult } from "../core/types.ts"
13
+ import { diagnostic } from "../core/parse.ts"
14
+
15
+ export class Workspace {
16
+ onSchema: ((uri: string) => void) | undefined
17
+ roots: string[]
18
+ private readonly read: ReadText
19
+ readonly documents = new Map<string, TextDocument>()
20
+ private readonly engines = new Map<string, Promise<Engine>>()
21
+ private controller = new AbortController()
22
+ generation = 0
23
+ constructor(roots: string[], read: ReadText = readText) {
24
+ this.roots = roots
25
+ this.read = read
26
+ }
27
+
28
+ invalidate() {
29
+ this.generation++
30
+ this.controller.abort()
31
+ this.controller = new AbortController()
32
+ this.engines.clear()
33
+ }
34
+
35
+ open(document: TextDocument): boolean {
36
+ this.documents.set(document.uri, document)
37
+ const resource =
38
+ path.basename(fileURLToPath(document.uri)) === "correctly.config.json" ||
39
+ this.isSchema(document.uri)
40
+ if (resource) this.invalidate()
41
+ return resource
42
+ }
43
+ close(uri: string): boolean {
44
+ const resource =
45
+ uri.endsWith("/correctly.config.json") || this.isSchema(uri)
46
+ this.documents.delete(uri)
47
+ if (resource) this.invalidate()
48
+ return resource
49
+ }
50
+ private readonly schemaUris = new Set<string>()
51
+ private isSchema(uri: string): boolean {
52
+ return this.schemaUris.has(uri)
53
+ }
54
+ resourceChanged(uri: string): boolean {
55
+ return uri.endsWith("/correctly.config.json") || this.isSchema(uri)
56
+ }
57
+ private readonly readBuffer: ReadText = (uri) => {
58
+ const document = this.documents.get(uri)
59
+ return document ? Promise.resolve(document.getText()) : this.read(uri)
60
+ }
61
+
62
+ async engineFor(uri: string): Promise<Engine> {
63
+ const file = fileURLToPath(uri)
64
+ const root = this.roots
65
+ .filter((r) => contains(r, file))
66
+ .sort((a, b) => b.length - a.length)[0]
67
+ if (this.roots.length && !root)
68
+ throw new CorrectlyError(
69
+ "config",
70
+ `Document is outside the editor workspace: ${file}`,
71
+ )
72
+ const virtualConfigs = new Set(
73
+ [...this.documents.keys()]
74
+ .filter((key) => key.endsWith("/correctly.config.json"))
75
+ .map((key) => fileURLToPath(key)),
76
+ )
77
+ const configPath = await discoverConfig(
78
+ path.dirname(file),
79
+ root,
80
+ virtualConfigs,
81
+ )
82
+ let promise = this.engines.get(configPath)
83
+ if (!promise) {
84
+ const signal = this.controller.signal
85
+ promise = (async () => {
86
+ const project = await loadProject(configPath, this.readBuffer)
87
+ const readSchema: ReadText = async (schema) => {
88
+ this.schemaUris.add(schema)
89
+ this.onSchema?.(schema)
90
+ return this.readBuffer(schema)
91
+ }
92
+ const engine = new Engine(project, { read: readSchema, signal })
93
+ await engine.prepare()
94
+ for (const resource of engine.store.resources.values())
95
+ this.schemaUris.add(resource.uri)
96
+ signal.throwIfAborted()
97
+ return engine
98
+ })()
99
+ this.engines.set(configPath, promise)
100
+ }
101
+ return promise
102
+ }
103
+
104
+ async validate(document: TextDocument): Promise<FileResult> {
105
+ try {
106
+ const engine = await this.engineFor(document.uri)
107
+ return await engine.validate(
108
+ fileURLToPath(document.uri),
109
+ document.getText(),
110
+ )
111
+ } catch (error) {
112
+ const problem = failure(error, fileURLToPath(document.uri))
113
+ return {
114
+ file: fileURLToPath(document.uri),
115
+ mode: document.languageId === "jsonc" ? "jsonc" : "json",
116
+ association: null,
117
+ coverage: "syntax-only",
118
+ diagnostics: [
119
+ diagnostic(document.getText(), problem.code, problem.message),
120
+ ],
121
+ failures: [problem],
122
+ }
123
+ }
124
+ }
125
+
126
+ documentPath(uri: string): string {
127
+ return fileURLToPath(uri)
128
+ }
129
+ rootUris(): string[] {
130
+ return this.roots.map((root) => pathToFileURL(root).href)
131
+ }
132
+ }
@@ -0,0 +1,7 @@
1
+ # Correctly for VS Code
2
+
3
+ Install the VSIX, add `correctly.config.json` to each workspace root, and open JSON or JSONC files. Correctly uses that same external configuration for validation, completion, and hover. It reads unsaved buffers and refreshes when configurations and schemas change.
4
+
5
+ The client and server are bundled; no npm install or separate schema map is needed. Run **Correctly: Restart Server** to restart the language server. JSON formatting remains the responsibility of your formatter.
6
+
7
+ VS Code's built-in JSON features can also provide their own diagnostics or suggestions. Correctly diagnostics have source `correctly`. For an editor experience using only Correctly validation, set `json.validate.enable` to `false`; this does not affect Correctly or require another schema association map.
@@ -0,0 +1,33 @@
1
+ import * as vscode from "vscode"
2
+ import { LanguageClient, TransportKind } from "vscode-languageclient/node"
3
+
4
+ let client: LanguageClient | undefined
5
+ export async function activate(context: vscode.ExtensionContext) {
6
+ const watcher = vscode.workspace.createFileSystemWatcher("**/*")
7
+ context.subscriptions.push(watcher)
8
+ client = new LanguageClient(
9
+ "correctly",
10
+ "Correctly",
11
+ {
12
+ module: context.asAbsolutePath("dist/server.mjs"),
13
+ transport: TransportKind.stdio,
14
+ },
15
+ {
16
+ documentSelector: [
17
+ { scheme: "file", language: "json" },
18
+ { scheme: "file", language: "jsonc" },
19
+ ],
20
+ synchronize: { fileEvents: watcher },
21
+ },
22
+ )
23
+ context.subscriptions.push(
24
+ vscode.commands.registerCommand("correctly.restartServer", async () => {
25
+ await client?.restart()
26
+ }),
27
+ )
28
+ await client.start()
29
+ }
30
+ export async function deactivate() {
31
+ await client?.stop()
32
+ client = undefined
33
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "correctly-vscode",
3
+ "displayName": "Correctly",
4
+ "version": "0.0.0",
5
+ "private": true,
6
+ "description": "JSON Schema validation and editor hints from one external configuration",
7
+ "categories": [
8
+ "Linters",
9
+ "Programming Languages"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/jeremybanka/correctly.git",
14
+ "directory": "packages/correctly/src/vscode"
15
+ },
16
+ "license": "MPL-2.0",
17
+ "publisher": "jeremybanka",
18
+ "type": "module",
19
+ "main": "./dist/extension.mjs",
20
+ "files": [
21
+ "dist",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "contributes": {
26
+ "commands": [
27
+ {
28
+ "command": "correctly.restartServer",
29
+ "title": "Correctly: Restart Server"
30
+ }
31
+ ]
32
+ },
33
+ "activationEvents": [
34
+ "onLanguage:json",
35
+ "onLanguage:jsonc"
36
+ ],
37
+ "engines": {
38
+ "vscode": "^1.100.0"
39
+ },
40
+ "extensionKind": [
41
+ "workspace"
42
+ ]
43
+ }