@vertekum/schema-builder 0.1.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/src/parser.ts ADDED
@@ -0,0 +1,238 @@
1
+ import type { Alt, Module, ModuleMeta, Node, Path, Step } from './ast';
2
+ import { DfnError } from './error';
3
+ import { type Token, tokenize } from './tokenizer';
4
+
5
+ /**
6
+ * Recursive-descent parser for `.dfn` modules. Statements are line-oriented: `use "<spec>"` or
7
+ * `name = expression`. Precedence: `.` binds tighter than `|`; `?` is postfix on the preceding
8
+ * step; `*` sits before a closing `>` or `]` and marks the set open. `root` is the reserved
9
+ * production `build` materializes — stored apart so fragments-only modules stay valid.
10
+ */
11
+ export function parse(source: string): Module {
12
+ return new Parser(tokenize(source)).module();
13
+ }
14
+
15
+ class Parser {
16
+ private readonly tokens: Token[];
17
+ private index = 0;
18
+
19
+ constructor(tokens: Token[]) {
20
+ this.tokens = tokens;
21
+ }
22
+
23
+ private peek(): Token {
24
+ return this.tokens[this.index];
25
+ }
26
+
27
+ private at(offset: number): Token | undefined {
28
+ return this.tokens[this.index + offset];
29
+ }
30
+
31
+ private next(): Token {
32
+ return this.tokens[this.index++];
33
+ }
34
+
35
+ private expect(kind: Token['kind'], what: string): Token {
36
+ const token = this.next();
37
+ if (token.kind !== kind) {
38
+ throw new DfnError(
39
+ `expected ${what}, got '${token.value || token.kind}'`,
40
+ token.line,
41
+ token.column,
42
+ );
43
+ }
44
+ return token;
45
+ }
46
+
47
+ private skipNewlines(): void {
48
+ while (this.peek().kind === 'newline') this.next();
49
+ }
50
+
51
+ module(): Module {
52
+ const uses: string[] = [];
53
+ const productions = new Map<string, Node>();
54
+ const meta: ModuleMeta = {};
55
+ let root: Node | undefined;
56
+
57
+ this.skipNewlines();
58
+ while (this.peek().kind !== 'eof') {
59
+ const token = this.peek();
60
+ if (token.kind === 'use') {
61
+ this.next();
62
+ uses.push(this.expect('string', 'a quoted specifier').value);
63
+ } else if (token.kind === 'ident' && this.at(1)?.kind === 'string') {
64
+ // `<ident> "<string>"` (no `=`) is a PRAGMA — document metadata, not a production, so
65
+ // nothing is reserved: `title = a | b` still declares a production named title.
66
+ const name = this.next();
67
+ const value = this.next().value;
68
+ if (
69
+ name.value !== 'id' &&
70
+ name.value !== 'title' &&
71
+ name.value !== 'description' &&
72
+ name.value !== 'scope'
73
+ ) {
74
+ throw new DfnError(
75
+ `unknown pragma '${name.value}' — id, title, description, and scope exist`,
76
+ name.line,
77
+ name.column,
78
+ );
79
+ }
80
+ const key = name.value as keyof ModuleMeta;
81
+ if (meta[key] !== undefined) {
82
+ throw new DfnError(
83
+ `duplicate pragma '${name.value}'`,
84
+ name.line,
85
+ name.column,
86
+ );
87
+ }
88
+ if (key === 'scope') {
89
+ if (value !== 'document' && value !== 'branch') {
90
+ throw new DfnError(
91
+ `scope is "document" or "branch", not "${value}"`,
92
+ name.line,
93
+ name.column,
94
+ );
95
+ }
96
+ meta.scope = value;
97
+ } else {
98
+ meta[key] = value;
99
+ }
100
+ } else if (token.kind === 'ident') {
101
+ const name = this.next();
102
+ this.expect('equals', "'='");
103
+ const expression = this.alternation();
104
+ if (name.value === 'root') {
105
+ if (root) {
106
+ throw new DfnError(
107
+ 'a module declares one root',
108
+ name.line,
109
+ name.column,
110
+ );
111
+ }
112
+ root = expression;
113
+ } else {
114
+ if (productions.has(name.value)) {
115
+ throw new DfnError(
116
+ `duplicate production '${name.value}'`,
117
+ name.line,
118
+ name.column,
119
+ );
120
+ }
121
+ productions.set(name.value, expression);
122
+ }
123
+ } else {
124
+ throw new DfnError(
125
+ `expected a statement, got '${token.value || token.kind}'`,
126
+ token.line,
127
+ token.column,
128
+ );
129
+ }
130
+ if (this.peek().kind !== 'eof') this.expect('newline', 'end of line');
131
+ this.skipNewlines();
132
+ }
133
+
134
+ return { uses, productions, root, meta };
135
+ }
136
+
137
+ /** `a | b | c` — a single option collapses to itself. */
138
+ private alternation(): Node {
139
+ const options: Node[] = [this.path()];
140
+ while (this.peek().kind === 'pipe') {
141
+ this.next();
142
+ options.push(this.path());
143
+ }
144
+ if (options.length === 1) return options[0];
145
+ const alt: Alt = { kind: 'alt', options };
146
+ return alt;
147
+ }
148
+
149
+ /** `a.b.c` — a single step with no `?` collapses to its term. */
150
+ private path(): Node {
151
+ const steps: Step[] = [this.step()];
152
+ while (this.peek().kind === 'dot') {
153
+ this.next();
154
+ steps.push(this.step());
155
+ }
156
+ if (steps.length === 1 && !steps[0].optional) return steps[0].term;
157
+ const path: Path = { kind: 'path', steps };
158
+ return path;
159
+ }
160
+
161
+ private step(): Step {
162
+ const term = this.term();
163
+ let optional = false;
164
+ if (this.peek().kind === 'question') {
165
+ this.next();
166
+ optional = true;
167
+ }
168
+ return { term, optional };
169
+ }
170
+
171
+ private term(): Step['term'] {
172
+ const token = this.next();
173
+ switch (token.kind) {
174
+ case 'ident':
175
+ case 'number':
176
+ return { kind: 'name', value: token.value };
177
+ case 'range': {
178
+ const { min, max, step } = token.range as {
179
+ min: number;
180
+ max: number;
181
+ step: number;
182
+ };
183
+ if (step === 0 || max < min) {
184
+ throw new DfnError(
185
+ 'range needs max >= min and a non-zero step',
186
+ token.line,
187
+ token.column,
188
+ );
189
+ }
190
+ return { kind: 'range', min, max, step };
191
+ }
192
+ case 'langle': {
193
+ const imported = this.peek().kind === 'at';
194
+ if (imported) this.next();
195
+ const name = this.expect('ident', 'a production name');
196
+ // Set modifiers: `[a, b]` picks only the listed members; `![a, b]` omits them.
197
+ const pick: string[] = [];
198
+ const omit: string[] = [];
199
+ const negated = this.peek().kind === 'bang';
200
+ if (negated) this.next();
201
+ if (this.peek().kind === 'lbracket') {
202
+ const into = negated ? omit : pick;
203
+ this.next();
204
+ into.push(this.expect('ident', 'a member name').value);
205
+ while (this.peek().kind === 'comma') {
206
+ this.next();
207
+ into.push(this.expect('ident', 'a member name').value);
208
+ }
209
+ this.expect('rbracket', "']'");
210
+ } else if (negated) {
211
+ const bad = this.peek();
212
+ throw new DfnError(
213
+ "'!' must be followed by a [list]",
214
+ bad.line,
215
+ bad.column,
216
+ );
217
+ }
218
+ const open = this.peek().kind === 'star';
219
+ if (open) this.next();
220
+ this.expect('rangle', "'>'");
221
+ return { kind: 'ref', name: name.value, imported, open, pick, omit };
222
+ }
223
+ case 'lbracket': {
224
+ const node = this.alternation();
225
+ const open = this.peek().kind === 'star';
226
+ if (open) this.next();
227
+ this.expect('rbracket', "']'");
228
+ return { kind: 'group', node, open };
229
+ }
230
+ default:
231
+ throw new DfnError(
232
+ `expected a term, got '${token.value || token.kind}'`,
233
+ token.line,
234
+ token.column,
235
+ );
236
+ }
237
+ }
238
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+ import type { Module } from './ast';
5
+ import { DfnError } from './error';
6
+ import { parse } from './parser';
7
+
8
+ /** A parsed module plus its imports, keyed by module name (basename without `.dfn`). */
9
+ export interface ResolvedModule {
10
+ /** Absolute path of the module file. */
11
+ path: string;
12
+ /** Module name: basename without extension — how `<@name>` finds an imported root. */
13
+ name: string;
14
+ module: Module;
15
+ imports: Map<string, ResolvedModule>;
16
+ }
17
+
18
+ /**
19
+ * Resolve a `use` specifier from the importing module's location. Relative specifiers walk the
20
+ * filesystem; package specifiers (`@scope/pkg/file.dfn`) resolve the package root via its
21
+ * `package.json` and join the rest — `require.resolve` on the file itself would demand an exports
22
+ * entry per `.dfn`, which content packages should not need.
23
+ */
24
+ function resolveSpecifier(spec: string, fromDir: string): string {
25
+ if (spec.startsWith('.')) return resolve(fromDir, spec);
26
+ const require = createRequire(join(fromDir, 'noop.js'));
27
+ // Exports-aware first: a package may remap flat specifiers into folders
28
+ // (`"./*.dfn": "./dfn/*.dfn"`). Root-join second, for content packages with no map.
29
+ try {
30
+ return require.resolve(spec);
31
+ } catch {
32
+ const segments = spec.split('/');
33
+ const packageEnd = spec.startsWith('@') ? 2 : 1;
34
+ const packageName = segments.slice(0, packageEnd).join('/');
35
+ const rest = segments.slice(packageEnd).join('/');
36
+ const packageJson = require.resolve(`${packageName}/package.json`);
37
+ return join(dirname(packageJson), rest);
38
+ }
39
+ }
40
+
41
+ /** Parse a module file and, recursively, everything it `use`s. A cycle is an authoring error. */
42
+ export function resolveModule(
43
+ path: string,
44
+ inProgress: Set<string> = new Set(),
45
+ ): ResolvedModule {
46
+ const absolute = resolve(path);
47
+ if (inProgress.has(absolute)) {
48
+ throw new DfnError(`import cycle through ${basename(absolute)}`, 1, 1);
49
+ }
50
+ inProgress.add(absolute);
51
+
52
+ let source: string;
53
+ try {
54
+ source = readFileSync(absolute, 'utf8');
55
+ } catch {
56
+ throw new DfnError(`cannot read ${absolute}`, 1, 1);
57
+ }
58
+ const module = parse(source);
59
+
60
+ const imports = new Map<string, ResolvedModule>();
61
+ for (const spec of module.uses) {
62
+ const target = resolveSpecifier(spec, dirname(absolute));
63
+ const resolved = resolveModule(target, inProgress);
64
+ if (imports.has(resolved.name)) {
65
+ throw new DfnError(`two imports share the name '${resolved.name}'`, 1, 1);
66
+ }
67
+ imports.set(resolved.name, resolved);
68
+ }
69
+
70
+ inProgress.delete(absolute);
71
+ return {
72
+ path: absolute,
73
+ name: basename(absolute).replace(/\.dfn$/, ''),
74
+ module,
75
+ imports,
76
+ };
77
+ }
@@ -0,0 +1,140 @@
1
+ import { DfnError } from './error';
2
+
3
+ /**
4
+ * The `.dfn` lexer. Line-aware (statements are line-oriented), producing a flat token stream the
5
+ * parser walks. `#` comments run to end of line. A term starting with a digit is numeric — a
6
+ * NUMBER (`950`) or a RANGE (`100-300/50`) — so hyphens inside identifiers (`color-role`) never
7
+ * collide with range syntax.
8
+ */
9
+
10
+ export type TokenKind =
11
+ | 'ident'
12
+ | 'number'
13
+ | 'range'
14
+ | 'string'
15
+ | 'use'
16
+ | 'equals'
17
+ | 'dot'
18
+ | 'pipe'
19
+ | 'lbracket'
20
+ | 'rbracket'
21
+ | 'langle'
22
+ | 'rangle'
23
+ | 'at'
24
+ | 'star'
25
+ | 'question'
26
+ | 'comma'
27
+ | 'bang'
28
+ | 'newline'
29
+ | 'eof';
30
+
31
+ export interface Token {
32
+ kind: TokenKind;
33
+ /** Source text (for ident/number/string); ranges carry min/max/step instead. */
34
+ value: string;
35
+ range?: { min: number; max: number; step: number };
36
+ line: number;
37
+ column: number;
38
+ }
39
+
40
+ const IDENT = /^[A-Za-z][A-Za-z0-9-]*/;
41
+ const RANGE = /^(\d+)-(\d+)\/(\d+)/;
42
+ const NUMBER = /^\d+/;
43
+
44
+ export function tokenize(source: string): Token[] {
45
+ const tokens: Token[] = [];
46
+ const lines = source.split('\n');
47
+
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const line = i + 1;
50
+ let text = lines[i];
51
+ const commentAt = text.indexOf('#');
52
+ if (commentAt !== -1) text = text.slice(0, commentAt);
53
+
54
+ // Continuation by indentation: a line that starts with whitespace continues the statement
55
+ // above it, so a long expression can wrap without any continuation character.
56
+ if (
57
+ /^\s/.test(text) &&
58
+ text.trim().length > 0 &&
59
+ tokens[tokens.length - 1]?.kind === 'newline'
60
+ ) {
61
+ tokens.pop();
62
+ }
63
+
64
+ let column = 1;
65
+ while (text.length > 0) {
66
+ const ws = text.match(/^\s+/);
67
+ if (ws) {
68
+ column += ws[0].length;
69
+ text = text.slice(ws[0].length);
70
+ continue;
71
+ }
72
+
73
+ const push = (kind: TokenKind, value: string): void => {
74
+ tokens.push({ kind, value, line, column });
75
+ column += value.length;
76
+ text = text.slice(value.length);
77
+ };
78
+
79
+ const range = text.match(RANGE);
80
+ if (range) {
81
+ tokens.push({
82
+ kind: 'range',
83
+ value: range[0],
84
+ range: {
85
+ min: Number(range[1]),
86
+ max: Number(range[2]),
87
+ step: Number(range[3]),
88
+ },
89
+ line,
90
+ column,
91
+ });
92
+ column += range[0].length;
93
+ text = text.slice(range[0].length);
94
+ continue;
95
+ }
96
+ const number = text.match(NUMBER);
97
+ if (number) {
98
+ push('number', number[0]);
99
+ continue;
100
+ }
101
+ const ident = text.match(IDENT);
102
+ if (ident) {
103
+ push(ident[0] === 'use' ? 'use' : 'ident', ident[0]);
104
+ continue;
105
+ }
106
+ if (text.startsWith('"')) {
107
+ const end = text.indexOf('"', 1);
108
+ if (end === -1) throw new DfnError('unterminated string', line, column);
109
+ const raw = text.slice(0, end + 1);
110
+ tokens.push({ kind: 'string', value: raw.slice(1, -1), line, column });
111
+ column += raw.length;
112
+ text = text.slice(raw.length);
113
+ continue;
114
+ }
115
+
116
+ const single: Record<string, TokenKind> = {
117
+ '=': 'equals',
118
+ '.': 'dot',
119
+ '|': 'pipe',
120
+ '[': 'lbracket',
121
+ ']': 'rbracket',
122
+ '<': 'langle',
123
+ '>': 'rangle',
124
+ '@': 'at',
125
+ '*': 'star',
126
+ '?': 'question',
127
+ ',': 'comma',
128
+ '!': 'bang',
129
+ };
130
+ const kind = single[text[0]];
131
+ if (!kind) throw new DfnError(`unexpected '${text[0]}'`, line, column);
132
+ push(kind, text[0]);
133
+ }
134
+
135
+ tokens.push({ kind: 'newline', value: '\n', line, column });
136
+ }
137
+
138
+ tokens.push({ kind: 'eof', value: '', line: lines.length + 1, column: 1 });
139
+ return tokens;
140
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "resolveJsonModule": true
7
+ },
8
+ "include": ["src"]
9
+ }