@vertekum/schema-builder 0.1.7 → 0.2.0
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 +22 -0
- package/README.md +6 -2
- package/docs/build.md +31 -6
- package/docs/emission.md +110 -48
- package/docs/expressions.md +12 -2
- package/docs/format.md +75 -0
- package/docs/language.md +41 -26
- package/docs/lint.md +23 -2
- package/docs/modules.md +8 -5
- package/docs/scales.md +29 -2
- package/grammar/dfn.tmLanguage.json +33 -4
- package/package.json +4 -4
- package/src/api.ts +16 -3
- package/src/ast.ts +23 -9
- package/src/build.ts +177 -25
- package/src/cli.ts +360 -32
- package/src/emit.ts +189 -65
- package/src/format.ts +273 -0
- package/src/index.ts +27 -1
- package/src/lint.ts +21 -4
- package/src/parser.ts +98 -13
- package/src/resolve.ts +12 -5
- package/src/tokenizer.ts +56 -4
package/src/parser.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Alt, Module, ModuleMeta, Node, Path, Step } from './ast';
|
|
1
|
+
import type { Alt, Module, ModuleMeta, Node, Path, Ref, Step } from './ast';
|
|
2
2
|
import { DfnError } from './error';
|
|
3
3
|
import { type Token, tokenize } from './tokenizer';
|
|
4
4
|
|
|
@@ -51,6 +51,8 @@ class Parser {
|
|
|
51
51
|
module(): Module {
|
|
52
52
|
const uses: Array<{ spec: string; alias?: string }> = [];
|
|
53
53
|
const productions = new Map<string, Node>();
|
|
54
|
+
const privates = new Set<string>();
|
|
55
|
+
const notes: Array<{ line: number; column: number; message: string }> = [];
|
|
54
56
|
const meta: ModuleMeta = {};
|
|
55
57
|
let root: Node | undefined;
|
|
56
58
|
|
|
@@ -75,10 +77,11 @@ class Parser {
|
|
|
75
77
|
name.value !== 'id' &&
|
|
76
78
|
name.value !== 'title' &&
|
|
77
79
|
name.value !== 'description' &&
|
|
78
|
-
name.value !== 'scope'
|
|
80
|
+
name.value !== 'scope' &&
|
|
81
|
+
name.value !== 'sealed'
|
|
79
82
|
) {
|
|
80
83
|
throw new DfnError(
|
|
81
|
-
`unknown pragma '${name.value}' — id, title, description, and
|
|
84
|
+
`unknown pragma '${name.value}' — id, title, description, scope, and sealed exist`,
|
|
82
85
|
name.line,
|
|
83
86
|
name.column,
|
|
84
87
|
);
|
|
@@ -92,17 +95,66 @@ class Parser {
|
|
|
92
95
|
);
|
|
93
96
|
}
|
|
94
97
|
if (key === 'scope') {
|
|
95
|
-
if (value
|
|
98
|
+
if (value === 'branch') {
|
|
99
|
+
// The old sealing value, before `sealed` existed — honored, and noted for lint.
|
|
100
|
+
meta.sealed = false;
|
|
101
|
+
notes.push({
|
|
102
|
+
line: name.line,
|
|
103
|
+
column: name.column,
|
|
104
|
+
message:
|
|
105
|
+
'scope "branch" is deprecated — write sealed "false" (scope now names the file\'s nature: document, def, or inline)',
|
|
106
|
+
});
|
|
107
|
+
} else if (
|
|
108
|
+
value === 'document' ||
|
|
109
|
+
value === 'def' ||
|
|
110
|
+
value === 'inline'
|
|
111
|
+
) {
|
|
112
|
+
meta.scope = value;
|
|
113
|
+
} else {
|
|
96
114
|
throw new DfnError(
|
|
97
|
-
`scope is "document" or "
|
|
115
|
+
`scope is "document", "def", or "inline", not "${value}"`,
|
|
98
116
|
name.line,
|
|
99
117
|
name.column,
|
|
100
118
|
);
|
|
101
119
|
}
|
|
102
|
-
|
|
120
|
+
} else if (key === 'sealed') {
|
|
121
|
+
if (value !== 'true' && value !== 'false') {
|
|
122
|
+
throw new DfnError(
|
|
123
|
+
`sealed is "true" or "false", not "${value}"`,
|
|
124
|
+
name.line,
|
|
125
|
+
name.column,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
meta.sealed = value === 'true';
|
|
103
129
|
} else {
|
|
104
130
|
meta[key] = value;
|
|
105
131
|
}
|
|
132
|
+
} else if (
|
|
133
|
+
token.kind === 'colon' &&
|
|
134
|
+
this.at(1)?.kind === 'ident' &&
|
|
135
|
+
this.at(2)?.kind === 'equals'
|
|
136
|
+
) {
|
|
137
|
+
// `:name = …` — a PRIVATE production: inlined at use, invisible to importers.
|
|
138
|
+
this.next();
|
|
139
|
+
const name = this.next();
|
|
140
|
+
this.next();
|
|
141
|
+
const expression = this.alternation();
|
|
142
|
+
if (name.value === 'root') {
|
|
143
|
+
throw new DfnError(
|
|
144
|
+
'root cannot be private — it is the module itself',
|
|
145
|
+
name.line,
|
|
146
|
+
name.column,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (productions.has(name.value)) {
|
|
150
|
+
throw new DfnError(
|
|
151
|
+
`duplicate production '${name.value}'`,
|
|
152
|
+
name.line,
|
|
153
|
+
name.column,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
productions.set(name.value, expression);
|
|
157
|
+
privates.add(name.value);
|
|
106
158
|
} else if (token.kind === 'ident') {
|
|
107
159
|
const name = this.next();
|
|
108
160
|
this.expect('equals', "'='");
|
|
@@ -147,7 +199,7 @@ class Parser {
|
|
|
147
199
|
this.skipNewlines();
|
|
148
200
|
}
|
|
149
201
|
|
|
150
|
-
return { uses, productions, root, meta };
|
|
202
|
+
return { uses, productions, private: privates, root, meta, notes };
|
|
151
203
|
}
|
|
152
204
|
|
|
153
205
|
/** `a | b | c` — a single option collapses to itself. */
|
|
@@ -184,8 +236,27 @@ class Parser {
|
|
|
184
236
|
return { term, optional };
|
|
185
237
|
}
|
|
186
238
|
|
|
187
|
-
/** A pick/omit list member:
|
|
188
|
-
private member(): string {
|
|
239
|
+
/** A pick/omit list member: a name, a number, or a REFERENCE (its whole name set). */
|
|
240
|
+
private member(): string | Ref {
|
|
241
|
+
if (this.peek().kind === 'langle') {
|
|
242
|
+
const at = this.peek();
|
|
243
|
+
const term = this.term();
|
|
244
|
+
if (term.kind !== 'ref') {
|
|
245
|
+
throw new DfnError(
|
|
246
|
+
'expected a member name or reference',
|
|
247
|
+
at.line,
|
|
248
|
+
at.column,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (term.open) {
|
|
252
|
+
throw new DfnError(
|
|
253
|
+
'a member reference names a closed set — drop the *',
|
|
254
|
+
at.line,
|
|
255
|
+
at.column,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return term;
|
|
259
|
+
}
|
|
189
260
|
const token = this.next();
|
|
190
261
|
if (token.kind !== 'ident' && token.kind !== 'number') {
|
|
191
262
|
throw new DfnError(
|
|
@@ -206,7 +277,11 @@ class Parser {
|
|
|
206
277
|
return { kind: 'name', value: token.value, ...at };
|
|
207
278
|
case 'range': {
|
|
208
279
|
const payload = token.range as NonNullable<Token['range']>;
|
|
209
|
-
const { min, max, mode, step, quantum, pad } = payload;
|
|
280
|
+
const { min, max, mode, step, quantum, pad, prefix, suffix } = payload;
|
|
281
|
+
const affixes = {
|
|
282
|
+
...(prefix !== undefined ? { prefix } : {}),
|
|
283
|
+
...(suffix !== undefined ? { suffix } : {}),
|
|
284
|
+
};
|
|
210
285
|
if (max < min) {
|
|
211
286
|
throw new DfnError(
|
|
212
287
|
'range needs max >= min',
|
|
@@ -229,7 +304,16 @@ class Parser {
|
|
|
229
304
|
token.column,
|
|
230
305
|
);
|
|
231
306
|
}
|
|
232
|
-
return {
|
|
307
|
+
return {
|
|
308
|
+
kind: 'range',
|
|
309
|
+
min,
|
|
310
|
+
max,
|
|
311
|
+
mode: 'stepped',
|
|
312
|
+
step,
|
|
313
|
+
pad,
|
|
314
|
+
...affixes,
|
|
315
|
+
...at,
|
|
316
|
+
};
|
|
233
317
|
}
|
|
234
318
|
if (step <= 1) {
|
|
235
319
|
throw new DfnError(
|
|
@@ -246,6 +330,7 @@ class Parser {
|
|
|
246
330
|
step,
|
|
247
331
|
quantum,
|
|
248
332
|
pad,
|
|
333
|
+
...affixes,
|
|
249
334
|
...at,
|
|
250
335
|
};
|
|
251
336
|
}
|
|
@@ -269,8 +354,8 @@ class Parser {
|
|
|
269
354
|
name = this.expect('ident', 'a production name');
|
|
270
355
|
}
|
|
271
356
|
// Set modifiers: `[a, b]` picks only the listed members; `![a, b]` omits them.
|
|
272
|
-
const pick: string
|
|
273
|
-
const omit: string
|
|
357
|
+
const pick: Array<string | Ref> = [];
|
|
358
|
+
const omit: Array<string | Ref> = [];
|
|
274
359
|
const negated = this.peek().kind === 'bang';
|
|
275
360
|
if (negated) this.next();
|
|
276
361
|
if (this.peek().kind === 'lbracket') {
|
package/src/resolve.ts
CHANGED
|
@@ -42,6 +42,8 @@ function resolveSpecifier(spec: string, fromDir: string): string {
|
|
|
42
42
|
export function resolveModule(
|
|
43
43
|
path: string,
|
|
44
44
|
inProgress: Set<string> = new Set(),
|
|
45
|
+
/** In-memory source overrides by absolute path — how `lint --fix` validates before writing. */
|
|
46
|
+
sources?: Map<string, string>,
|
|
45
47
|
): ResolvedModule {
|
|
46
48
|
const absolute = resolve(path);
|
|
47
49
|
if (inProgress.has(absolute)) {
|
|
@@ -50,10 +52,15 @@ export function resolveModule(
|
|
|
50
52
|
inProgress.add(absolute);
|
|
51
53
|
|
|
52
54
|
let source: string;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
const override = sources?.get(absolute);
|
|
56
|
+
if (override !== undefined) {
|
|
57
|
+
source = override;
|
|
58
|
+
} else {
|
|
59
|
+
try {
|
|
60
|
+
source = readFileSync(absolute, 'utf8');
|
|
61
|
+
} catch {
|
|
62
|
+
throw new DfnError(`cannot read ${absolute}`, 1, 1);
|
|
63
|
+
}
|
|
57
64
|
}
|
|
58
65
|
let module: Module;
|
|
59
66
|
try {
|
|
@@ -70,7 +77,7 @@ export function resolveModule(
|
|
|
70
77
|
const imports = new Map<string, ResolvedModule>();
|
|
71
78
|
for (const { spec, alias } of module.uses) {
|
|
72
79
|
const target = resolveSpecifier(spec, dirname(absolute));
|
|
73
|
-
const resolved = resolveModule(target, inProgress);
|
|
80
|
+
const resolved = resolveModule(target, inProgress, sources);
|
|
74
81
|
const key = alias ?? resolved.name;
|
|
75
82
|
if (imports.has(key)) {
|
|
76
83
|
throw new DfnError(
|
package/src/tokenizer.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type TokenKind =
|
|
|
26
26
|
| 'comma'
|
|
27
27
|
| 'bang'
|
|
28
28
|
| 'slash'
|
|
29
|
+
| 'colon'
|
|
29
30
|
| 'newline'
|
|
30
31
|
| 'eof';
|
|
31
32
|
|
|
@@ -42,13 +43,24 @@ export interface Token {
|
|
|
42
43
|
quantum?: number;
|
|
43
44
|
/** Pad width inferred from a leading zero on a written endpoint (`025` → 3). */
|
|
44
45
|
pad?: number;
|
|
46
|
+
/** Affixes from the parenthesized form: `x(2-8/2)s` → prefix `x`, suffix `s`. */
|
|
47
|
+
prefix?: string;
|
|
48
|
+
suffix?: string;
|
|
45
49
|
};
|
|
46
50
|
line: number;
|
|
47
51
|
column: number;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
const IDENT = /^[A-Za-z][A-Za-z0-9-]*/;
|
|
51
|
-
|
|
55
|
+
/** The step is optional: `2-4` is an additive scale stepped by 1. */
|
|
56
|
+
const RANGE = /^(\d+)-(\d+)(?:([*/])(\d+(?:\.\d+)?))?(?:~(\d+))?/;
|
|
57
|
+
/**
|
|
58
|
+
* An AFFIXED scale: word fragments hugging a parenthesized formula, as ONE term —
|
|
59
|
+
* `(2-4)xs`, `xs(2-4)`, `x(2-8/2)s`. The parentheses are what make the affix
|
|
60
|
+
* boundaries readable, so a bare range never takes affixes.
|
|
61
|
+
*/
|
|
62
|
+
const AFFIXED =
|
|
63
|
+
/^([A-Za-z][A-Za-z0-9-]*)?\((\d+)-(\d+)(?:([*/])(\d+(?:\.\d+)?))?(?:~(\d+))?\)([A-Za-z][A-Za-z0-9-]*)?/;
|
|
52
64
|
/**
|
|
53
65
|
* A digit-leading NAME (`2xs`, `4k-display`): digits, then at least one letter. Tried after
|
|
54
66
|
* RANGE — so `100-300/50` stays numeric — and before NUMBER, so the whole word is one token
|
|
@@ -67,6 +79,9 @@ function padWidth(...literals: string[]): number | undefined {
|
|
|
67
79
|
export function tokenize(source: string): Token[] {
|
|
68
80
|
const tokens: Token[] = [];
|
|
69
81
|
const lines = source.split('\n');
|
|
82
|
+
// Open `[` positions. A statement ends at the first newline at bracket depth 0, so while
|
|
83
|
+
// this stack is non-empty a group is a BLOCK — newlines inside it are insignificant.
|
|
84
|
+
const openBrackets: Array<{ line: number; column: number }> = [];
|
|
70
85
|
|
|
71
86
|
for (let i = 0; i < lines.length; i++) {
|
|
72
87
|
const line = i + 1;
|
|
@@ -99,6 +114,28 @@ export function tokenize(source: string): Token[] {
|
|
|
99
114
|
text = text.slice(value.length);
|
|
100
115
|
};
|
|
101
116
|
|
|
117
|
+
const affixed = text.match(AFFIXED);
|
|
118
|
+
if (affixed) {
|
|
119
|
+
tokens.push({
|
|
120
|
+
kind: 'range',
|
|
121
|
+
value: affixed[0],
|
|
122
|
+
range: {
|
|
123
|
+
min: Number(affixed[2]),
|
|
124
|
+
max: Number(affixed[3]),
|
|
125
|
+
mode: (affixed[4] ?? '/') as '/' | '*',
|
|
126
|
+
step: affixed[5] ? Number(affixed[5]) : 1,
|
|
127
|
+
quantum: affixed[6] ? Number(affixed[6]) : undefined,
|
|
128
|
+
pad: padWidth(affixed[2], affixed[3]),
|
|
129
|
+
prefix: affixed[1] || undefined,
|
|
130
|
+
suffix: affixed[7] || undefined,
|
|
131
|
+
},
|
|
132
|
+
line,
|
|
133
|
+
column,
|
|
134
|
+
});
|
|
135
|
+
column += affixed[0].length;
|
|
136
|
+
text = text.slice(affixed[0].length);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
102
139
|
const range = text.match(RANGE);
|
|
103
140
|
if (range) {
|
|
104
141
|
tokens.push({
|
|
@@ -107,8 +144,8 @@ export function tokenize(source: string): Token[] {
|
|
|
107
144
|
range: {
|
|
108
145
|
min: Number(range[1]),
|
|
109
146
|
max: Number(range[2]),
|
|
110
|
-
mode: range[3] as '/' | '*',
|
|
111
|
-
step: Number(range[4]),
|
|
147
|
+
mode: (range[3] ?? '/') as '/' | '*',
|
|
148
|
+
step: range[4] ? Number(range[4]) : 1,
|
|
112
149
|
quantum: range[5] ? Number(range[5]) : undefined,
|
|
113
150
|
pad: padWidth(range[1], range[2]),
|
|
114
151
|
},
|
|
@@ -158,13 +195,28 @@ export function tokenize(source: string): Token[] {
|
|
|
158
195
|
',': 'comma',
|
|
159
196
|
'!': 'bang',
|
|
160
197
|
'/': 'slash',
|
|
198
|
+
':': 'colon',
|
|
161
199
|
};
|
|
162
200
|
const kind = single[text[0]];
|
|
163
201
|
if (!kind) throw new DfnError(`unexpected '${text[0]}'`, line, column);
|
|
202
|
+
if (kind === 'lbracket') openBrackets.push({ line, column });
|
|
203
|
+
if (kind === 'rbracket') openBrackets.pop();
|
|
164
204
|
push(kind, text[0]);
|
|
165
205
|
}
|
|
166
206
|
|
|
167
|
-
|
|
207
|
+
// Inside a block, a newline is not a statement boundary.
|
|
208
|
+
if (openBrackets.length === 0) {
|
|
209
|
+
tokens.push({ kind: 'newline', value: '\n', line, column });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const unclosed = openBrackets[0];
|
|
214
|
+
if (unclosed) {
|
|
215
|
+
throw new DfnError(
|
|
216
|
+
`unclosed '[' opened at ${unclosed.line}:${unclosed.column}`,
|
|
217
|
+
unclosed.line,
|
|
218
|
+
unclosed.column,
|
|
219
|
+
);
|
|
168
220
|
}
|
|
169
221
|
|
|
170
222
|
tokens.push({ kind: 'eof', value: '', line: lines.length + 1, column: 1 });
|