@vertekum/schema-builder 0.1.7 → 0.2.1

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 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 scope exist`,
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 !== 'document' && value !== 'branch') {
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 "branch", not "${value}"`,
115
+ `scope is "document", "def", or "inline", not "${value}"`,
98
116
  name.line,
99
117
  name.column,
100
118
  );
101
119
  }
102
- meta.scope = value;
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: an identifier or a (possibly padded) number. */
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 { kind: 'range', min, max, mode: 'stepped', step, pad, ...at };
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
- try {
54
- source = readFileSync(absolute, 'utf8');
55
- } catch {
56
- throw new DfnError(`cannot read ${absolute}`, 1, 1);
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
- const RANGE = /^(\d+)-(\d+)([*/])(\d+(?:\.\d+)?)(?:~(\d+))?/;
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
- tokens.push({ kind: 'newline', value: '\n', line, column });
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 });