@vertekum/schema-builder 0.1.4 → 0.1.6
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 +12 -0
- package/README.md +37 -89
- package/docs/build.md +81 -0
- package/docs/emission.md +129 -0
- package/docs/expressions.md +150 -0
- package/docs/language.md +96 -0
- package/docs/lint.md +59 -0
- package/docs/modules.md +105 -0
- package/docs/scales.md +88 -0
- package/grammar/dfn.tmLanguage.json +6 -6
- package/package.json +2 -1
- package/src/api.ts +11 -4
- package/src/ast.ts +13 -4
- package/src/build.ts +58 -20
- package/src/cli.ts +51 -1
- package/src/error.ts +8 -2
- package/src/lint.ts +80 -0
- package/src/parser.ts +24 -3
- package/src/resolve.ts +11 -1
- package/src/tokenizer.ts +11 -0
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
|
@@ -133,6 +133,16 @@ class Parser {
|
|
|
133
133
|
token.column,
|
|
134
134
|
);
|
|
135
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
|
+
}
|
|
136
146
|
if (this.peek().kind !== 'eof') this.expect('newline', 'end of line');
|
|
137
147
|
this.skipNewlines();
|
|
138
148
|
}
|
|
@@ -189,10 +199,11 @@ class Parser {
|
|
|
189
199
|
|
|
190
200
|
private term(): Step['term'] {
|
|
191
201
|
const token = this.next();
|
|
202
|
+
const at = { line: token.line, column: token.column };
|
|
192
203
|
switch (token.kind) {
|
|
193
204
|
case 'ident':
|
|
194
205
|
case 'number':
|
|
195
|
-
return { kind: 'name', value: token.value };
|
|
206
|
+
return { kind: 'name', value: token.value, ...at };
|
|
196
207
|
case 'range': {
|
|
197
208
|
const payload = token.range as NonNullable<Token['range']>;
|
|
198
209
|
const { min, max, mode, step, quantum, pad } = payload;
|
|
@@ -218,7 +229,7 @@ class Parser {
|
|
|
218
229
|
token.column,
|
|
219
230
|
);
|
|
220
231
|
}
|
|
221
|
-
return { kind: 'range', min, max, mode: 'stepped', step, pad };
|
|
232
|
+
return { kind: 'range', min, max, mode: 'stepped', step, pad, ...at };
|
|
222
233
|
}
|
|
223
234
|
if (step <= 1) {
|
|
224
235
|
throw new DfnError(
|
|
@@ -235,6 +246,7 @@ class Parser {
|
|
|
235
246
|
step,
|
|
236
247
|
quantum,
|
|
237
248
|
pad,
|
|
249
|
+
...at,
|
|
238
250
|
};
|
|
239
251
|
}
|
|
240
252
|
case 'langle': {
|
|
@@ -289,6 +301,7 @@ class Parser {
|
|
|
289
301
|
open,
|
|
290
302
|
pick,
|
|
291
303
|
omit,
|
|
304
|
+
...at,
|
|
292
305
|
};
|
|
293
306
|
}
|
|
294
307
|
case 'lbracket': {
|
|
@@ -296,8 +309,16 @@ class Parser {
|
|
|
296
309
|
const open = this.peek().kind === 'star';
|
|
297
310
|
if (open) this.next();
|
|
298
311
|
this.expect('rbracket', "']'");
|
|
299
|
-
return { kind: 'group', node, open };
|
|
312
|
+
return { kind: 'group', node, open, ...at };
|
|
300
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
|
+
);
|
|
301
322
|
default:
|
|
302
323
|
throw new DfnError(
|
|
303
324
|
`expected a term, got '${token.value || token.kind}'`,
|
package/src/resolve.ts
CHANGED
|
@@ -55,7 +55,17 @@ 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
71
|
for (const { spec, alias } of module.uses) {
|
package/src/tokenizer.ts
CHANGED
|
@@ -49,6 +49,12 @@ export interface Token {
|
|
|
49
49
|
|
|
50
50
|
const IDENT = /^[A-Za-z][A-Za-z0-9-]*/;
|
|
51
51
|
const RANGE = /^(\d+)-(\d+)([*/])(\d+(?:\.\d+)?)(?:~(\d+))?/;
|
|
52
|
+
/**
|
|
53
|
+
* A digit-leading NAME (`2xs`, `4k-display`): digits, then at least one letter. Tried after
|
|
54
|
+
* RANGE — so `100-300/50` stays numeric — and before NUMBER, so the whole word is one token
|
|
55
|
+
* rather than `2` + `xs`. Real design systems name t-shirt sizes this way.
|
|
56
|
+
*/
|
|
57
|
+
const WORD = /^\d+[A-Za-z][A-Za-z0-9-]*/;
|
|
52
58
|
const NUMBER = /^\d+/;
|
|
53
59
|
|
|
54
60
|
/** "Write the numbers as they appear": a leading zero declares the pad width. */
|
|
@@ -113,6 +119,11 @@ export function tokenize(source: string): Token[] {
|
|
|
113
119
|
text = text.slice(range[0].length);
|
|
114
120
|
continue;
|
|
115
121
|
}
|
|
122
|
+
const word = text.match(WORD);
|
|
123
|
+
if (word) {
|
|
124
|
+
push('ident', word[0]);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
116
127
|
const number = text.match(NUMBER);
|
|
117
128
|
if (number) {
|
|
118
129
|
push('number', number[0]);
|