correctly 0.0.0 → 0.1.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.
- package/AGENTS.md +6 -2
- package/README.md +21 -15
- package/dist/ajv.d.mts +11 -0
- package/dist/ajv.d.mts.map +1 -0
- package/dist/ajv.mjs +221 -0
- package/dist/ajv.mjs.map +1 -0
- package/dist/check-BI7U3ZLc.mjs +88 -0
- package/dist/check-BI7U3ZLc.mjs.map +1 -0
- package/dist/cli.mjs +14 -62
- package/dist/cli.mjs.map +1 -1
- package/dist/config-Bx1-XXeo.d.mts +189 -0
- package/dist/config-Bx1-XXeo.d.mts.map +1 -0
- package/dist/config-Cb6o4YnJ.mjs +348 -0
- package/dist/config-Cb6o4YnJ.mjs.map +1 -0
- package/dist/core.d.mts +27 -125
- package/dist/core.d.mts.map +1 -1
- package/dist/core.mjs +5 -2
- package/dist/diagnostics-BqD1WOy4.mjs +70 -0
- package/dist/diagnostics-BqD1WOy4.mjs.map +1 -0
- package/dist/engine-VST0hoSk.mjs +111 -0
- package/dist/engine-VST0hoSk.mjs.map +1 -0
- package/dist/extensions-ClclWslY.d.mts +11 -0
- package/dist/extensions-ClclWslY.d.mts.map +1 -0
- package/dist/hints-HvSIhuMM.mjs +57 -0
- package/dist/hints-HvSIhuMM.mjs.map +1 -0
- package/dist/lsp.mjs +40 -91
- package/dist/lsp.mjs.map +1 -1
- package/dist/renovate.d.mts +5 -0
- package/dist/renovate.d.mts.map +1 -0
- package/dist/renovate.mjs +11 -0
- package/dist/renovate.mjs.map +1 -0
- package/dist/schemas-UxCtIdo2.mjs +265 -0
- package/dist/schemas-UxCtIdo2.mjs.map +1 -0
- package/dist/session-DCFav56x.mjs +113 -0
- package/dist/session-DCFav56x.mjs.map +1 -0
- package/dist/worker.mjs +80 -0
- package/dist/worker.mjs.map +1 -0
- package/docs/guide.md +166 -34
- package/examples/basic/correctly.config.ts +13 -0
- package/package.json +69 -60
- package/src/cli/check.ts +41 -11
- package/src/cli/main.ts +3 -2
- package/src/cli/readable.ts +24 -5
- package/src/cli/vsix.ts +1 -0
- package/src/core/adapters.ts +54 -0
- package/src/core/config.ts +201 -64
- package/src/core/engine.ts +118 -218
- package/src/core/extensions.ts +144 -0
- package/src/core/index.ts +3 -0
- package/src/core/parse.ts +26 -2
- package/src/core/parsers.ts +22 -0
- package/src/core/schema-walk.ts +43 -0
- package/src/core/schemas.ts +21 -41
- package/src/core/types.ts +33 -6
- package/src/extensions/renovate.ts +4 -0
- package/src/lsp/hints.ts +22 -12
- package/src/lsp/server.ts +9 -2
- package/src/lsp/workspace.ts +49 -54
- package/src/runtime/session.ts +173 -0
- package/src/runtime/worker.ts +99 -0
- package/src/validators/ajv.ts +257 -0
- package/src/vscode/README.md +1 -1
- package/src/vscode/extension.ts +2 -4
- package/src/vscode/package.json +9 -2
- package/dist/engine-BgWBnb1A.mjs +0 -746
- package/dist/engine-BgWBnb1A.mjs.map +0 -1
- package/examples/basic/correctly.config.json +0 -10
- package/src/core/config.schema.json +0 -47
package/src/core/schemas.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { AnySchema, SchemaObject } from "ajv"
|
|
|
6
6
|
import { parseDocument } from "./parse.ts"
|
|
7
7
|
import { readText, type Project, type ReadText } from "./config.ts"
|
|
8
8
|
import { CorrectlyError } from "./types.ts"
|
|
9
|
+
import { schemaChildren } from "./schema-walk.ts"
|
|
9
10
|
|
|
10
11
|
export type Dialect = "draft7" | "2020-12"
|
|
11
12
|
export const DIALECTS = {
|
|
@@ -23,28 +24,6 @@ const VOCABULARIES = new Set(
|
|
|
23
24
|
"content",
|
|
24
25
|
].map((name) => `https://json-schema.org/draft/2020-12/vocab/${name}`),
|
|
25
26
|
)
|
|
26
|
-
const SINGLE_SCHEMAS = [
|
|
27
|
-
"additionalItems",
|
|
28
|
-
"additionalProperties",
|
|
29
|
-
"contains",
|
|
30
|
-
"propertyNames",
|
|
31
|
-
"not",
|
|
32
|
-
"if",
|
|
33
|
-
"then",
|
|
34
|
-
"else",
|
|
35
|
-
"unevaluatedProperties",
|
|
36
|
-
"unevaluatedItems",
|
|
37
|
-
"contentSchema",
|
|
38
|
-
]
|
|
39
|
-
const MAP_SCHEMAS = [
|
|
40
|
-
"properties",
|
|
41
|
-
"patternProperties",
|
|
42
|
-
"definitions",
|
|
43
|
-
"$defs",
|
|
44
|
-
"dependentSchemas",
|
|
45
|
-
"dependencies",
|
|
46
|
-
]
|
|
47
|
-
const ARRAY_SCHEMAS = ["allOf", "anyOf", "oneOf", "prefixItems"]
|
|
48
27
|
|
|
49
28
|
function isObject(value: unknown): value is SchemaObject {
|
|
50
29
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
@@ -70,6 +49,7 @@ export type SchemaResource = {
|
|
|
70
49
|
uri: string
|
|
71
50
|
schema: AnySchema
|
|
72
51
|
dialect: Dialect
|
|
52
|
+
pointer?: string
|
|
73
53
|
}
|
|
74
54
|
type CacheEntry = {
|
|
75
55
|
uri: string
|
|
@@ -96,7 +76,12 @@ export class SchemaStore {
|
|
|
96
76
|
private readonly read: ReadText
|
|
97
77
|
private readonly request: typeof fetch
|
|
98
78
|
private requests = 0
|
|
99
|
-
constructor(
|
|
79
|
+
constructor(
|
|
80
|
+
project: Pick<Project, "root"> & {
|
|
81
|
+
config: Pick<Project["config"], "remote">
|
|
82
|
+
},
|
|
83
|
+
options: StoreOptions = {},
|
|
84
|
+
) {
|
|
100
85
|
this.options = options
|
|
101
86
|
const remote = project.config.remote
|
|
102
87
|
this.cacheDir = path.resolve(
|
|
@@ -200,6 +185,7 @@ export class SchemaStore {
|
|
|
200
185
|
base: string,
|
|
201
186
|
expected: Dialect,
|
|
202
187
|
root: SchemaResource,
|
|
188
|
+
location = "",
|
|
203
189
|
) {
|
|
204
190
|
if (!isObject(schema)) return
|
|
205
191
|
const ownDialect = dialect(schema.$schema, expected)
|
|
@@ -239,28 +225,22 @@ export class SchemaStore {
|
|
|
239
225
|
throw new CorrectlyError("schema", `$id must be a string: ${base}`)
|
|
240
226
|
scope = new URL(schema.$id, base).href
|
|
241
227
|
schema.$id = scope
|
|
242
|
-
this.register(scope.replace(/#$/, ""), {
|
|
228
|
+
this.register(scope.replace(/#$/, ""), {
|
|
229
|
+
...root,
|
|
230
|
+
schema,
|
|
231
|
+
pointer: location,
|
|
232
|
+
})
|
|
243
233
|
}
|
|
244
234
|
for (const anchor of [schema.$anchor, schema.$dynamicAnchor]) {
|
|
245
235
|
if (typeof anchor === "string")
|
|
246
|
-
this.register(new URL(`#${anchor}`, scope).href, {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (isObject(schema[key]))
|
|
252
|
-
for (const child of Object.values(
|
|
253
|
-
schema[key] as Record<string, unknown>,
|
|
254
|
-
))
|
|
255
|
-
this.normalize(child, scope, expected, root)
|
|
256
|
-
}
|
|
257
|
-
for (const key of [...ARRAY_SCHEMAS, "items"]) {
|
|
258
|
-
const children: unknown = schema[key]
|
|
259
|
-
if (Array.isArray(children))
|
|
260
|
-
for (const child of children)
|
|
261
|
-
this.normalize(child, scope, expected, root)
|
|
262
|
-
else if (key === "items") this.normalize(children, scope, expected, root)
|
|
236
|
+
this.register(new URL(`#${anchor}`, scope).href, {
|
|
237
|
+
...root,
|
|
238
|
+
schema,
|
|
239
|
+
pointer: location,
|
|
240
|
+
})
|
|
263
241
|
}
|
|
242
|
+
for (const [path, child] of schemaChildren(schema))
|
|
243
|
+
this.normalize(child, scope, expected, root, location + path)
|
|
264
244
|
}
|
|
265
245
|
|
|
266
246
|
private async remote(uri: string): Promise<string> {
|
package/src/core/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type Mode =
|
|
1
|
+
export type Mode = string
|
|
2
2
|
export type Position = { line: number; character: number }
|
|
3
3
|
export type Range = { start: Position; end: Position }
|
|
4
4
|
export type Diagnostic = {
|
|
@@ -10,43 +10,70 @@ export type Diagnostic = {
|
|
|
10
10
|
range: Range
|
|
11
11
|
context?: { parent: number; label: string }
|
|
12
12
|
}
|
|
13
|
-
export type
|
|
13
|
+
export type ExtensionRequirement = {
|
|
14
|
+
schemaUri: string
|
|
15
|
+
schemaPointer: string
|
|
16
|
+
format: string
|
|
17
|
+
suggestedExtension?: string
|
|
18
|
+
}
|
|
19
|
+
export type Failure = {
|
|
20
|
+
code: string
|
|
21
|
+
message: string
|
|
22
|
+
file?: string
|
|
23
|
+
details?: ExtensionRequirement
|
|
24
|
+
}
|
|
14
25
|
export class CorrectlyError extends Error {
|
|
15
26
|
readonly code: string
|
|
16
|
-
|
|
27
|
+
readonly details?: ExtensionRequirement
|
|
28
|
+
constructor(code: string, message: string, details?: ExtensionRequirement) {
|
|
17
29
|
super(message)
|
|
18
30
|
this.name = "CorrectlyError"
|
|
19
31
|
this.code = code
|
|
32
|
+
if (details) this.details = details
|
|
20
33
|
}
|
|
21
34
|
}
|
|
35
|
+
export function isCorrectlyError(error: unknown): error is CorrectlyError {
|
|
36
|
+
return (
|
|
37
|
+
error instanceof Error &&
|
|
38
|
+
error.name === "CorrectlyError" &&
|
|
39
|
+
"code" in error &&
|
|
40
|
+
typeof error.code === "string"
|
|
41
|
+
)
|
|
42
|
+
}
|
|
22
43
|
export function failure(error: unknown, file?: string): Failure {
|
|
23
44
|
return {
|
|
24
|
-
code: error
|
|
45
|
+
code: isCorrectlyError(error) ? error.code : "execution",
|
|
25
46
|
message: error instanceof Error ? error.message : String(error),
|
|
26
47
|
...(file === undefined ? {} : { file }),
|
|
48
|
+
...(isCorrectlyError(error) && error.details
|
|
49
|
+
? { details: error.details }
|
|
50
|
+
: {}),
|
|
27
51
|
}
|
|
28
52
|
}
|
|
29
53
|
export type Association = {
|
|
30
54
|
index: number
|
|
31
55
|
name: string
|
|
32
56
|
schema: string | null
|
|
57
|
+
validator: string | null
|
|
33
58
|
mode: Mode
|
|
59
|
+
extensions?: string[]
|
|
34
60
|
}
|
|
35
61
|
export type FileResult = {
|
|
36
62
|
file: string
|
|
37
63
|
mode: Mode
|
|
38
|
-
coverage: "schema" | "syntax-only" | "excluded"
|
|
64
|
+
coverage: "schema" | "validated" | "syntax-only" | "excluded"
|
|
39
65
|
association: Association | null
|
|
40
66
|
diagnostics: Diagnostic[]
|
|
41
67
|
failures: Failure[]
|
|
42
68
|
}
|
|
43
69
|
export type Report = {
|
|
44
|
-
reportVersion:
|
|
70
|
+
reportVersion: 2
|
|
45
71
|
config: string | null
|
|
46
72
|
files: FileResult[]
|
|
47
73
|
failures: Failure[]
|
|
48
74
|
summary: {
|
|
49
75
|
checked: number
|
|
76
|
+
validated: number
|
|
50
77
|
schemaCovered: number
|
|
51
78
|
syntaxOnly: number
|
|
52
79
|
invalid: number
|
package/src/lsp/hints.ts
CHANGED
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
} from "vscode-json-languageservice"
|
|
7
7
|
import type { TextDocument } from "vscode-languageserver-textdocument"
|
|
8
8
|
import { fileURLToPath } from "node:url"
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
import type { ProjectSession } from "../runtime/session.ts"
|
|
10
11
|
import type { Engine } from "../core/engine.ts"
|
|
11
12
|
|
|
12
13
|
// Only the schema-selection accessor is hidden. The original AST (including
|
|
@@ -31,20 +32,19 @@ export class Hints {
|
|
|
31
32
|
document: TextDocument,
|
|
32
33
|
): Promise<LanguageService | undefined> {
|
|
33
34
|
const file = fileURLToPath(document.uri)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
const editor = await engine.editor(file)
|
|
36
|
+
if (!editor) return undefined
|
|
37
|
+
const key = String(editor.association)
|
|
38
|
+
|
|
38
39
|
let services = this.services.get(engine)
|
|
39
40
|
if (!services) {
|
|
40
41
|
services = new Map()
|
|
41
42
|
this.services.set(engine, services)
|
|
42
43
|
}
|
|
43
|
-
let service = services.get(
|
|
44
|
+
let service = services.get(key)
|
|
44
45
|
if (!service) {
|
|
45
46
|
service = getLanguageService({
|
|
46
|
-
schemaRequestService:
|
|
47
|
-
JSON.stringify((await engine.store.load(uri)).schema),
|
|
47
|
+
schemaRequestService: (uri) => editor.support.readSchema(uri),
|
|
48
48
|
workspaceContext: {
|
|
49
49
|
resolveRelativePath: (relative, resource) =>
|
|
50
50
|
new URL(relative, resource).href,
|
|
@@ -53,13 +53,18 @@ export class Hints {
|
|
|
53
53
|
// A per-association service prevents overlapping rules being combined.
|
|
54
54
|
service.configure({
|
|
55
55
|
validate: false,
|
|
56
|
-
schemas: [{ uri:
|
|
56
|
+
schemas: [{ uri: editor.support.uri, fileMatch: ["*"] }],
|
|
57
57
|
})
|
|
58
|
-
services.set(
|
|
58
|
+
services.set(key, service)
|
|
59
59
|
}
|
|
60
60
|
return service
|
|
61
61
|
}
|
|
62
|
-
async complete(
|
|
62
|
+
async complete(
|
|
63
|
+
engine: Engine | ProjectSession,
|
|
64
|
+
document: TextDocument,
|
|
65
|
+
position: Position,
|
|
66
|
+
) {
|
|
67
|
+
if (!("editor" in engine)) return engine.complete(document, position)
|
|
63
68
|
const service = await this.service(engine, document)
|
|
64
69
|
return service
|
|
65
70
|
? await service.doComplete(
|
|
@@ -69,7 +74,12 @@ export class Hints {
|
|
|
69
74
|
)
|
|
70
75
|
: { isIncomplete: false, items: [] }
|
|
71
76
|
}
|
|
72
|
-
async hover(
|
|
77
|
+
async hover(
|
|
78
|
+
engine: Engine | ProjectSession,
|
|
79
|
+
document: TextDocument,
|
|
80
|
+
position: Position,
|
|
81
|
+
) {
|
|
82
|
+
if (!("editor" in engine)) return engine.hover(document, position)
|
|
73
83
|
const service = await this.service(engine, document)
|
|
74
84
|
return service
|
|
75
85
|
? await service.doHover(
|
package/src/lsp/server.ts
CHANGED
|
@@ -92,7 +92,11 @@ export function startServer() {
|
|
|
92
92
|
?.dynamicRegistration ?? false
|
|
93
93
|
return {
|
|
94
94
|
capabilities: {
|
|
95
|
-
textDocumentSync:
|
|
95
|
+
textDocumentSync: {
|
|
96
|
+
openClose: true,
|
|
97
|
+
change: TextDocumentSyncKind.Incremental,
|
|
98
|
+
save: { includeText: false },
|
|
99
|
+
},
|
|
96
100
|
completionProvider: { triggerCharacters: ['"', ":"] },
|
|
97
101
|
hoverProvider: true,
|
|
98
102
|
workspace: {
|
|
@@ -106,7 +110,7 @@ export function startServer() {
|
|
|
106
110
|
if (dynamicWatch)
|
|
107
111
|
void connection.client
|
|
108
112
|
.register(DidChangeWatchedFilesNotification.type, {
|
|
109
|
-
watchers: [{ globPattern: "
|
|
113
|
+
watchers: [{ globPattern: "**/*" }],
|
|
110
114
|
})
|
|
111
115
|
.catch((error: unknown) => connection.console.error(String(error)))
|
|
112
116
|
connection.workspace.onDidChangeWorkspaceFolders((event) => {
|
|
@@ -171,6 +175,9 @@ export function startServer() {
|
|
|
171
175
|
if (document.uri.startsWith("file:") && workspace.close(document.uri))
|
|
172
176
|
scheduleAll()
|
|
173
177
|
})
|
|
178
|
+
documents.onDidSave(({ document }) => {
|
|
179
|
+
if (workspace.resourceChanged(document.uri)) refresh()
|
|
180
|
+
})
|
|
174
181
|
connection.onDidChangeWatchedFiles((params) => {
|
|
175
182
|
// Cache writes and unrelated JSON files must not trigger schema reloads.
|
|
176
183
|
if (params.changes.some((change) => workspace.resourceChanged(change.uri)))
|
package/src/lsp/workspace.ts
CHANGED
|
@@ -1,48 +1,36 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
2
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
3
3
|
import { TextDocument } from "vscode-languageserver-textdocument"
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
discoverConfig,
|
|
7
|
-
loadProject,
|
|
8
|
-
readText,
|
|
9
|
-
type ReadText,
|
|
10
|
-
} from "../core/config.ts"
|
|
11
|
-
import { Engine } from "../core/engine.ts"
|
|
4
|
+
import { contains, discoverConfig, CONFIG_NAME } from "../core/config.ts"
|
|
5
|
+
import { ProjectSession } from "../runtime/session.ts"
|
|
12
6
|
import { CorrectlyError, failure, type FileResult } from "../core/types.ts"
|
|
13
7
|
import { diagnostic } from "../core/parse.ts"
|
|
14
8
|
|
|
15
9
|
export class Workspace {
|
|
16
10
|
onSchema: ((uri: string) => void) | undefined
|
|
17
11
|
roots: string[]
|
|
18
|
-
private readonly read: ReadText
|
|
19
12
|
readonly documents = new Map<string, TextDocument>()
|
|
20
|
-
private readonly engines = new Map<string,
|
|
21
|
-
private controller = new AbortController()
|
|
13
|
+
private readonly engines = new Map<string, ProjectSession>()
|
|
22
14
|
generation = 0
|
|
23
|
-
constructor(roots: string[]
|
|
15
|
+
constructor(roots: string[]) {
|
|
24
16
|
this.roots = roots
|
|
25
|
-
this.read = read
|
|
26
17
|
}
|
|
27
18
|
|
|
28
19
|
invalidate() {
|
|
29
20
|
this.generation++
|
|
30
|
-
this.
|
|
31
|
-
this.controller = new AbortController()
|
|
21
|
+
for (const session of this.engines.values()) void session.dispose()
|
|
32
22
|
this.engines.clear()
|
|
33
23
|
}
|
|
34
24
|
|
|
35
25
|
open(document: TextDocument): boolean {
|
|
36
26
|
this.documents.set(document.uri, document)
|
|
37
27
|
const resource =
|
|
38
|
-
|
|
39
|
-
this.isSchema(document.uri)
|
|
28
|
+
this.isSchema(document.uri) && !this.moduleUris.has(document.uri)
|
|
40
29
|
if (resource) this.invalidate()
|
|
41
30
|
return resource
|
|
42
31
|
}
|
|
43
32
|
close(uri: string): boolean {
|
|
44
|
-
const resource =
|
|
45
|
-
uri.endsWith("/correctly.config.json") || this.isSchema(uri)
|
|
33
|
+
const resource = this.isSchema(uri) && !this.moduleUris.has(uri)
|
|
46
34
|
this.documents.delete(uri)
|
|
47
35
|
if (resource) this.invalidate()
|
|
48
36
|
return resource
|
|
@@ -51,15 +39,19 @@ export class Workspace {
|
|
|
51
39
|
private isSchema(uri: string): boolean {
|
|
52
40
|
return this.schemaUris.has(uri)
|
|
53
41
|
}
|
|
42
|
+
private readonly moduleUris = new Set<string>()
|
|
54
43
|
resourceChanged(uri: string): boolean {
|
|
55
|
-
return
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
44
|
+
return (
|
|
45
|
+
uri.endsWith(`/${CONFIG_NAME}`) ||
|
|
46
|
+
this.isSchema(uri) ||
|
|
47
|
+
this.moduleUris.has(uri) ||
|
|
48
|
+
/\/(?:package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$/.test(
|
|
49
|
+
uri,
|
|
50
|
+
)
|
|
51
|
+
)
|
|
60
52
|
}
|
|
61
53
|
|
|
62
|
-
async engineFor(uri: string): Promise<
|
|
54
|
+
async engineFor(uri: string): Promise<ProjectSession> {
|
|
63
55
|
const file = fileURLToPath(uri)
|
|
64
56
|
const root = this.roots
|
|
65
57
|
.filter((r) => contains(r, file))
|
|
@@ -69,36 +61,25 @@ export class Workspace {
|
|
|
69
61
|
"config",
|
|
70
62
|
`Document is outside the editor workspace: ${file}`,
|
|
71
63
|
)
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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)
|
|
64
|
+
const configPath = await discoverConfig(path.dirname(file), root)
|
|
65
|
+
let session = this.engines.get(configPath)
|
|
66
|
+
if (!session) {
|
|
67
|
+
session = new ProjectSession(configPath, {
|
|
68
|
+
buffers: [...this.documents].map(([uri, document]) => [
|
|
69
|
+
uri,
|
|
70
|
+
document.getText(),
|
|
71
|
+
]),
|
|
72
|
+
onDependency: (dependency, kind) => {
|
|
73
|
+
;(kind === "module" ? this.moduleUris : this.schemaUris).add(
|
|
74
|
+
dependency,
|
|
75
|
+
)
|
|
76
|
+
this.onSchema?.(dependency)
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
this.engines.set(configPath, session)
|
|
100
80
|
}
|
|
101
|
-
|
|
81
|
+
await session.ready
|
|
82
|
+
return session
|
|
102
83
|
}
|
|
103
84
|
|
|
104
85
|
async validate(document: TextDocument): Promise<FileResult> {
|
|
@@ -110,6 +91,20 @@ export class Workspace {
|
|
|
110
91
|
)
|
|
111
92
|
} catch (error) {
|
|
112
93
|
const problem = failure(error, fileURLToPath(document.uri))
|
|
94
|
+
if (
|
|
95
|
+
problem.code === "config" &&
|
|
96
|
+
problem.message.startsWith(`No ${CONFIG_NAME} found`) &&
|
|
97
|
+
!["json", "jsonc"].includes(document.languageId)
|
|
98
|
+
)
|
|
99
|
+
return {
|
|
100
|
+
file: fileURLToPath(document.uri),
|
|
101
|
+
mode: document.languageId,
|
|
102
|
+
association: null,
|
|
103
|
+
coverage: "excluded",
|
|
104
|
+
diagnostics: [],
|
|
105
|
+
failures: [],
|
|
106
|
+
}
|
|
107
|
+
|
|
113
108
|
return {
|
|
114
109
|
file: fileURLToPath(document.uri),
|
|
115
110
|
mode: document.languageId === "jsonc" ? "jsonc" : "json",
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads"
|
|
2
|
+
import type { TextDocument } from "vscode-languageserver-textdocument"
|
|
3
|
+
import type { CompletionList, Hover } from "vscode-json-languageservice"
|
|
4
|
+
import {
|
|
5
|
+
CorrectlyError,
|
|
6
|
+
type Failure,
|
|
7
|
+
type FileResult,
|
|
8
|
+
type Position,
|
|
9
|
+
type Report,
|
|
10
|
+
} from "../core/types.ts"
|
|
11
|
+
|
|
12
|
+
export type SessionOptions = {
|
|
13
|
+
offline?: boolean
|
|
14
|
+
buffers?: [string, string][]
|
|
15
|
+
onDependency?: (uri: string, kind: "module" | "resource") => void
|
|
16
|
+
}
|
|
17
|
+
export type CheckResult = { report: Report; sources: [string, string][] }
|
|
18
|
+
/** Functions stay in this worker, which is replaced to invalidate native module caches. */
|
|
19
|
+
export class ProjectSession {
|
|
20
|
+
private readonly worker: Worker
|
|
21
|
+
private readonly pending = new Map<
|
|
22
|
+
number,
|
|
23
|
+
{ resolve(value: unknown): void; reject(error: Error): void }
|
|
24
|
+
>()
|
|
25
|
+
private next = 0
|
|
26
|
+
private closed = false
|
|
27
|
+
private disposal: Promise<void> | undefined
|
|
28
|
+
readonly ready: Promise<void>
|
|
29
|
+
constructor(configPath: string, options: SessionOptions = {}) {
|
|
30
|
+
const source = import.meta.url.endsWith(".ts")
|
|
31
|
+
this.worker = new Worker(
|
|
32
|
+
new URL(source ? "./worker.ts" : "./worker.mjs", import.meta.url),
|
|
33
|
+
{ stdout: true, stderr: true },
|
|
34
|
+
)
|
|
35
|
+
this.worker.stdout?.on("data", (chunk: Buffer) =>
|
|
36
|
+
process.stderr.write(chunk),
|
|
37
|
+
)
|
|
38
|
+
this.worker.stderr?.on("data", (chunk: Buffer) =>
|
|
39
|
+
process.stderr.write(chunk),
|
|
40
|
+
)
|
|
41
|
+
this.worker.on(
|
|
42
|
+
"message",
|
|
43
|
+
(message: {
|
|
44
|
+
id?: number
|
|
45
|
+
value?: unknown
|
|
46
|
+
error?: Failure
|
|
47
|
+
dependency?: { uri: string; kind: "module" | "resource" }
|
|
48
|
+
}) => {
|
|
49
|
+
if (message.dependency) {
|
|
50
|
+
options.onDependency?.(
|
|
51
|
+
message.dependency.uri,
|
|
52
|
+
message.dependency.kind,
|
|
53
|
+
)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const pending = this.pending.get(message.id!)
|
|
57
|
+
if (!pending) return
|
|
58
|
+
this.pending.delete(message.id!)
|
|
59
|
+
if (message.error)
|
|
60
|
+
pending.reject(
|
|
61
|
+
new CorrectlyError(
|
|
62
|
+
message.error.code,
|
|
63
|
+
message.error.message,
|
|
64
|
+
message.error.details,
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
else pending.resolve(message.value)
|
|
68
|
+
if (!this.pending.size) this.worker.unref()
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
this.worker.on("error", (error) =>
|
|
72
|
+
this.fail(
|
|
73
|
+
new CorrectlyError(
|
|
74
|
+
"config",
|
|
75
|
+
`Project worker failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
this.worker.on("exit", (code) =>
|
|
80
|
+
this.fail(
|
|
81
|
+
new CorrectlyError("config", `Project worker exited (${code})`),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
this.ready = this.request<void>("init", {
|
|
85
|
+
configPath,
|
|
86
|
+
offline: options.offline,
|
|
87
|
+
buffers: options.buffers ?? [],
|
|
88
|
+
})
|
|
89
|
+
// Avoid an unhandled rejection if a project is invalidated before its consumer awaits it.
|
|
90
|
+
void this.ready.catch(() => {})
|
|
91
|
+
}
|
|
92
|
+
private fail(error: Error) {
|
|
93
|
+
this.closed = true
|
|
94
|
+
for (const pending of this.pending.values()) pending.reject(error)
|
|
95
|
+
this.pending.clear()
|
|
96
|
+
}
|
|
97
|
+
private request<T>(method: string, params: unknown): Promise<T> {
|
|
98
|
+
if (this.closed)
|
|
99
|
+
return Promise.reject(
|
|
100
|
+
new CorrectlyError("execution", "Project worker has been closed"),
|
|
101
|
+
)
|
|
102
|
+
const id = this.next++
|
|
103
|
+
this.worker.ref()
|
|
104
|
+
return new Promise<T>((resolve, reject) => {
|
|
105
|
+
this.pending.set(id, { resolve: (value) => resolve(value as T), reject })
|
|
106
|
+
this.worker.postMessage({ id, method, params })
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
async validate(file: string, text: string): Promise<FileResult> {
|
|
110
|
+
await this.ready
|
|
111
|
+
return this.request("validate", { file, text })
|
|
112
|
+
}
|
|
113
|
+
async complete(
|
|
114
|
+
document: TextDocument,
|
|
115
|
+
position: Position,
|
|
116
|
+
): Promise<CompletionList | null> {
|
|
117
|
+
await this.ready
|
|
118
|
+
return this.request("complete", {
|
|
119
|
+
uri: document.uri,
|
|
120
|
+
text: document.getText(),
|
|
121
|
+
language: document.languageId,
|
|
122
|
+
version: document.version,
|
|
123
|
+
position,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
async hover(
|
|
127
|
+
document: TextDocument,
|
|
128
|
+
position: Position,
|
|
129
|
+
): Promise<Hover | null> {
|
|
130
|
+
await this.ready
|
|
131
|
+
return this.request("hover", {
|
|
132
|
+
uri: document.uri,
|
|
133
|
+
text: document.getText(),
|
|
134
|
+
language: document.languageId,
|
|
135
|
+
version: document.version,
|
|
136
|
+
position,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
async check(params: {
|
|
140
|
+
cwd: string
|
|
141
|
+
files?: string[]
|
|
142
|
+
sources: boolean
|
|
143
|
+
}): Promise<CheckResult> {
|
|
144
|
+
await this.ready
|
|
145
|
+
return this.request("check", params)
|
|
146
|
+
}
|
|
147
|
+
dispose(): Promise<void> {
|
|
148
|
+
return (this.disposal ??= (async () => {
|
|
149
|
+
const finished = this.closed
|
|
150
|
+
? Promise.resolve()
|
|
151
|
+
: this.request("dispose", {}).catch(() => {})
|
|
152
|
+
this.closed = true
|
|
153
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
154
|
+
try {
|
|
155
|
+
await Promise.race([
|
|
156
|
+
finished,
|
|
157
|
+
new Promise<void>((resolve) => {
|
|
158
|
+
timer = setTimeout(resolve, 250)
|
|
159
|
+
}),
|
|
160
|
+
])
|
|
161
|
+
} finally {
|
|
162
|
+
clearTimeout(timer)
|
|
163
|
+
this.fail(
|
|
164
|
+
new CorrectlyError(
|
|
165
|
+
"execution",
|
|
166
|
+
"Project configuration was invalidated",
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
await this.worker.terminate()
|
|
170
|
+
}
|
|
171
|
+
})())
|
|
172
|
+
}
|
|
173
|
+
}
|