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.
Files changed (68) hide show
  1. package/AGENTS.md +6 -2
  2. package/README.md +21 -15
  3. package/dist/ajv.d.mts +11 -0
  4. package/dist/ajv.d.mts.map +1 -0
  5. package/dist/ajv.mjs +221 -0
  6. package/dist/ajv.mjs.map +1 -0
  7. package/dist/check-BI7U3ZLc.mjs +88 -0
  8. package/dist/check-BI7U3ZLc.mjs.map +1 -0
  9. package/dist/cli.mjs +14 -62
  10. package/dist/cli.mjs.map +1 -1
  11. package/dist/config-Bx1-XXeo.d.mts +189 -0
  12. package/dist/config-Bx1-XXeo.d.mts.map +1 -0
  13. package/dist/config-Cb6o4YnJ.mjs +348 -0
  14. package/dist/config-Cb6o4YnJ.mjs.map +1 -0
  15. package/dist/core.d.mts +27 -125
  16. package/dist/core.d.mts.map +1 -1
  17. package/dist/core.mjs +5 -2
  18. package/dist/diagnostics-BqD1WOy4.mjs +70 -0
  19. package/dist/diagnostics-BqD1WOy4.mjs.map +1 -0
  20. package/dist/engine-VST0hoSk.mjs +111 -0
  21. package/dist/engine-VST0hoSk.mjs.map +1 -0
  22. package/dist/extensions-ClclWslY.d.mts +11 -0
  23. package/dist/extensions-ClclWslY.d.mts.map +1 -0
  24. package/dist/hints-HvSIhuMM.mjs +57 -0
  25. package/dist/hints-HvSIhuMM.mjs.map +1 -0
  26. package/dist/lsp.mjs +40 -91
  27. package/dist/lsp.mjs.map +1 -1
  28. package/dist/renovate.d.mts +5 -0
  29. package/dist/renovate.d.mts.map +1 -0
  30. package/dist/renovate.mjs +11 -0
  31. package/dist/renovate.mjs.map +1 -0
  32. package/dist/schemas-UxCtIdo2.mjs +265 -0
  33. package/dist/schemas-UxCtIdo2.mjs.map +1 -0
  34. package/dist/session-DCFav56x.mjs +113 -0
  35. package/dist/session-DCFav56x.mjs.map +1 -0
  36. package/dist/worker.mjs +80 -0
  37. package/dist/worker.mjs.map +1 -0
  38. package/docs/guide.md +166 -34
  39. package/examples/basic/correctly.config.ts +13 -0
  40. package/package.json +69 -60
  41. package/src/cli/check.ts +41 -11
  42. package/src/cli/main.ts +3 -2
  43. package/src/cli/readable.ts +24 -5
  44. package/src/cli/vsix.ts +1 -0
  45. package/src/core/adapters.ts +54 -0
  46. package/src/core/config.ts +201 -64
  47. package/src/core/engine.ts +118 -218
  48. package/src/core/extensions.ts +144 -0
  49. package/src/core/index.ts +3 -0
  50. package/src/core/parse.ts +26 -2
  51. package/src/core/parsers.ts +22 -0
  52. package/src/core/schema-walk.ts +43 -0
  53. package/src/core/schemas.ts +21 -41
  54. package/src/core/types.ts +33 -6
  55. package/src/extensions/renovate.ts +4 -0
  56. package/src/lsp/hints.ts +22 -12
  57. package/src/lsp/server.ts +9 -2
  58. package/src/lsp/workspace.ts +49 -54
  59. package/src/runtime/session.ts +173 -0
  60. package/src/runtime/worker.ts +99 -0
  61. package/src/validators/ajv.ts +257 -0
  62. package/src/vscode/README.md +1 -1
  63. package/src/vscode/extension.ts +2 -4
  64. package/src/vscode/package.json +9 -2
  65. package/dist/engine-BgWBnb1A.mjs +0 -746
  66. package/dist/engine-BgWBnb1A.mjs.map +0 -1
  67. package/examples/basic/correctly.config.json +0 -10
  68. package/src/core/config.schema.json +0 -47
@@ -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(project: Project, options: StoreOptions = {}) {
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(/#$/, ""), { ...root, schema })
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, { ...root, schema })
247
- }
248
- for (const key of SINGLE_SCHEMAS)
249
- this.normalize(schema[key], scope, expected, root)
250
- for (const key of MAP_SCHEMAS) {
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 = "json" | "jsonc"
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 Failure = { code: string; message: string; file?: string }
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
- constructor(code: string, message: string) {
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 instanceof CorrectlyError ? error.code : "execution",
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: 1
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
@@ -0,0 +1,4 @@
1
+ import type { AjvExtension } from "../core/extensions.ts"
2
+ export function renovate(): AjvExtension {
3
+ return { id: "renovate", annotations: ["x-renovate-version"] }
4
+ }
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
- import { associationFor, isIncluded } from "../core/config.ts"
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
- if (!isIncluded(engine.project, file)) return undefined
35
- const association = associationFor(engine.project, file)
36
- if (!association?.schema) return undefined
37
- await engine.validator(association.schema)
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(association.schema)
44
+ let service = services.get(key)
44
45
  if (!service) {
45
46
  service = getLanguageService({
46
- schemaRequestService: async (uri) =>
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: association.schema, fileMatch: ["*"] }],
56
+ schemas: [{ uri: editor.support.uri, fileMatch: ["*"] }],
57
57
  })
58
- services.set(association.schema, service)
58
+ services.set(key, service)
59
59
  }
60
60
  return service
61
61
  }
62
- async complete(engine: Engine, document: TextDocument, position: Position) {
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(engine: Engine, document: TextDocument, position: Position) {
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: TextDocumentSyncKind.Incremental,
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: "**/*.{json,jsonc}" }],
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)))
@@ -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
- contains,
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, Promise<Engine>>()
21
- private controller = new AbortController()
13
+ private readonly engines = new Map<string, ProjectSession>()
22
14
  generation = 0
23
- constructor(roots: string[], read: ReadText = readText) {
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.controller.abort()
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
- path.basename(fileURLToPath(document.uri)) === "correctly.config.json" ||
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 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)
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<Engine> {
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 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)
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
- return promise
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
+ }