@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 +12 -0
- package/docs/language.md +4 -3
- package/grammar/dfn.tmLanguage.json +6 -6
- package/package.json +3 -3
- package/src/build.ts +47 -3
- package/src/tokenizer.ts +11 -0
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
|
|
33
|
-
(`
|
|
34
|
-
a
|
|
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.
|
|
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/
|
|
47
|
-
"@vertekum/
|
|
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(
|
|
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(
|
|
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]);
|