@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/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
- const module = parse(source);
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]);