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.
- package/AGENTS.md +8 -0
- package/LICENSE +373 -0
- package/README.md +24 -0
- package/dist/cli.mjs +294 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/core.d.mts +183 -0
- package/dist/core.d.mts.map +1 -0
- package/dist/core.mjs +2 -0
- package/dist/engine-BgWBnb1A.mjs +746 -0
- package/dist/engine-BgWBnb1A.mjs.map +1 -0
- package/dist/lsp.mjs +315 -0
- package/dist/lsp.mjs.map +1 -0
- package/docs/guide.md +114 -0
- package/examples/basic/correctly.config.json +10 -0
- package/examples/basic/project.json +1 -0
- package/examples/basic/project.jsonc +5 -0
- package/examples/basic/project.schema.json +10 -0
- package/package.json +61 -0
- package/src/cli/check.ts +83 -0
- package/src/cli/main.ts +86 -0
- package/src/cli/readable.ts +295 -0
- package/src/cli/vsix.ts +86 -0
- package/src/core/config.schema.json +47 -0
- package/src/core/config.ts +200 -0
- package/src/core/diagnostics.ts +114 -0
- package/src/core/engine.ts +247 -0
- package/src/core/index.ts +10 -0
- package/src/core/parse.ts +168 -0
- package/src/core/schemas.ts +379 -0
- package/src/core/types.ts +56 -0
- package/src/lsp/hints.ts +82 -0
- package/src/lsp/server.ts +265 -0
- package/src/lsp/workspace.ts +132 -0
- package/src/vscode/README.md +7 -0
- package/src/vscode/extension.ts +33 -0
- package/src/vscode/package.json +43 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findNodeAtLocation,
|
|
3
|
+
getNodePath,
|
|
4
|
+
getNodeValue,
|
|
5
|
+
parseTree,
|
|
6
|
+
printParseErrorCode,
|
|
7
|
+
type Node,
|
|
8
|
+
type ParseError,
|
|
9
|
+
} from "jsonc-parser"
|
|
10
|
+
import type { Diagnostic, Mode, Position } from "./types.ts"
|
|
11
|
+
|
|
12
|
+
export type SourceSpan = { offset: number; length: number }
|
|
13
|
+
export type ParsedDocument = {
|
|
14
|
+
value: unknown
|
|
15
|
+
diagnostics: Diagnostic[]
|
|
16
|
+
locate: (pointer: string, key?: boolean) => SourceSpan | undefined
|
|
17
|
+
}
|
|
18
|
+
export type ParsedJsonDocument = ParsedDocument & { root: Node | undefined }
|
|
19
|
+
|
|
20
|
+
export function pointer(parts: readonly (string | number)[]): string {
|
|
21
|
+
return parts
|
|
22
|
+
.map((p) => `/${String(p).replace(/~/g, "~0").replace(/\//g, "~1")}`)
|
|
23
|
+
.join("")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function positionAt(text: string, offset: number): Position {
|
|
27
|
+
const end = Math.min(Math.max(0, offset), text.length)
|
|
28
|
+
let line = 0
|
|
29
|
+
let start = 0
|
|
30
|
+
for (let i = 0; i < end; i++) {
|
|
31
|
+
if (text[i] === "\r") {
|
|
32
|
+
if (text[i + 1] === "\n" && i + 1 < end) i++
|
|
33
|
+
line++
|
|
34
|
+
start = i + 1
|
|
35
|
+
} else if (text[i] === "\n") {
|
|
36
|
+
line++
|
|
37
|
+
start = i + 1
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { line, character: end - start }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function diagnostic(
|
|
44
|
+
text: string,
|
|
45
|
+
code: string,
|
|
46
|
+
message: string,
|
|
47
|
+
jsonPointer = "",
|
|
48
|
+
offset = 0,
|
|
49
|
+
length = 1,
|
|
50
|
+
): Diagnostic {
|
|
51
|
+
const start = Math.min(offset, text.length)
|
|
52
|
+
const size = Math.min(Math.max(1, length), text.length - start)
|
|
53
|
+
return {
|
|
54
|
+
code,
|
|
55
|
+
message,
|
|
56
|
+
pointer: jsonPointer,
|
|
57
|
+
offset: start,
|
|
58
|
+
length: size,
|
|
59
|
+
range: {
|
|
60
|
+
start: positionAt(text, start),
|
|
61
|
+
end: positionAt(text, start + size),
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseDocument(text: string, mode: Mode): ParsedJsonDocument {
|
|
67
|
+
const errors: ParseError[] = []
|
|
68
|
+
const root = parseTree(text, errors, {
|
|
69
|
+
disallowComments: mode === "json",
|
|
70
|
+
allowTrailingComma: mode === "jsonc",
|
|
71
|
+
allowEmptyContent: false,
|
|
72
|
+
})
|
|
73
|
+
const diagnostics = errors.map((e) =>
|
|
74
|
+
diagnostic(
|
|
75
|
+
text,
|
|
76
|
+
"syntax",
|
|
77
|
+
printParseErrorCode(e.error),
|
|
78
|
+
"",
|
|
79
|
+
e.offset,
|
|
80
|
+
e.length,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
function visit(node: Node) {
|
|
84
|
+
if (node.type === "object") {
|
|
85
|
+
const keys = new Set<string>()
|
|
86
|
+
for (const property of node.children ?? []) {
|
|
87
|
+
const key = property.children?.[0]
|
|
88
|
+
if (!key || typeof key.value !== "string") continue
|
|
89
|
+
if (keys.has(key.value)) {
|
|
90
|
+
diagnostics.push(
|
|
91
|
+
diagnostic(
|
|
92
|
+
text,
|
|
93
|
+
"duplicate-key",
|
|
94
|
+
`Duplicate key ${JSON.stringify(key.value)}`,
|
|
95
|
+
pointer([...getNodePath(node), key.value]),
|
|
96
|
+
key.offset,
|
|
97
|
+
key.length,
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
keys.add(key.value)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (node.type === "number" && !Number.isFinite(node.value)) {
|
|
105
|
+
diagnostics.push(
|
|
106
|
+
diagnostic(
|
|
107
|
+
text,
|
|
108
|
+
"syntax",
|
|
109
|
+
"Number exceeds the supported finite range",
|
|
110
|
+
pointer(getNodePath(node)),
|
|
111
|
+
node.offset,
|
|
112
|
+
node.length,
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
for (const child of node.children ?? []) visit(child)
|
|
117
|
+
}
|
|
118
|
+
if (root) visit(root)
|
|
119
|
+
// jsonc-parser's object values have null prototypes. Ajv's deep equality
|
|
120
|
+
// expects ordinary JSON objects; Object.fromEntries safely preserves __proto__.
|
|
121
|
+
function value(node: Node): unknown {
|
|
122
|
+
if (node.type === "object")
|
|
123
|
+
return Object.fromEntries(
|
|
124
|
+
(node.children ?? []).flatMap((property) => {
|
|
125
|
+
const key = property.children?.[0]
|
|
126
|
+
const child = property.children?.[1]
|
|
127
|
+
return key && child ? [[String(key.value), value(child)]] : []
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
if (node.type === "array") return (node.children ?? []).map(value)
|
|
131
|
+
return getNodeValue(node) as unknown
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
root,
|
|
135
|
+
value: root ? value(root) : undefined,
|
|
136
|
+
diagnostics,
|
|
137
|
+
locate: (jsonPointer, key = false) => {
|
|
138
|
+
const node = locate(root, jsonPointer, key)
|
|
139
|
+
return node ? { offset: node.offset, length: node.length } : undefined
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function locate(
|
|
145
|
+
root: Node | undefined,
|
|
146
|
+
jsonPointer: string,
|
|
147
|
+
key = false,
|
|
148
|
+
): Node | undefined {
|
|
149
|
+
if (!root) return undefined
|
|
150
|
+
const parts =
|
|
151
|
+
jsonPointer === ""
|
|
152
|
+
? []
|
|
153
|
+
: jsonPointer
|
|
154
|
+
.slice(1)
|
|
155
|
+
.split("/")
|
|
156
|
+
.map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"))
|
|
157
|
+
let current = root
|
|
158
|
+
for (const part of parts) {
|
|
159
|
+
const next = findNodeAtLocation(current, [
|
|
160
|
+
current.type === "array" ? Number(part) : part,
|
|
161
|
+
])
|
|
162
|
+
if (!next) break
|
|
163
|
+
current = next
|
|
164
|
+
}
|
|
165
|
+
return key && current.parent?.type === "property"
|
|
166
|
+
? (current.parent.children?.[0] ?? current)
|
|
167
|
+
: current
|
|
168
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
import type { AnySchema, SchemaObject } from "ajv"
|
|
6
|
+
import { parseDocument } from "./parse.ts"
|
|
7
|
+
import { readText, type Project, type ReadText } from "./config.ts"
|
|
8
|
+
import { CorrectlyError } from "./types.ts"
|
|
9
|
+
|
|
10
|
+
export type Dialect = "draft7" | "2020-12"
|
|
11
|
+
export const DIALECTS = {
|
|
12
|
+
draft7: "http://json-schema.org/draft-07/schema#",
|
|
13
|
+
"2020-12": "https://json-schema.org/draft/2020-12/schema",
|
|
14
|
+
} as const
|
|
15
|
+
const VOCABULARIES = new Set(
|
|
16
|
+
[
|
|
17
|
+
"core",
|
|
18
|
+
"applicator",
|
|
19
|
+
"unevaluated",
|
|
20
|
+
"validation",
|
|
21
|
+
"meta-data",
|
|
22
|
+
"format-annotation",
|
|
23
|
+
"content",
|
|
24
|
+
].map((name) => `https://json-schema.org/draft/2020-12/vocab/${name}`),
|
|
25
|
+
)
|
|
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
|
+
|
|
49
|
+
function isObject(value: unknown): value is SchemaObject {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
51
|
+
}
|
|
52
|
+
function dialect(value: unknown, inherited: Dialect): Dialect {
|
|
53
|
+
if (value === undefined) return inherited
|
|
54
|
+
if (typeof value !== "string")
|
|
55
|
+
throw new CorrectlyError("schema", "Schema $schema must be a string")
|
|
56
|
+
const id = value.replace(/#$/, "")
|
|
57
|
+
if (
|
|
58
|
+
id === "http://json-schema.org/draft-07/schema" ||
|
|
59
|
+
id === "https://json-schema.org/draft-07/schema"
|
|
60
|
+
)
|
|
61
|
+
return "draft7"
|
|
62
|
+
if (id === DIALECTS["2020-12"]) return "2020-12"
|
|
63
|
+
throw new CorrectlyError(
|
|
64
|
+
"unsupported-dialect",
|
|
65
|
+
`Unsupported schema dialect: ${value}; supported: draft 7, draft 2020-12`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type SchemaResource = {
|
|
70
|
+
uri: string
|
|
71
|
+
schema: AnySchema
|
|
72
|
+
dialect: Dialect
|
|
73
|
+
}
|
|
74
|
+
type CacheEntry = {
|
|
75
|
+
uri: string
|
|
76
|
+
text: string
|
|
77
|
+
etag?: string
|
|
78
|
+
lastModified?: string
|
|
79
|
+
}
|
|
80
|
+
export type StoreOptions = {
|
|
81
|
+
offline?: boolean
|
|
82
|
+
read?: ReadText
|
|
83
|
+
fetch?: typeof fetch
|
|
84
|
+
signal?: AbortSignal
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class SchemaStore {
|
|
88
|
+
private readonly options: StoreOptions
|
|
89
|
+
readonly resources = new Map<string, SchemaResource>()
|
|
90
|
+
private readonly pending = new Map<string, Promise<SchemaResource>>()
|
|
91
|
+
private readonly cacheDir: string
|
|
92
|
+
private readonly offline: boolean
|
|
93
|
+
private readonly timeoutMs: number
|
|
94
|
+
private readonly maxBytes: number
|
|
95
|
+
private readonly maxRequests: number
|
|
96
|
+
private readonly read: ReadText
|
|
97
|
+
private readonly request: typeof fetch
|
|
98
|
+
private requests = 0
|
|
99
|
+
constructor(project: Project, options: StoreOptions = {}) {
|
|
100
|
+
this.options = options
|
|
101
|
+
const remote = project.config.remote
|
|
102
|
+
this.cacheDir = path.resolve(
|
|
103
|
+
project.root,
|
|
104
|
+
remote?.cacheDir ?? ".correctly-cache",
|
|
105
|
+
)
|
|
106
|
+
this.offline = options.offline ?? remote?.offline ?? false
|
|
107
|
+
this.timeoutMs = remote?.timeoutMs ?? 5000
|
|
108
|
+
this.maxBytes = remote?.maxBytes ?? 2 * 1024 * 1024
|
|
109
|
+
this.maxRequests = remote?.maxRequests ?? 64
|
|
110
|
+
this.read = options.read ?? readText
|
|
111
|
+
this.request = options.fetch ?? fetch
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async load(
|
|
115
|
+
value: string,
|
|
116
|
+
inherited: Dialect = "draft7",
|
|
117
|
+
): Promise<SchemaResource> {
|
|
118
|
+
this.options.signal?.throwIfAborted()
|
|
119
|
+
const url = new URL(value)
|
|
120
|
+
url.hash = ""
|
|
121
|
+
const uri = url.href
|
|
122
|
+
const known = this.resources.get(uri)
|
|
123
|
+
if (known) return known
|
|
124
|
+
let promise = this.pending.get(uri)
|
|
125
|
+
if (!promise) {
|
|
126
|
+
promise = this.loadResource(uri, inherited)
|
|
127
|
+
this.pending.set(uri, promise)
|
|
128
|
+
}
|
|
129
|
+
return promise
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async loadResource(
|
|
133
|
+
uri: string,
|
|
134
|
+
inherited: Dialect,
|
|
135
|
+
): Promise<SchemaResource> {
|
|
136
|
+
if (++this.requests > this.maxRequests)
|
|
137
|
+
throw new CorrectlyError(
|
|
138
|
+
"schema-limit",
|
|
139
|
+
`Schema resource limit (${this.maxRequests}) exceeded`,
|
|
140
|
+
)
|
|
141
|
+
let text: string
|
|
142
|
+
const url = new URL(uri)
|
|
143
|
+
try {
|
|
144
|
+
if (url.protocol === "file:") text = await this.read(uri)
|
|
145
|
+
else if (url.protocol === "https:" || url.protocol === "http:")
|
|
146
|
+
text = await this.remote(uri)
|
|
147
|
+
else
|
|
148
|
+
throw new CorrectlyError(
|
|
149
|
+
"schema",
|
|
150
|
+
`Unsupported schema protocol: ${url.protocol}`,
|
|
151
|
+
)
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof CorrectlyError) throw error
|
|
154
|
+
throw new CorrectlyError(
|
|
155
|
+
"schema-load",
|
|
156
|
+
`Cannot load schema ${uri}: ${error instanceof Error ? error.message : String(error)}`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
this.options.signal?.throwIfAborted()
|
|
160
|
+
if (Buffer.byteLength(text) > this.maxBytes)
|
|
161
|
+
throw new CorrectlyError(
|
|
162
|
+
"schema-limit",
|
|
163
|
+
`Schema ${uri} exceeds ${this.maxBytes} bytes`,
|
|
164
|
+
)
|
|
165
|
+
const parsed = parseDocument(text, "json")
|
|
166
|
+
if (parsed.diagnostics.length) {
|
|
167
|
+
const d = parsed.diagnostics[0]!
|
|
168
|
+
throw new CorrectlyError(
|
|
169
|
+
"schema",
|
|
170
|
+
`Invalid schema JSON at ${uri}:${d.range.start.line + 1}:${d.range.start.character + 1}: ${d.message}`,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
if (typeof parsed.value !== "boolean" && !isObject(parsed.value))
|
|
174
|
+
throw new CorrectlyError(
|
|
175
|
+
"schema",
|
|
176
|
+
`Schema must be an object or boolean: ${uri}`,
|
|
177
|
+
)
|
|
178
|
+
const rootDialect = dialect(
|
|
179
|
+
isObject(parsed.value) ? parsed.value.$schema : undefined,
|
|
180
|
+
inherited,
|
|
181
|
+
)
|
|
182
|
+
const schema = parsed.value as AnySchema
|
|
183
|
+
const resource: SchemaResource = { uri, schema, dialect: rootDialect }
|
|
184
|
+
// Normalize schema identifiers only, never document data or annotation values.
|
|
185
|
+
this.normalize(schema, uri, rootDialect, resource)
|
|
186
|
+
if (isObject(schema) && schema.$id === undefined) schema.$id = uri
|
|
187
|
+
this.register(uri, resource)
|
|
188
|
+
return resource
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private register(uri: string, resource: SchemaResource) {
|
|
192
|
+
const previous = this.resources.get(uri)
|
|
193
|
+
if (previous && previous.schema !== resource.schema)
|
|
194
|
+
throw new CorrectlyError("schema", `Duplicate schema identifier: ${uri}`)
|
|
195
|
+
this.resources.set(uri, resource)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private normalize(
|
|
199
|
+
schema: unknown,
|
|
200
|
+
base: string,
|
|
201
|
+
expected: Dialect,
|
|
202
|
+
root: SchemaResource,
|
|
203
|
+
) {
|
|
204
|
+
if (!isObject(schema)) return
|
|
205
|
+
const ownDialect = dialect(schema.$schema, expected)
|
|
206
|
+
if (ownDialect !== expected)
|
|
207
|
+
throw new CorrectlyError(
|
|
208
|
+
"unsupported-dialect",
|
|
209
|
+
`Mixed schema dialects at ${base} are not supported`,
|
|
210
|
+
)
|
|
211
|
+
if (schema.$schema !== undefined) schema.$schema = DIALECTS[ownDialect]
|
|
212
|
+
if (schema.$async !== undefined)
|
|
213
|
+
throw new CorrectlyError(
|
|
214
|
+
"schema",
|
|
215
|
+
`Asynchronous data validation is not supported: ${base}`,
|
|
216
|
+
)
|
|
217
|
+
if (schema.$vocabulary !== undefined) {
|
|
218
|
+
if (!isObject(schema.$vocabulary))
|
|
219
|
+
throw new CorrectlyError(
|
|
220
|
+
"schema",
|
|
221
|
+
`$vocabulary must be an object: ${base}`,
|
|
222
|
+
)
|
|
223
|
+
for (const [vocabulary, required] of Object.entries(schema.$vocabulary)) {
|
|
224
|
+
if (typeof required !== "boolean")
|
|
225
|
+
throw new CorrectlyError(
|
|
226
|
+
"schema",
|
|
227
|
+
`$vocabulary values must be boolean: ${base}`,
|
|
228
|
+
)
|
|
229
|
+
if (required && !VOCABULARIES.has(vocabulary))
|
|
230
|
+
throw new CorrectlyError(
|
|
231
|
+
"unsupported-vocabulary",
|
|
232
|
+
`Unsupported required vocabulary: ${vocabulary}`,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
let scope = base
|
|
237
|
+
if (schema.$id !== undefined) {
|
|
238
|
+
if (typeof schema.$id !== "string")
|
|
239
|
+
throw new CorrectlyError("schema", `$id must be a string: ${base}`)
|
|
240
|
+
scope = new URL(schema.$id, base).href
|
|
241
|
+
schema.$id = scope
|
|
242
|
+
this.register(scope.replace(/#$/, ""), { ...root, schema })
|
|
243
|
+
}
|
|
244
|
+
for (const anchor of [schema.$anchor, schema.$dynamicAnchor]) {
|
|
245
|
+
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)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private async remote(uri: string): Promise<string> {
|
|
267
|
+
const cacheFile = path.join(
|
|
268
|
+
this.cacheDir,
|
|
269
|
+
`${createHash("sha256").update(uri).digest("hex")}.json`,
|
|
270
|
+
)
|
|
271
|
+
let cached: CacheEntry | undefined
|
|
272
|
+
try {
|
|
273
|
+
const raw = await readFile(cacheFile, "utf8")
|
|
274
|
+
const entry: unknown = JSON.parse(raw)
|
|
275
|
+
if (
|
|
276
|
+
!isObject(entry) ||
|
|
277
|
+
entry.uri !== uri ||
|
|
278
|
+
typeof entry.text !== "string"
|
|
279
|
+
)
|
|
280
|
+
throw new Error("invalid cache entry")
|
|
281
|
+
cached = entry as CacheEntry
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
284
|
+
throw new CorrectlyError(
|
|
285
|
+
"schema-cache",
|
|
286
|
+
`Cannot read schema cache ${cacheFile}: ${String(error)}`,
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
if (this.offline) {
|
|
290
|
+
if (!cached)
|
|
291
|
+
throw new CorrectlyError(
|
|
292
|
+
"offline-miss",
|
|
293
|
+
`Schema is not cached in offline mode: ${uri}`,
|
|
294
|
+
)
|
|
295
|
+
return cached.text
|
|
296
|
+
}
|
|
297
|
+
const timeout = AbortSignal.timeout(this.timeoutMs)
|
|
298
|
+
const signal = this.options.signal
|
|
299
|
+
? AbortSignal.any([this.options.signal, timeout])
|
|
300
|
+
: timeout
|
|
301
|
+
const headers: Record<string, string> = {}
|
|
302
|
+
if (cached?.etag) headers["If-None-Match"] = cached.etag
|
|
303
|
+
if (cached?.lastModified) headers["If-Modified-Since"] = cached.lastModified
|
|
304
|
+
let current = uri
|
|
305
|
+
for (let redirects = 0; redirects <= 5; redirects++) {
|
|
306
|
+
const response = await this.request(current, {
|
|
307
|
+
signal,
|
|
308
|
+
headers,
|
|
309
|
+
redirect: "manual",
|
|
310
|
+
})
|
|
311
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
312
|
+
await response.body?.cancel()
|
|
313
|
+
const location = response.headers.get("location")
|
|
314
|
+
if (!location)
|
|
315
|
+
throw new CorrectlyError(
|
|
316
|
+
"schema-load",
|
|
317
|
+
`Redirect without location: ${current}`,
|
|
318
|
+
)
|
|
319
|
+
current = new URL(location, current).href
|
|
320
|
+
if (!["http:", "https:"].includes(new URL(current).protocol))
|
|
321
|
+
throw new CorrectlyError(
|
|
322
|
+
"schema-load",
|
|
323
|
+
`Unsupported redirect: ${current}`,
|
|
324
|
+
)
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
if (response.status === 304 && cached) {
|
|
328
|
+
await response.body?.cancel()
|
|
329
|
+
return cached.text
|
|
330
|
+
}
|
|
331
|
+
if (!response.ok || !response.body) {
|
|
332
|
+
await response.body?.cancel()
|
|
333
|
+
throw new CorrectlyError(
|
|
334
|
+
"schema-load",
|
|
335
|
+
`HTTP ${response.status} loading schema ${uri}`,
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
const reader = response.body.getReader()
|
|
339
|
+
const chunks: Uint8Array[] = []
|
|
340
|
+
let size = 0
|
|
341
|
+
try {
|
|
342
|
+
while (true) {
|
|
343
|
+
const { value, done } = await reader.read()
|
|
344
|
+
if (done) break
|
|
345
|
+
size += value.byteLength
|
|
346
|
+
if (size > this.maxBytes)
|
|
347
|
+
throw new CorrectlyError(
|
|
348
|
+
"schema-limit",
|
|
349
|
+
`Schema ${uri} exceeds ${this.maxBytes} bytes`,
|
|
350
|
+
)
|
|
351
|
+
chunks.push(value)
|
|
352
|
+
}
|
|
353
|
+
} finally {
|
|
354
|
+
await reader.cancel()
|
|
355
|
+
}
|
|
356
|
+
const text = Buffer.concat(chunks).toString("utf8")
|
|
357
|
+
const entry: CacheEntry = { uri, text }
|
|
358
|
+
const etag = response.headers.get("etag")
|
|
359
|
+
const lastModified = response.headers.get("last-modified")
|
|
360
|
+
if (etag) entry.etag = etag
|
|
361
|
+
if (lastModified) entry.lastModified = lastModified
|
|
362
|
+
signal.throwIfAborted()
|
|
363
|
+
await mkdir(this.cacheDir, { recursive: true })
|
|
364
|
+
const temporary = `${cacheFile}.${randomUUID()}.tmp`
|
|
365
|
+
await writeFile(temporary, JSON.stringify(entry))
|
|
366
|
+
await rename(temporary, cacheFile)
|
|
367
|
+
return text
|
|
368
|
+
}
|
|
369
|
+
throw new CorrectlyError(
|
|
370
|
+
"schema-limit",
|
|
371
|
+
`Too many redirects loading schema ${uri}`,
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function localSchemaPath(uri: string): string | undefined {
|
|
377
|
+
const url = new URL(uri)
|
|
378
|
+
return url.protocol === "file:" ? fileURLToPath(url) : undefined
|
|
379
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type Mode = "json" | "jsonc"
|
|
2
|
+
export type Position = { line: number; character: number }
|
|
3
|
+
export type Range = { start: Position; end: Position }
|
|
4
|
+
export type Diagnostic = {
|
|
5
|
+
code: string
|
|
6
|
+
message: string
|
|
7
|
+
pointer: string
|
|
8
|
+
offset: number
|
|
9
|
+
length: number
|
|
10
|
+
range: Range
|
|
11
|
+
context?: { parent: number; label: string }
|
|
12
|
+
}
|
|
13
|
+
export type Failure = { code: string; message: string; file?: string }
|
|
14
|
+
export class CorrectlyError extends Error {
|
|
15
|
+
readonly code: string
|
|
16
|
+
constructor(code: string, message: string) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = "CorrectlyError"
|
|
19
|
+
this.code = code
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function failure(error: unknown, file?: string): Failure {
|
|
23
|
+
return {
|
|
24
|
+
code: error instanceof CorrectlyError ? error.code : "execution",
|
|
25
|
+
message: error instanceof Error ? error.message : String(error),
|
|
26
|
+
...(file === undefined ? {} : { file }),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export type Association = {
|
|
30
|
+
index: number
|
|
31
|
+
name: string
|
|
32
|
+
schema: string | null
|
|
33
|
+
mode: Mode
|
|
34
|
+
}
|
|
35
|
+
export type FileResult = {
|
|
36
|
+
file: string
|
|
37
|
+
mode: Mode
|
|
38
|
+
coverage: "schema" | "syntax-only" | "excluded"
|
|
39
|
+
association: Association | null
|
|
40
|
+
diagnostics: Diagnostic[]
|
|
41
|
+
failures: Failure[]
|
|
42
|
+
}
|
|
43
|
+
export type Report = {
|
|
44
|
+
reportVersion: 1
|
|
45
|
+
config: string | null
|
|
46
|
+
files: FileResult[]
|
|
47
|
+
failures: Failure[]
|
|
48
|
+
summary: {
|
|
49
|
+
checked: number
|
|
50
|
+
schemaCovered: number
|
|
51
|
+
syntaxOnly: number
|
|
52
|
+
invalid: number
|
|
53
|
+
failures: number
|
|
54
|
+
}
|
|
55
|
+
exitCode: 0 | 1 | 2
|
|
56
|
+
}
|
package/src/lsp/hints.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getLanguageService,
|
|
3
|
+
type JSONDocument,
|
|
4
|
+
type LanguageService,
|
|
5
|
+
type Position,
|
|
6
|
+
} from "vscode-json-languageservice"
|
|
7
|
+
import type { TextDocument } from "vscode-languageserver-textdocument"
|
|
8
|
+
import { fileURLToPath } from "node:url"
|
|
9
|
+
import { associationFor, isIncluded } from "../core/config.ts"
|
|
10
|
+
import type { Engine } from "../core/engine.ts"
|
|
11
|
+
|
|
12
|
+
// Only the schema-selection accessor is hidden. The original AST (including
|
|
13
|
+
// $schema as ordinary data) is retained by every bound traversal method.
|
|
14
|
+
export function externalSelection(document: JSONDocument): JSONDocument {
|
|
15
|
+
return new Proxy(document, {
|
|
16
|
+
get(target, key) {
|
|
17
|
+
if (key === "root") return undefined
|
|
18
|
+
const value: unknown = Reflect.get(target, key)
|
|
19
|
+
return typeof value === "function" ? value.bind(target) : value
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class Hints {
|
|
25
|
+
private readonly services = new WeakMap<
|
|
26
|
+
Engine,
|
|
27
|
+
Map<string, LanguageService>
|
|
28
|
+
>()
|
|
29
|
+
private async service(
|
|
30
|
+
engine: Engine,
|
|
31
|
+
document: TextDocument,
|
|
32
|
+
): Promise<LanguageService | undefined> {
|
|
33
|
+
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)
|
|
38
|
+
let services = this.services.get(engine)
|
|
39
|
+
if (!services) {
|
|
40
|
+
services = new Map()
|
|
41
|
+
this.services.set(engine, services)
|
|
42
|
+
}
|
|
43
|
+
let service = services.get(association.schema)
|
|
44
|
+
if (!service) {
|
|
45
|
+
service = getLanguageService({
|
|
46
|
+
schemaRequestService: async (uri) =>
|
|
47
|
+
JSON.stringify((await engine.store.load(uri)).schema),
|
|
48
|
+
workspaceContext: {
|
|
49
|
+
resolveRelativePath: (relative, resource) =>
|
|
50
|
+
new URL(relative, resource).href,
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
// A per-association service prevents overlapping rules being combined.
|
|
54
|
+
service.configure({
|
|
55
|
+
validate: false,
|
|
56
|
+
schemas: [{ uri: association.schema, fileMatch: ["*"] }],
|
|
57
|
+
})
|
|
58
|
+
services.set(association.schema, service)
|
|
59
|
+
}
|
|
60
|
+
return service
|
|
61
|
+
}
|
|
62
|
+
async complete(engine: Engine, document: TextDocument, position: Position) {
|
|
63
|
+
const service = await this.service(engine, document)
|
|
64
|
+
return service
|
|
65
|
+
? await service.doComplete(
|
|
66
|
+
document,
|
|
67
|
+
position,
|
|
68
|
+
externalSelection(service.parseJSONDocument(document)),
|
|
69
|
+
)
|
|
70
|
+
: { isIncomplete: false, items: [] }
|
|
71
|
+
}
|
|
72
|
+
async hover(engine: Engine, document: TextDocument, position: Position) {
|
|
73
|
+
const service = await this.service(engine, document)
|
|
74
|
+
return service
|
|
75
|
+
? await service.doHover(
|
|
76
|
+
document,
|
|
77
|
+
position,
|
|
78
|
+
externalSelection(service.parseJSONDocument(document)),
|
|
79
|
+
)
|
|
80
|
+
: null
|
|
81
|
+
}
|
|
82
|
+
}
|