@vertekum/schema-builder 0.1.5 → 0.1.7

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @vertekum/schema-builder
2
2
 
3
+ ## 0.1.7
4
+
5
+ ### Patch Changes
6
+
7
+ - [`880b288`](https://github.com/moros-oxus/vertekum/commit/880b288630455eaf629f0d458fb38b3eaf0222a6) Thanks [@tschemmer](https://github.com/tschemmer)! - Grammar errors are attributed to the module that contains them — a failure inside an imported module names that file, not the one being linted or built. Referencing a fragment by module name (`<@t-shirt>`) now explains that a fragment has no root and lists its productions to reference instead; a qualified miss lists what the import declares.
8
+
9
+ ## 0.1.6
10
+
11
+ ### Patch Changes
12
+
13
+ - [`2e9a56f`](https://github.com/moros-oxus/vertekum/commit/2e9a56f5cc52de26ee8768ae0035178ebe4d847e) Thanks [@tschemmer](https://github.com/tschemmer)! - Names may start with digits when they contain a letter (`2xs`, `4k-display`) — everywhere a name works, including pick/omit lists. Pure numbers and scale expressions keep their numeric meaning; the syntax highlighting follows suit.
14
+
3
15
  ## 0.1.5
4
16
 
5
17
  ### Patch Changes
package/docs/language.md CHANGED
@@ -29,9 +29,10 @@ root = color.[text | icon].<role>.<@emphasis>
29
29
  ```
30
30
 
31
31
  - **`#` starts a comment**, running to the end of the line.
32
- - **Identifiers** are `[A-Za-z][A-Za-z0-9-]*` — letters, digits, hyphens
33
- (`color-role`). A term that starts with a digit is numeric: a bare number (`950`) or
34
- a [scale expression](./scales.md) (`100-300/50`) — which is why hyphens inside names
32
+ - **Identifiers** are letters, digits, and hyphens (`color-role`), and may start with
33
+ digits **when they contain a letter** (`2xs`, `4k-display`). A term that is purely
34
+ numeric keeps its numeric meaning: a bare number (`950`) or a
35
+ [scale expression](./scales.md) (`100-300/50`) — which is why hyphens inside names
35
36
  never collide with range syntax.
36
37
  - **Strings** (pragma and `use` arguments) are double-quoted.
37
38
 
@@ -61,7 +61,7 @@
61
61
  }
62
62
  },
63
63
  "production": {
64
- "match": "^([A-Za-z][A-Za-z0-9-]*)\\s*(=)",
64
+ "match": "^(\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*)\\s*(=)",
65
65
  "captures": {
66
66
  "1": {
67
67
  "name": "entity.name.function.production.dfn"
@@ -127,7 +127,7 @@
127
127
  },
128
128
  "patterns": [
129
129
  {
130
- "match": "([A-Za-z][A-Za-z0-9-]*)(/)([A-Za-z][A-Za-z0-9-]*)",
130
+ "match": "(\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*)(/)(\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*)",
131
131
  "captures": {
132
132
  "1": {
133
133
  "name": "entity.name.namespace.module.dfn"
@@ -145,7 +145,7 @@
145
145
  },
146
146
  {
147
147
  "name": "variable.other.reference.imported.dfn",
148
- "match": "[A-Za-z][A-Za-z0-9-]*"
148
+ "match": "\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*"
149
149
  }
150
150
  ]
151
151
  },
@@ -171,7 +171,7 @@
171
171
  },
172
172
  {
173
173
  "name": "variable.other.reference.dfn",
174
- "match": "[A-Za-z][A-Za-z0-9-]*"
174
+ "match": "\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*"
175
175
  }
176
176
  ]
177
177
  },
@@ -197,7 +197,7 @@
197
197
  "patterns": [
198
198
  {
199
199
  "name": "variable.parameter.member.dfn",
200
- "match": "[A-Za-z][A-Za-z0-9-]*|\\d+"
200
+ "match": "\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*|\\d+"
201
201
  },
202
202
  {
203
203
  "name": "punctuation.separator.comma.dfn",
@@ -245,7 +245,7 @@
245
245
  },
246
246
  "name": {
247
247
  "name": "constant.language.name.dfn",
248
- "match": "[A-Za-z][A-Za-z0-9-]*"
248
+ "match": "\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*"
249
249
  }
250
250
  }
251
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertekum/schema-builder",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Declare token vocabularies as grammar; build them into JSON Schema files",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -43,7 +43,7 @@
43
43
  "zod": "^3.25.76"
44
44
  },
45
45
  "devDependencies": {
46
- "@vertekum/core": "0.1.2",
47
- "@vertekum/schema-vocabulary": "0.1.0"
46
+ "@vertekum/schema-vocabulary": "0.1.0",
47
+ "@vertekum/core": "0.1.2"
48
48
  }
49
49
  }
package/src/build.ts CHANGED
@@ -33,8 +33,19 @@ interface Scope {
33
33
  expanding: Set<string>;
34
34
  }
35
35
 
36
+ /** The `<@key/name>, …` listing a miss suggests — an error should name the fix, not just the gap. */
37
+ function productionsOf(key: string, imported: ResolvedModule): string {
38
+ return [...imported.module.productions.keys()]
39
+ .map((p) => `<@${key}/${p}>`)
40
+ .join(', ');
41
+ }
42
+
36
43
  /** Find what `<name>` / `<@name>` means in this scope. Imported roots go by module name. */
