@vertekum/schema-builder 0.1.3 → 0.1.5
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/CHANGELOG.md +16 -0
- package/README.md +37 -87
- package/docs/build.md +81 -0
- package/docs/emission.md +129 -0
- package/docs/expressions.md +150 -0
- package/docs/language.md +95 -0
- package/docs/lint.md +59 -0
- package/docs/modules.md +105 -0
- package/docs/scales.md +88 -0
- package/grammar/dfn.tmLanguage.json +1 -1
- package/package.json +5 -4
- package/src/api.ts +11 -4
- package/src/ast.ts +25 -7
- package/src/build.ts +94 -20
- package/src/cli.ts +62 -4
- package/src/error.ts +8 -2
- package/src/lint.ts +80 -0
- package/src/parser.ts +83 -14
- package/src/resolve.ts +20 -5
- package/src/tokenizer.ts +23 -4
package/src/cli.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { basename, dirname, join, relative } from 'node:path';
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative } from 'node:path';
|
|
3
3
|
import type { CommandDescriptor, CommandResult } from '@vertekum/core';
|
|
4
4
|
import { assertOpenSetsAreNameSets, build } from './build';
|
|
5
5
|
import { emit, isStamped } from './emit';
|
|
6
|
+
import { lintModule } from './lint';
|
|
6
7
|
import { resolveModule } from './resolve';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -25,20 +26,74 @@ function modulesUnder(dir: string): string[] {
|
|
|
25
26
|
.map((f) => join(dir, f));
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export function buildModule(
|
|
29
|
+
export function buildModule(
|
|
30
|
+
modulePath: string,
|
|
31
|
+
/** The module name the provenance stamp records; default: the basename. Pass a relative
|
|
32
|
+
* path when modules nest, so same-named modules stamp distinguishably. */
|
|
33
|
+
moduleLabel?: string,
|
|
34
|
+
): {
|
|
29
35
|
target: string;
|
|
30
36
|
content: string;
|
|
31
37
|
} {
|
|
32
38
|
const resolved = resolveModule(modulePath);
|
|
33
39
|
assertOpenSetsAreNameSets(resolved);
|
|
34
40
|
const tree = build(resolved);
|
|
35
|
-
const moduleFile = basename(modulePath);
|
|
41
|
+
const moduleFile = moduleLabel ?? basename(modulePath);
|
|
36
42
|
return {
|
|
37
43
|
target: join(dirname(modulePath), `${resolved.name}.json`),
|
|
38
44
|
content: emit(tree, { moduleFile, ...resolved.module.meta }),
|
|
39
45
|
};
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
/**
|
|
49
|
+
* `vertekum schema lint [module]`: validate the `.dfn` sources themselves. Where `build --check`
|
|
50
|
+
* asks "are the artifacts current?", lint asks "is the grammar sound?" — fragments included,
|
|
51
|
+
* every production evaluated, findings collected instead of aborting on the first. Findings exit
|
|
52
|
+
* `1` through a thrown error carrying the listing (the `build --check` staleness precedent).
|
|
53
|
+
*/
|
|
54
|
+
export const schemaLintCommand: CommandDescriptor = {
|
|
55
|
+
name: 'schema lint',
|
|
56
|
+
description:
|
|
57
|
+
'validate .dfn vocabulary modules — fragments included — without building',
|
|
58
|
+
args: [
|
|
59
|
+
{
|
|
60
|
+
name: 'module',
|
|
61
|
+
required: false,
|
|
62
|
+
description: "a .dfn file; default: every module under './schemas'",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
run(ctx): CommandResult {
|
|
66
|
+
const { projectDir } = ctx.project as ProjectDir;
|
|
67
|
+
const modules = ctx.args.module
|
|
68
|
+
? [join(projectDir, ctx.args.module)]
|
|
69
|
+
: modulesUnder(join(projectDir, 'schemas'));
|
|
70
|
+
if (modules.length === 0) {
|
|
71
|
+
return { summary: 'no .dfn modules found' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const project = (file: string) =>
|
|
75
|
+
isAbsolute(file) ? relative(projectDir, file) : file;
|
|
76
|
+
const diagnostics = modules.flatMap((modulePath) =>
|
|
77
|
+
lintModule(modulePath).map((d) => ({ ...d, file: project(d.file) })),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (diagnostics.length > 0) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
[
|
|
83
|
+
`${diagnostics.length} problem(s) in the .dfn modules:`,
|
|
84
|
+
...diagnostics.map(
|
|
85
|
+
(d) => ` ${d.file}:${d.line}:${d.column} ${d.message}`,
|
|
86
|
+
),
|
|
87
|
+
].join('\n'),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
summary: `${modules.length} module(s) clean`,
|
|
92
|
+
data: { modules: modules.map(project) },
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
42
97
|
export const schemaBuildCommand: CommandDescriptor = {
|
|
43
98
|
name: 'schema build',
|
|
44
99
|
description: 'build .dfn vocabulary modules into JSON Schema files',
|
|
@@ -77,7 +132,10 @@ export const schemaBuildCommand: CommandDescriptor = {
|
|
|
77
132
|
fragments.push(relative(projectDir, modulePath));
|
|
78
133
|
continue;
|
|
79
134
|
}
|
|
80
|
-
const { target, content } = buildModule(
|
|
135
|
+
const { target, content } = buildModule(
|
|
136
|
+
modulePath,
|
|
137
|
+
relative(projectDir, modulePath),
|
|
138
|
+
);
|
|
81
139
|
const existing = existsSync(target)
|
|
82
140
|
? readFileSync(target, 'utf8')
|
|
83
141
|
: undefined;
|
package/src/error.ts
CHANGED
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
export class DfnError extends Error {
|
|
3
3
|
readonly line: number;
|
|
4
4
|
readonly column: number;
|
|
5
|
+
/** The module the position refers to, when the thrower knows it (multi-file runs). */
|
|
6
|
+
readonly file?: string;
|
|
7
|
+
/** The bare message, without the position prefix — what a diagnostic report carries. */
|
|
8
|
+
readonly detail: string;
|
|
5
9
|
|
|
6
|
-
constructor(message: string, line: number, column: number) {
|
|
7
|
-
super(`${line}:${column} ${message}`);
|
|
10
|
+
constructor(message: string, line: number, column: number, file?: string) {
|
|
11
|
+
super(`${file ? `${file}:` : ''}${line}:${column} ${message}`);
|
|
8
12
|
this.name = 'DfnError';
|
|
9
13
|
this.line = line;
|
|
10
14
|
this.column = column;
|
|
15
|
+
this.file = file;
|
|
16
|
+
this.detail = message;
|
|
11
17
|
}
|
|
12
18
|
}
|
package/src/lint.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { assertOpenSetsAreNameSets, build, evaluateProduction } from './build';
|
|
2
|
+
import { DfnError } from './error';
|
|
3
|
+
import { resolveModule } from './resolve';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Grammar-only validation of a `.dfn` module — the whole module, not just what a root reaches.
|
|
7
|
+
*
|
|
8
|
+
* `build --dry-run` validates incidentally: it expands from `root`, so a fragment's productions
|
|
9
|
+
* and a rooted module's unused ones are parsed and nothing more. Lint's contract is the source
|
|
10
|
+
* itself — every production evaluates (cycle-detected, references resolved, pick/omit members and
|
|
11
|
+
* scales checked, open sets verified name-only), and diagnostics COLLECT: one broken production
|
|
12
|
+
* does not hide the next one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface DfnDiagnostic {
|
|
16
|
+
/** The module the position refers to — the linted file, unless the error names another. */
|
|
17
|
+
file: string;
|
|
18
|
+
line: number;
|
|
19
|
+
column: number;
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toDiagnostic(file: string, error: unknown): DfnDiagnostic {
|
|
24
|
+
if (error instanceof DfnError) {
|
|
25
|
+
return {
|
|
26
|
+
file: error.file ?? file,
|
|
27
|
+
line: error.line,
|
|
28
|
+
column: error.column,
|
|
29
|
+
message: error.detail,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
file,
|
|
34
|
+
line: 1,
|
|
35
|
+
column: 1,
|
|
36
|
+
message: error instanceof Error ? error.message : String(error),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Lint one module. Returns every finding; an empty array means the grammar is sound. */
|
|
41
|
+
export function lintModule(path: string): DfnDiagnostic[] {
|
|
42
|
+
let resolved: ReturnType<typeof resolveModule>;
|
|
43
|
+
try {
|
|
44
|
+
resolved = resolveModule(path);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return [toDiagnostic(path, error)];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const out: DfnDiagnostic[] = [];
|
|
50
|
+
const collect = (walk: () => void): void => {
|
|
51
|
+
try {
|
|
52
|
+
walk();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
out.push(toDiagnostic(path, error));
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
for (const [name, node] of resolved.module.productions) {
|
|
59
|
+
collect(() => {
|
|
60
|
+
evaluateProduction(resolved, name);
|
|
61
|
+
assertOpenSetsAreNameSets(resolved, node);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (resolved.module.root) {
|
|
65
|
+
// `build` re-expands referenced productions, so a production's error can surface twice —
|
|
66
|
+
// once from its own walk, once through the root. The dedupe below keeps one.
|
|
67
|
+
collect(() => {
|
|
68
|
+
build(resolved);
|
|
69
|
+
assertOpenSetsAreNameSets(resolved);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
return out.filter((d) => {
|
|
75
|
+
const key = `${d.file}|${d.line}|${d.column}|${d.message}`;
|
|
76
|
+
if (seen.has(key)) return false;
|
|
77
|
+
seen.add(key);
|
|
78
|
+
return true;
|
|
79
|
+
});
|
|
80
|
+
}
|
package/src/parser.ts
CHANGED
|
@@ -49,7 +49,7 @@ class Parser {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
module(): Module {
|
|
52
|
-
const uses: string
|
|
52
|
+
const uses: Array<{ spec: string; alias?: string }> = [];
|
|
53
53
|
const productions = new Map<string, Node>();
|
|
54
54
|
const meta: ModuleMeta = {};
|
|
55
55
|
let root: Node | undefined;
|
|
@@ -59,7 +59,13 @@ class Parser {
|
|
|
59
59
|
const token = this.peek();
|
|
60
60
|
if (token.kind === 'use') {
|
|
61
61
|
this.next();
|
|
62
|
-
|
|
62
|
+
const spec = this.expect('string', 'a quoted specifier').value;
|
|
63
|
+
let alias: string | undefined;
|
|
64
|
+
if (this.peek().kind === 'ident' && this.peek().value === 'as') {
|
|
65
|
+
this.next();
|
|
66
|
+
alias = this.expect('ident', 'an import alias').value;
|
|
67
|
+
}
|
|
68
|
+
uses.push({ spec, alias });
|
|
63
69
|
} else if (token.kind === 'ident' && this.at(1)?.kind === 'string') {
|
|
64
70
|
// `<ident> "<string>"` (no `=`) is a PRAGMA — document metadata, not a production, so
|
|
65
71
|
// nothing is reserved: `title = a | b` still declares a production named title.
|
|
@@ -127,6 +133,16 @@ class Parser {
|
|
|
127
133
|
token.column,
|
|
128
134
|
);
|
|
129
135
|
}
|
|
136
|
+
// `<scale>*` — the open-set mark after a closed `>`/`]` lands here as a stray
|
|
137
|
+
// statement tail; name the fix rather than complaining about the line ending.
|
|
138
|
+
if (this.peek().kind === 'star') {
|
|
139
|
+
const star = this.peek();
|
|
140
|
+
throw new DfnError(
|
|
141
|
+
"'*' marks a set open and sits inside the reference or group it opens — <name*> or [a | b *]",
|
|
142
|
+
star.line,
|
|
143
|
+
star.column,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
130
146
|
if (this.peek().kind !== 'eof') this.expect('newline', 'end of line');
|
|
131
147
|
this.skipNewlines();
|
|
132
148
|
}
|
|
@@ -168,26 +184,70 @@ class Parser {
|
|
|
168
184
|
return { term, optional };
|
|
169
185
|
}
|
|
170
186
|
|
|
187
|
+
/** A pick/omit list member: an identifier or a (possibly padded) number. */
|
|
188
|
+
private member(): string {
|
|
189
|
+
const token = this.next();
|
|
190
|
+
if (token.kind !== 'ident' && token.kind !== 'number') {
|
|
191
|
+
throw new DfnError(
|
|
192
|
+
`expected a member name, got '${token.value || token.kind}'`,
|
|
193
|
+
token.line,
|
|
194
|
+
token.column,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return token.value;
|
|
198
|
+
}
|
|
199
|
+
|
|
171
200
|
private term(): Step['term'] {
|
|
172
201
|
const token = this.next();
|
|
202
|
+
const at = { line: token.line, column: token.column };
|
|
173
203
|
switch (token.kind) {
|
|
174
204
|
case 'ident':
|
|
175
205
|
case 'number':
|
|
176
|
-
return { kind: 'name', value: token.value };
|
|
206
|
+
return { kind: 'name', value: token.value, ...at };
|
|
177
207
|
case 'range': {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
208
|
+
const payload = token.range as NonNullable<Token['range']>;
|
|
209
|
+
const { min, max, mode, step, quantum, pad } = payload;
|
|
210
|
+
if (max < min) {
|
|
211
|
+
throw new DfnError(
|
|
212
|
+
'range needs max >= min',
|
|
213
|
+
token.line,
|
|
214
|
+
token.column,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (mode === '/') {
|
|
218
|
+
if (quantum !== undefined) {
|
|
219
|
+
throw new DfnError(
|
|
220
|
+
'~quantum belongs to multiplied scales (min-max*factor~q)',
|
|
221
|
+
token.line,
|
|
222
|
+
token.column,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (step === 0 || !Number.isInteger(step)) {
|
|
226
|
+
throw new DfnError(
|
|
227
|
+
'a stepped range needs a whole-number step greater than zero',
|
|
228
|
+
token.line,
|
|
229
|
+
token.column,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return { kind: 'range', min, max, mode: 'stepped', step, pad, ...at };
|
|
233
|
+
}
|
|
234
|
+
if (step <= 1) {
|
|
184
235
|
throw new DfnError(
|
|
185
|
-
'range needs
|
|
236
|
+
'a multiplied range needs a factor greater than one',
|
|
186
237
|
token.line,
|
|
187
238
|
token.column,
|
|
188
239
|
);
|
|
189
240
|
}
|
|
190
|
-
return {
|
|
241
|
+
return {
|
|
242
|
+
kind: 'range',
|
|
243
|
+
min,
|
|
244
|
+
max,
|
|
245
|
+
mode: 'multiplied',
|
|
246
|
+
step,
|
|
247
|
+
quantum,
|
|
248
|
+
pad,
|
|
249
|
+
...at,
|
|
250
|
+
};
|
|
191
251
|
}
|
|
192
252
|
case 'langle': {
|
|
193
253
|
const imported = this.peek().kind === 'at';
|
|
@@ -216,10 +276,10 @@ class Parser {
|
|
|
216
276
|
if (this.peek().kind === 'lbracket') {
|
|
217
277
|
const into = negated ? omit : pick;
|
|
218
278
|
this.next();
|
|
219
|
-
into.push(this.
|
|
279
|
+
into.push(this.member());
|
|
220
280
|
while (this.peek().kind === 'comma') {
|
|
221
281
|
this.next();
|
|
222
|
-
into.push(this.
|
|
282
|
+
into.push(this.member());
|
|
223
283
|
}
|
|
224
284
|
this.expect('rbracket', "']'");
|
|
225
285
|
} else if (negated) {
|
|
@@ -241,6 +301,7 @@ class Parser {
|
|
|
241
301
|
open,
|
|
242
302
|
pick,
|
|
243
303
|
omit,
|
|
304
|
+
...at,
|
|
244
305
|
};
|
|
245
306
|
}
|
|
246
307
|
case 'lbracket': {
|
|
@@ -248,8 +309,16 @@ class Parser {
|
|
|
248
309
|
const open = this.peek().kind === 'star';
|
|
249
310
|
if (open) this.next();
|
|
250
311
|
this.expect('rbracket', "']'");
|
|
251
|
-
return { kind: 'group', node, open };
|
|
312
|
+
return { kind: 'group', node, open, ...at };
|
|
252
313
|
}
|
|
314
|
+
case 'star':
|
|
315
|
+
// The most common misplacement: `color.*` or `a | *`. The mark exists — it just
|
|
316
|
+
// sits inside the set it opens, so say that instead of a bare grammar complaint.
|
|
317
|
+
throw new DfnError(
|
|
318
|
+
"'*' marks a set open and sits inside the reference or group it opens — <name*> or [a | b *]",
|
|
319
|
+
token.line,
|
|
320
|
+
token.column,
|
|
321
|
+
);
|
|
253
322
|
default:
|
|
254
323
|
throw new DfnError(
|
|
255
324
|
`expected a term, got '${token.value || token.kind}'`,
|
package/src/resolve.ts
CHANGED
|
@@ -55,16 +55,31 @@ export function resolveModule(
|
|
|
55
55
|
} catch {
|
|
56
56
|
throw new DfnError(`cannot read ${absolute}`, 1, 1);
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
let module: Module;
|
|
59
|
+
try {
|
|
60
|
+
module = parse(source);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// Stamp the file: a sweep parses many modules, and `13:10 …` without a file name is
|
|
63
|
+
// exactly the kind of message this pipeline should never emit.
|
|
64
|
+
if (error instanceof DfnError && !error.file) {
|
|
65
|
+
throw new DfnError(error.detail, error.line, error.column, absolute);
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
59
69
|
|
|
60
70
|
const imports = new Map<string, ResolvedModule>();
|
|
61
|
-
for (const spec of module.uses) {
|
|
71
|
+
for (const { spec, alias } of module.uses) {
|
|
62
72
|
const target = resolveSpecifier(spec, dirname(absolute));
|
|
63
73
|
const resolved = resolveModule(target, inProgress);
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
const key = alias ?? resolved.name;
|
|
75
|
+
if (imports.has(key)) {
|
|
76
|
+
throw new DfnError(
|
|
77
|
+
`two imports share the name '${key}' — alias one: use "…" as other-name`,
|
|
78
|
+
1,
|
|
79
|
+
1,
|
|
80
|
+
);
|
|
66
81
|
}
|
|
67
|
-
imports.set(
|
|
82
|
+
imports.set(key, resolved);
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
inProgress.delete(absolute);
|
package/src/tokenizer.ts
CHANGED
|
@@ -31,17 +31,33 @@ export type TokenKind =
|
|
|
31
31
|
|
|
32
32
|
export interface Token {
|
|
33
33
|
kind: TokenKind;
|
|
34
|
-
/** Source text (for ident/number/string); ranges carry
|
|
34
|
+
/** Source text (for ident/number/string); ranges carry their parsed payload instead. */
|
|
35
35
|
value: string;
|
|
36
|
-
range?: {
|
|
36
|
+
range?: {
|
|
37
|
+
min: number;
|
|
38
|
+
max: number;
|
|
39
|
+
/** `/` = stepped (additive), `*` = multiplied (geometric). */
|
|
40
|
+
mode: '/' | '*';
|
|
41
|
+
step: number;
|
|
42
|
+
quantum?: number;
|
|
43
|
+
/** Pad width inferred from a leading zero on a written endpoint (`025` → 3). */
|
|
44
|
+
pad?: number;
|
|
45
|
+
};
|
|
37
46
|
line: number;
|
|
38
47
|
column: number;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
const IDENT = /^[A-Za-z][A-Za-z0-9-]*/;
|
|
42
|
-
const RANGE = /^(\d+)-(\d+)
|
|
51
|
+
const RANGE = /^(\d+)-(\d+)([*/])(\d+(?:\.\d+)?)(?:~(\d+))?/;
|
|
43
52
|
const NUMBER = /^\d+/;
|
|
44
53
|
|
|
54
|
+
/** "Write the numbers as they appear": a leading zero declares the pad width. */
|
|
55
|
+
function padWidth(...literals: string[]): number | undefined {
|
|
56
|
+
const declared = literals.filter((l) => l.length > 1 && l.startsWith('0'));
|
|
57
|
+
if (declared.length === 0) return undefined;
|
|
58
|
+
return Math.max(...declared.map((l) => l.length));
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
export function tokenize(source: string): Token[] {
|
|
46
62
|
const tokens: Token[] = [];
|
|
47
63
|
const lines = source.split('\n');
|
|
@@ -85,7 +101,10 @@ export function tokenize(source: string): Token[] {
|
|
|
85
101
|
range: {
|
|
86
102
|
min: Number(range[1]),
|
|
87
103
|
max: Number(range[2]),
|
|
88
|
-
|
|
104
|
+
mode: range[3] as '/' | '*',
|
|
105
|
+
step: Number(range[4]),
|
|
106
|
+
quantum: range[5] ? Number(range[5]) : undefined,
|
|
107
|
+
pad: padWidth(range[1], range[2]),
|
|
89
108
|
},
|
|
90
109
|
line,
|
|
91
110
|
column,
|