37
44
  function target(ref: Ref, scope: Scope): { node: Node; scope: Scope } {
45
+ // Every error here names the module whose TEXT holds the ref — `scope.module` — so a
46
+ // failure inside an imported production's body is attributed to that file, not to
47
+ // whichever module the walk started from.
48
+ const file = scope.module.path;
38
49
  if (!ref.imported) {
39
50
  const production = scope.module.module.productions.get(ref.name);
40
51
  if (!production) {
@@ -42,6 +53,7 @@ function target(ref: Ref, scope: Scope): { node: Node; scope: Scope } {
42
53
  `unknown production '<${ref.name}>'`,
43
54
  ref.line,
44
55
  ref.column,
56
+ file,
45
57
  );
46
58
  }
47
59
  return { node: production, scope };
@@ -51,14 +63,20 @@ function target(ref: Ref, scope: Scope): { node: Node; scope: Scope } {
51
63
  if (ref.from) {
52
64
  const imported = scope.module.imports.get(ref.from);
53
65
  if (!imported) {
54
- throw new DfnError(`no import named '${ref.from}'`, ref.line, ref.column);
66
+ throw new DfnError(
67
+ `no import named '${ref.from}'`,
68
+ ref.line,
69
+ ref.column,
70
+ file,
71
+ );
55
72
  }
56
73
  const production = imported.module.productions.get(ref.name);
57
74
  if (!production) {
58
75
  throw new DfnError(
59
- `'${ref.from}' has no production '${ref.name}'`,
76
+ `'${ref.from}' has no production '${ref.name}' — it declares: ${productionsOf(ref.from, imported)}`,
60
77
  ref.line,
61
78
  ref.column,
79
+ file,
62
80
  );
63
81
  }
64
82
  return {
@@ -77,10 +95,22 @@ function target(ref: Ref, scope: Scope): { node: Node; scope: Scope } {
77
95
  }
78
96
  }
79
97
  if (hits.length === 0) {
98
+ // The near-miss that reads as a missing `use`: the import EXISTS under this key, but
99
+ // `<@key>` means its root and a fragment has none. Say so, and name the real forms.
100
+ const fragment = scope.module.imports.get(ref.name);
101
+ if (fragment) {
102
+ throw new DfnError(
103
+ `'${ref.name}' is imported, but it declares no root (a fragment) — reference one of its productions: ${productionsOf(ref.name, fragment)}`,
104
+ ref.line,
105
+ ref.column,
106
+ file,
107
+ );
108
+ }
80
109
  throw new DfnError(
81
110
  `no import provides '<@${ref.name}>'`,
82
111
  ref.line,
83
112
  ref.column,
113
+ file,
84
114
  );
85
115
  }
86
116
  if (hits.length > 1) {
@@ -88,6 +118,7 @@ function target(ref: Ref, scope: Scope): { node: Node; scope: Scope } {
88
118
  `'<@${ref.name}>' is ambiguous across imports — qualify it: <@module/${ref.name}>`,
89
119
  ref.line,
90
120
  ref.column,
121
+ file,
91
122
  );
92
123
  }
93
124
  return hits[0];
@@ -132,6 +163,7 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
132
163
  error instanceof Error ? error.message : String(error),
133
164
  node.line,
134
165
  node.column,
166
+ scope.module.path,
135
167
  );
136
168
  }
137
169
  if (scale.collisions.length > 0) {
@@ -139,6 +171,7 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
139
171
  `scale steps ${scale.collisions.join(', ')} quantize onto earlier names — the quantum is too coarse for the factor`,
140
172
  node.line,
141
173
  node.column,
174
+ scope.module.path,
142
175
  );
143
176
  }
144
177
  const forest = leaf();
@@ -188,6 +221,7 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
188
221
  `'<${node.name}>' expands through itself`,
189
222
  node.line,
190
223
  node.column,
224
+ scope.module.path,
191
225
  );
192
226
  }
193
227
  scope.expanding.add(node.name);
@@ -200,6 +234,7 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
200
234
  `'<${node.name}>' has no member '${omitted}' to omit`,
201
235
  node.line,
202
236
  node.column,
237
+ scope.module.path,
203
238
  );
204
239
  }
205
240
  }
@@ -210,6 +245,7 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
210
245
  `'<${node.name}>' has no member '${picked}' to pick`,
211
246
  node.line,
212
247
  node.column,
248
+ scope.module.path,
213
249
  );
214
250
  }
215
251
  }
@@ -254,7 +290,12 @@ function isTerminal(node: TreeNode): boolean {
254
290
  export function build(resolved: ResolvedModule): TreeNode {
255
291
  const root = resolved.module.root;
256
292
  if (!root) {
257
- throw new DfnError(`${resolved.name}.dfn declares no root`, 1, 1);
293
+ throw new DfnError(
294
+ `${resolved.name}.dfn declares no root`,
295
+ 1,
296
+ 1,
297
+ resolved.path,
298
+ );
258
299
  }
259
300
  const scope: Scope = { module: resolved, expanding: new Set() };
260
301
 
@@ -274,6 +315,7 @@ export function build(resolved: ResolvedModule): TreeNode {
274
315
  `top-level '${name}' comes from both '${owner}' and '${option.name}'`,
275
316
  option.line,
276
317
  option.column,
318
+ resolved.path,
277
319
  );
278
320
  }
279
321
  seen.set(name, option.name);
@@ -332,6 +374,7 @@ export function assertOpenSetsAreNameSets(
332
374
  '* opens a set of names, not sub-paths',
333
375
  node.line,
334
376
  node.column,
377
+ scope.module.path,
335
378
  );
336
379
  }
337
380
  assertOpenSetsAreNameSets(resolved, node.node, scope);
@@ -344,6 +387,7 @@ export function assertOpenSetsAreNameSets(
344
387
  `'<${node.name}*>' opens a set, but the production is not name-only`,
345
388
  node.line,
346
389
  node.column,
390
+ scope.module.path,
347
391
  );
348
392
  }
349
393
  }
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]);