@vertekum/schema-builder 0.1.6 → 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 +28 -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 +3 -3
- package/src/api.ts +16 -3
- package/src/ast.ts +23 -9
- package/src/build.ts +224 -28
- 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/emit.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import type { TreeNode } from './build';
|
|
1
|
+
import type { PatternRef, TreeNode } from './build';
|
|
2
|
+
import type { ResolvedModule } from './resolve';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Emission — one content rule for every file:
|
|
6
|
+
*
|
|
7
|
+
* - PUBLIC productions emit as `$defs` PATTERNS: open at the top (`properties` +
|
|
8
|
+
* `patternProperties`, no seal), sealed as normal inside. A pattern never seals —
|
|
9
|
+
* sealing belongs to the positions that apply patterns.
|
|
10
|
+
* - `root` emits as the document's `properties` — the definitive syntagma, sealed per the
|
|
11
|
+
* `sealed` pragma. In a def-scope file the root ALSO lands as `$defs.<filename>` and the
|
|
12
|
+
* document body applies it by reference, so the file stands alone AND serves as a pattern.
|
|
13
|
+
* - A position that received a pattern's full member set references it (`allOf` of `$ref`s
|
|
14
|
+
* with the seal above); a merge that grew a member drops back to expansion (deep-checked).
|
|
15
|
+
* - The empty sealed leaf emits once as `$defs.terminal`, referenced from every leaf.
|
|
9
16
|
*/
|
|
10
17
|
|
|
11
18
|
export const STAMP_PREFIX = 'built by @vertekum/schema-builder from';
|
|
@@ -16,94 +23,211 @@ export function stamp(moduleFile: string): string {
|
|
|
16
23
|
|
|
17
24
|
type Schema = Record<string, unknown>;
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
const TERMINAL = 'terminal';
|
|
27
|
+
const PATTERN_VALVE = { '^\\$': true };
|
|
28
|
+
|
|
29
|
+
export interface EmitOptions {
|
|
30
|
+
/** The module filename for the provenance stamp, e.g. `color.dfn`. */
|
|
31
|
+
moduleFile: string;
|
|
32
|
+
/** Explicit `id` pragma; wins over `schemaId`. */
|
|
33
|
+
id?: string;
|
|
34
|
+
/** Derived `$id` (configured base + artifact path) when no pragma id. */
|
|
35
|
+
schemaId?: string;
|
|
36
|
+
title?: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
/** The file's nature (inline never emits). */
|
|
39
|
+
scopeKind: 'document' | 'def';
|
|
40
|
+
/** The document top's seal (`sealed` pragma; default true). */
|
|
41
|
+
sealed: boolean;
|
|
42
|
+
/** Public patterns in declaration order: def name → bare tree. */
|
|
43
|
+
patterns: Map<string, TreeNode>;
|
|
44
|
+
/** def-scope with a root: the root's def name (the filename). */
|
|
45
|
+
rootDefName?: string;
|
|
46
|
+
/** Bare tree for a pattern reference; undefined = expand instead. */
|
|
47
|
+
bare?: (ref: PatternRef) => TreeNode | undefined;
|
|
48
|
+
/** Linked mode: the artifact path a cross-module `$ref` should use; undefined = inline. */
|
|
49
|
+
linkResolve?: (module: ResolvedModule) => string | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Structural equality of name trees — what "the pattern is still intact here" means. */
|
|
53
|
+
function sameTree(a: TreeNode, b: TreeNode): boolean {
|
|
54
|
+
if (a.open !== b.open) return false;
|
|
55
|
+
if (a.children.size !== b.children.size) return false;
|
|
56
|
+
for (const [name, child] of a.children) {
|
|
57
|
+
const other = b.children.get(name);
|
|
58
|
+
if (!other || !sameTree(child, other)) return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Context {
|
|
64
|
+
options: EmitOptions;
|
|
65
|
+
/** Local def names that exist (patterns + rootDefName) — what `#/$defs/…` may target. */
|
|
66
|
+
localDefs: Set<string>;
|
|
67
|
+
/** Set when any leaf referenced `$defs.terminal` (decides whether it is emitted). */
|
|
68
|
+
usedTerminal: { value: boolean };
|
|
69
|
+
/** Suppress the terminal ref while emitting the terminal def's own body. */
|
|
70
|
+
inTerminal: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The `$ref` string for a pattern, or undefined when it must expand. */
|
|
74
|
+
function refTargetOf(ref: PatternRef, ctx: Context): string | undefined {
|
|
75
|
+
if (!ref.module) {
|
|
76
|
+
return ctx.localDefs.has(ref.name) ? `#/$defs/${ref.name}` : undefined;
|
|
77
|
+
}
|
|
78
|
+
const artifact = ctx.options.linkResolve?.(ref.module);
|
|
79
|
+
if (!artifact) return undefined;
|
|
80
|
+
if (ref.root) {
|
|
81
|
+
// A def module's ROOT is referenced as the file itself — the file IS the pattern —
|
|
82
|
+
// UNLESS the author sealed the file: then the whole-file ref would drag the seal
|
|
83
|
+
// into the consumer's composition, so the pointer reaches the unsealed def instead.
|
|
84
|
+
const sealed = ref.module.module.meta.sealed ?? false;
|
|
85
|
+
return sealed ? `${artifact}#/$defs/root` : artifact;
|
|
22
86
|
}
|
|
23
|
-
|
|
87
|
+
return `${artifact}#/$defs/${ref.name}`;
|
|
24
88
|
}
|
|
25
89
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Emit one position. `seal: false` only for a pattern def's own top — everything below
|
|
92
|
+
* seals as normal.
|
|
93
|
+
*/
|
|
94
|
+
function position(node: TreeNode, ctx: Context, seal: boolean): Schema {
|
|
95
|
+
// Linked document-child: verbatim top-level branch of a sealed artifact — bare ref.
|
|
96
|
+
if (node.link && ctx.options.linkResolve) {
|
|
97
|
+
const artifact = ctx.options.linkResolve(node.link.module);
|
|
98
|
+
if (artifact) {
|
|
99
|
+
return { $ref: `${artifact}#/properties/${node.link.top}` };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The empty sealed leaf, deduped: reference `$defs.terminal`.
|
|
104
|
+
if (
|
|
105
|
+
seal &&
|
|
106
|
+
!ctx.inTerminal &&
|
|
107
|
+
!node.open &&
|
|
108
|
+
node.children.size === 0 &&
|
|
109
|
+
ctx.localDefs.has(TERMINAL)
|
|
110
|
+
) {
|
|
111
|
+
ctx.usedTerminal.value = true;
|
|
112
|
+
return { $ref: `#/$defs/${TERMINAL}` };
|
|
29
113
|
}
|
|
114
|
+
|
|
115
|
+
// Patterns whose member sets are still intact here become `$ref`s; their members leave
|
|
116
|
+
// `properties`. Open positions always expand — additions need the members spelled out.
|
|
117
|
+
const refs: string[] = [];
|
|
118
|
+
const covered = new Set<string>();
|
|
119
|
+
if (node.patterns && !node.open) {
|
|
120
|
+
for (const ref of node.patterns.values()) {
|
|
121
|
+
const target = refTargetOf(ref, ctx);
|
|
122
|
+
const bare = ctx.options.bare?.(ref);
|
|
123
|
+
if (!target || !bare || bare.children.size === 0) continue;
|
|
124
|
+
let intact = true;
|
|
125
|
+
for (const [name, member] of bare.children) {
|
|
126
|
+
const mine = node.children.get(name);
|
|
127
|
+
if (!mine || !sameTree(member, mine)) {
|
|
128
|
+
intact = false;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!intact) continue;
|
|
133
|
+
refs.push(target);
|
|
134
|
+
for (const name of bare.children.keys()) covered.add(name);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
30
138
|
const properties: Schema = {};
|
|
31
139
|
for (const [name, child] of node.children) {
|
|
32
|
-
|
|
140
|
+
if (covered.has(name)) continue;
|
|
141
|
+
properties[name] = position(child, ctx, true);
|
|
33
142
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
143
|
+
|
|
144
|
+
const schema: Schema = {};
|
|
145
|
+
if (refs.length > 0) schema.allOf = refs.map(($ref) => ({ $ref }));
|
|
146
|
+
schema.type = 'object';
|
|
147
|
+
if (Object.keys(properties).length > 0 || refs.length === 0) {
|
|
148
|
+
schema.properties = properties;
|
|
149
|
+
}
|
|
150
|
+
schema.patternProperties = { ...PATTERN_VALVE };
|
|
39
151
|
if (node.open) {
|
|
40
|
-
// Additions join the set: they take the same tail every listed member has.
|
|
41
|
-
// it by construction (`*` is restricted to name-only sets), so the first child is the shape.
|
|
152
|
+
// Additions join the set: they take the same tail every listed member has.
|
|
42
153
|
const first = node.children.values().next().value as TreeNode | undefined;
|
|
43
154
|
schema.additionalProperties = first
|
|
44
|
-
? position(first,
|
|
45
|
-
: { type: 'object', patternProperties: {
|
|
46
|
-
} else {
|
|
155
|
+
? position(first, ctx, true)
|
|
156
|
+
: { type: 'object', patternProperties: { ...PATTERN_VALVE } };
|
|
157
|
+
} else if (seal) {
|
|
47
158
|
schema.unevaluatedProperties = false;
|
|
48
159
|
}
|
|
49
160
|
return schema;
|
|
50
161
|
}
|
|
51
162
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
163
|
+
export function emit(tree: TreeNode | undefined, options: EmitOptions): string {
|
|
164
|
+
const localDefs = new Set<string>(options.patterns.keys());
|
|
165
|
+
if (options.rootDefName) localDefs.add(options.rootDefName);
|
|
166
|
+
// The terminal dedupe steps aside on the rare production named `terminal`.
|
|
167
|
+
const terminalAvailable = !localDefs.has(TERMINAL);
|
|
168
|
+
if (terminalAvailable) localDefs.add(TERMINAL);
|
|
57
169
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
description?: string;
|
|
65
|
-
scope?: 'document' | 'branch';
|
|
66
|
-
}
|
|
170
|
+
const ctx: Context = {
|
|
171
|
+
options,
|
|
172
|
+
localDefs,
|
|
173
|
+
usedTerminal: { value: false },
|
|
174
|
+
inTerminal: false,
|
|
175
|
+
};
|
|
67
176
|
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
177
|
+
// Bodies first — they decide whether `$defs.terminal` is needed at all.
|
|
178
|
+
const defBodies = new Map<string, Schema>();
|
|
179
|
+
for (const [name, bare] of options.patterns) {
|
|
180
|
+
defBodies.set(name, position(bare, ctx, false));
|
|
181
|
+
}
|
|
182
|
+
if (options.rootDefName && tree) {
|
|
183
|
+
defBodies.set(options.rootDefName, position(tree, ctx, false));
|
|
184
|
+
}
|
|
185
|
+
const body: Schema | undefined =
|
|
186
|
+
options.scopeKind === 'document' && tree
|
|
187
|
+
? position(tree, ctx, options.sealed)
|
|
188
|
+
: options.scopeKind === 'def' && options.rootDefName
|
|
189
|
+
? {
|
|
190
|
+
type: 'object',
|
|
191
|
+
allOf: [{ $ref: `#/$defs/${options.rootDefName}` }],
|
|
192
|
+
patternProperties: { ...PATTERN_VALVE },
|
|
193
|
+
...(options.sealed ? { unevaluatedProperties: false } : {}),
|
|
194
|
+
}
|
|
195
|
+
: undefined;
|
|
74
196
|
|
|
75
197
|
const document: Schema = {
|
|
76
198
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
77
199
|
};
|
|
78
|
-
|
|
200
|
+
const id = options.id ?? options.schemaId;
|
|
201
|
+
if (id) document.$id = id;
|
|
79
202
|
document.$comment = stamp(options.moduleFile);
|
|
80
203
|
if (options.title) document.title = options.title;
|
|
81
204
|
if (options.description) document.description = options.description;
|
|
82
205
|
|
|
83
|
-
if (
|
|
206
|
+
if (defBodies.size > 0 || ctx.usedTerminal.value) {
|
|
84
207
|
const defs: Schema = {};
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
};
|
|
95
|
-
collect(tree);
|
|
208
|
+
if (ctx.usedTerminal.value && terminalAvailable) {
|
|
209
|
+
defs[TERMINAL] = {
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: {},
|
|
212
|
+
patternProperties: { ...PATTERN_VALVE },
|
|
213
|
+
unevaluatedProperties: false,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
for (const [name, defBody] of defBodies) defs[name] = defBody;
|
|
96
217
|
document.$defs = defs;
|
|
97
218
|
}
|
|
98
219
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
220
|
+
if (body) {
|
|
221
|
+
if (body.allOf) document.allOf = body.allOf;
|
|
222
|
+
document.type = body.type;
|
|
223
|
+
if (body.properties) document.properties = body.properties;
|
|
224
|
+
document.patternProperties = body.patternProperties;
|
|
225
|
+
if (body.additionalProperties !== undefined) {
|
|
226
|
+
document.additionalProperties = body.additionalProperties;
|
|
227
|
+
}
|
|
228
|
+
if (body.unevaluatedProperties !== undefined) {
|
|
229
|
+
document.unevaluatedProperties = body.unevaluatedProperties;
|
|
230
|
+
}
|
|
107
231
|
}
|
|
108
232
|
|
|
109
233
|
return `${JSON.stringify(document, null, 2)}\n`;
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { type Token, tokenize } from './tokenizer';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `.dfn` formatter: JS-literal block formatting with authorial break choices.
|
|
7
|
+
*
|
|
8
|
+
* Inline vs block is the author's call — a `[` with content on its line stays inline, a `[`
|
|
9
|
+
* followed by a newline is a block. The formatter owns everything inside that choice: block
|
|
10
|
+
* indentation (+1 unit per level, closer dedented to its opener's line), canonical token
|
|
11
|
+
* spacing, comment placement, and whitespace hygiene. It never moves a line break.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface FormatOptions {
|
|
15
|
+
/** One indent unit (a run of spaces, or '\t'). Default two spaces. */
|
|
16
|
+
indent: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Micro-spacing is canonical — one style, no knobs. The seam exists so a knob could. */
|
|
20
|
+
const DEFAULT_OPTIONS: FormatOptions = { indent: ' ' };
|
|
21
|
+
|
|
22
|
+
/** Split one source line into its code part and its `#` comment (strings respected). */
|
|
23
|
+
function splitComment(line: string): { code: string; comment: string | null } {
|
|
24
|
+
let inString = false;
|
|
25
|
+
for (let i = 0; i < line.length; i++) {
|
|
26
|
+
const ch = line[i];
|
|
27
|
+
if (ch === '"') inString = !inString;
|
|
28
|
+
if (ch === '#' && !inString) {
|
|
29
|
+
return { code: line.slice(0, i), comment: line.slice(i).trimEnd() };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { code: line, comment: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The token's source text, re-quoted for strings. */
|
|
36
|
+
function text(token: Token): string {
|
|
37
|
+
return token.kind === 'string' ? `"${token.value}"` : token.value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const TIGHT_AFTER = new Set([
|
|
41
|
+
'dot',
|
|
42
|
+
'langle',
|
|
43
|
+
'at',
|
|
44
|
+
'slash',
|
|
45
|
+
'bang',
|
|
46
|
+
'lbracket',
|
|
47
|
+
'colon',
|
|
48
|
+
]);
|
|
49
|
+
const TIGHT_BEFORE = new Set([
|
|
50
|
+
'dot',
|
|
51
|
+
'comma',
|
|
52
|
+
'question',
|
|
53
|
+
'rangle',
|
|
54
|
+
'rbracket',
|
|
55
|
+
'slash',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/** The separator between two adjacent tokens on one line. */
|
|
59
|
+
function separator(prev: Token, cur: Token, next: Token | undefined): string {
|
|
60
|
+
// `*` is tight inside a reference (`<x*>`), spaced before a group closer (`[a | b *]`).
|
|
61
|
+
if (cur.kind === 'star') return next?.kind === 'rangle' ? '' : ' ';
|
|
62
|
+
if (prev.kind === 'star') return ''; // always immediately before its closer
|
|
63
|
+
if (TIGHT_AFTER.has(prev.kind)) return '';
|
|
64
|
+
if (TIGHT_BEFORE.has(cur.kind)) return '';
|
|
65
|
+
return ' ';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Re-emit one line's tokens under the canonical spacing rules. */
|
|
69
|
+
function printTokens(tokens: Token[]): string {
|
|
70
|
+
let out = '';
|
|
71
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
72
|
+
if (i > 0) out += separator(tokens[i - 1], tokens[i], tokens[i + 1]);
|
|
73
|
+
out += text(tokens[i]);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Format a module's source. Throws (a DfnError from the tokenizer) when the source cannot be
|
|
80
|
+
* lexed — a broken module is lint's report; the formatter never rewrites what it cannot read.
|
|
81
|
+
*/
|
|
82
|
+
export function formatSource(
|
|
83
|
+
source: string,
|
|
84
|
+
options: FormatOptions = DEFAULT_OPTIONS,
|
|
85
|
+
): string {
|
|
86
|
+
// One pass over the whole source: per-line token groups, and which bracket tokens belong to
|
|
87
|
+
// BLOCK pairs (opener and closer on different lines) — only those contribute indent levels.
|
|
88
|
+
const tokens = tokenize(source);
|
|
89
|
+
const byLine = new Map<number, Token[]>();
|
|
90
|
+
const blockBrackets = new Set<Token>();
|
|
91
|
+
const opens: Token[] = [];
|
|
92
|
+
for (const token of tokens) {
|
|
93
|
+
if (token.kind === 'newline' || token.kind === 'eof') continue;
|
|
94
|
+
const list = byLine.get(token.line) ?? [];
|
|
95
|
+
list.push(token);
|
|
96
|
+
byLine.set(token.line, list);
|
|
97
|
+
if (token.kind === 'lbracket') opens.push(token);
|
|
98
|
+
if (token.kind === 'rbracket') {
|
|
99
|
+
const open = opens.pop();
|
|
100
|
+
if (open && open.line !== token.line) {
|
|
101
|
+
blockBrackets.add(open);
|
|
102
|
+
blockBrackets.add(token);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const lines = source.split('\n');
|
|
108
|
+
const out: string[] = [];
|
|
109
|
+
let level = 0;
|
|
110
|
+
let blankRun = 0;
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < lines.length; i++) {
|
|
113
|
+
const { comment } = splitComment(lines[i]);
|
|
114
|
+
const lineTokens = byLine.get(i + 1) ?? [];
|
|
115
|
+
|
|
116
|
+
if (lineTokens.length === 0 && comment === null) {
|
|
117
|
+
blankRun += 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// Runs of blank lines collapse to one; none at the start of the file.
|
|
121
|
+
if (blankRun > 0 && out.length > 0) out.push('');
|
|
122
|
+
blankRun = 0;
|
|
123
|
+
|
|
124
|
+
// A leading run of block closers sits at its openers' level, one step back each.
|
|
125
|
+
let leadingClosers = 0;
|
|
126
|
+
for (const token of lineTokens) {
|
|
127
|
+
if (token.kind === 'rbracket' && blockBrackets.has(token))
|
|
128
|
+
leadingClosers += 1;
|
|
129
|
+
else break;
|
|
130
|
+
}
|
|
131
|
+
const startLevel = Math.max(0, level - leadingClosers);
|
|
132
|
+
for (const token of lineTokens) {
|
|
133
|
+
if (!blockBrackets.has(token)) continue;
|
|
134
|
+
if (token.kind === 'lbracket') level += 1;
|
|
135
|
+
if (token.kind === 'rbracket') level = Math.max(0, level - 1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const indent = options.indent.repeat(startLevel);
|
|
139
|
+
const code = printTokens(lineTokens);
|
|
140
|
+
if (comment !== null) {
|
|
141
|
+
out.push(
|
|
142
|
+
code.length > 0
|
|
143
|
+
? `${indent}${code} ${comment}`
|
|
144
|
+
: `${indent}${comment}`,
|
|
145
|
+
);
|
|
146
|
+
} else {
|
|
147
|
+
out.push(indent + code);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return `${out.join('\n')}\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the indent unit for a `.dfn` file: the project's `format.indent` when set, else the
|
|
156
|
+
* nearest `.editorconfig` (`[*.dfn]` over `[*]`, closest file wins, `root = true` stops the
|
|
157
|
+
* walk), else two spaces.
|
|
158
|
+
*/
|
|
159
|
+
export function resolveIndent(
|
|
160
|
+
filePath: string,
|
|
161
|
+
projectIndent?: string | number,
|
|
162
|
+
): string {
|
|
163
|
+
if (typeof projectIndent === 'number') return ' '.repeat(projectIndent);
|
|
164
|
+
if (typeof projectIndent === 'string') return projectIndent;
|
|
165
|
+
|
|
166
|
+
let dir = dirname(filePath);
|
|
167
|
+
const configs: string[] = [];
|
|
168
|
+
for (;;) {
|
|
169
|
+
const candidate = join(dir, '.editorconfig');
|
|
170
|
+
if (existsSync(candidate)) {
|
|
171
|
+
const content = readFileSync(candidate, 'utf8');
|
|
172
|
+
configs.push(content);
|
|
173
|
+
if (/^\s*root\s*=\s*true\s*$/im.test(content)) break;
|
|
174
|
+
}
|
|
175
|
+
const parent = dirname(dir);
|
|
176
|
+
if (parent === dir) break;
|
|
177
|
+
dir = parent;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Farthest file first, so closer files (and later sections within a file) override.
|
|
181
|
+
let style: string | undefined;
|
|
182
|
+
let size: string | undefined;
|
|
183
|
+
for (const content of configs.reverse()) {
|
|
184
|
+
let applies = false;
|
|
185
|
+
for (const raw of content.split('\n')) {
|
|
186
|
+
const line = raw.trim();
|
|
187
|
+
const section = line.match(/^\[(.+)\]$/);
|
|
188
|
+
if (section) {
|
|
189
|
+
const glob = section[1];
|
|
190
|
+
applies =
|
|
191
|
+
glob === '*' ||
|
|
192
|
+
glob === '**' ||
|
|
193
|
+
/(^|[*{,])dfn[},]?$|\*\.dfn/.test(glob);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!applies) continue;
|
|
197
|
+
const pair = line.match(/^([A-Za-z_]+)\s*=\s*(.+)$/);
|
|
198
|
+
if (!pair) continue;
|
|
199
|
+
if (pair[1] === 'indent_style') style = pair[2].trim();
|
|
200
|
+
if (pair[1] === 'indent_size') size = pair[2].trim();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (style === 'tab') return '\t';
|
|
205
|
+
const width = size !== undefined ? Number.parseInt(size, 10) : Number.NaN;
|
|
206
|
+
if (Number.isFinite(width) && width > 0) return ' '.repeat(width);
|
|
207
|
+
return DEFAULT_OPTIONS.indent;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** One applied repair. */
|
|
211
|
+
export interface DfnFix {
|
|
212
|
+
line: number;
|
|
213
|
+
column: number;
|
|
214
|
+
message: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Apply the mechanical, unambiguous repairs — currently one: a trailing open-set mark
|
|
219
|
+
* (`<name>*` → `<name*>`, `[a | b]*` → `[a | b *]`). Repaired lines are re-emitted through the
|
|
220
|
+
* canonical printer; untouched lines stay verbatim. A source that cannot be lexed fixes nothing.
|
|
221
|
+
*/
|
|
222
|
+
export function fixSource(source: string): {
|
|
223
|
+
content: string;
|
|
224
|
+
fixes: DfnFix[];
|
|
225
|
+
} {
|
|
226
|
+
let tokens: Token[];
|
|
227
|
+
try {
|
|
228
|
+
tokens = tokenize(source);
|
|
229
|
+
} catch {
|
|
230
|
+
return { content: source, fixes: [] };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const byLine = new Map<number, Token[]>();
|
|
234
|
+
for (const token of tokens) {
|
|
235
|
+
if (token.kind === 'newline' || token.kind === 'eof') continue;
|
|
236
|
+
const list = byLine.get(token.line) ?? [];
|
|
237
|
+
list.push(token);
|
|
238
|
+
byLine.set(token.line, list);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const fixes: DfnFix[] = [];
|
|
242
|
+
const lines = source.split('\n');
|
|
243
|
+
for (const [line, lineTokens] of byLine) {
|
|
244
|
+
let repaired = false;
|
|
245
|
+
for (let i = 0; i + 1 < lineTokens.length; i++) {
|
|
246
|
+
const closer = lineTokens[i];
|
|
247
|
+
const star = lineTokens[i + 1];
|
|
248
|
+
if (
|
|
249
|
+
(closer.kind === 'rangle' || closer.kind === 'rbracket') &&
|
|
250
|
+
star.kind === 'star'
|
|
251
|
+
) {
|
|
252
|
+
lineTokens[i] = star;
|
|
253
|
+
lineTokens[i + 1] = closer;
|
|
254
|
+
repaired = true;
|
|
255
|
+
fixes.push({
|
|
256
|
+
line,
|
|
257
|
+
column: star.column,
|
|
258
|
+
message: `moved '*' inside the ${closer.kind === 'rangle' ? 'reference' : 'group'} it opens`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (repaired) {
|
|
263
|
+
const raw = lines[line - 1];
|
|
264
|
+
const { comment } = splitComment(raw);
|
|
265
|
+
const leading = raw.match(/^\s*/)?.[0] ?? '';
|
|
266
|
+
const code = printTokens(lineTokens);
|
|
267
|
+
lines[line - 1] =
|
|
268
|
+
comment !== null ? `${leading}${code} ${comment}` : leading + code;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { content: lines.join('\n'), fixes };
|
|
273
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,37 @@
|
|
|
1
1
|
import { defineExtension, type ExtensionManifest } from '@vertekum/core';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
import { activate } from './api';
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The input/output pair, declared once in config:
|
|
7
|
+
*
|
|
8
|
+
* schemaBuilderExtension({ source: './src/dfn', out: './src/schemas' })
|
|
9
|
+
*
|
|
10
|
+
* `source` is where the `.dfn` modules live — the default sweep for `schema build`, `lint`,
|
|
11
|
+
* and `fmt`. `out` is where built `.json` files land, mirroring `source`'s directory
|
|
12
|
+
* structure; unset means beside each module. Both resolve relative to the working directory.
|
|
13
|
+
*/
|
|
14
|
+
export const SchemaBuilderSettings = z.object({
|
|
15
|
+
source: z.string().default('./schemas'),
|
|
16
|
+
out: z.string().optional(),
|
|
17
|
+
/**
|
|
18
|
+
* Linked emission: an unmodified `<@module>` root embedding `$ref`s the child module's own
|
|
19
|
+
* artifact instead of inlining a copy. Default false — self-contained artifacts.
|
|
20
|
+
*/
|
|
21
|
+
link: z.boolean().default(false),
|
|
22
|
+
/**
|
|
23
|
+
* Base URI for derived `$id`s: each artifact gets base + its artifact-relative path.
|
|
24
|
+
* A file's `id` pragma wins. Unset: no `$id` unless the pragma supplies one.
|
|
25
|
+
*/
|
|
26
|
+
schemaId: z.string().optional(),
|
|
27
|
+
});
|
|
28
|
+
|
|
4
29
|
export const schemaBuilderManifest = {
|
|
5
30
|
id: 'vtk.schema-builder',
|
|
6
31
|
name: 'Schema Builder',
|
|
7
32
|
description:
|
|
8
|
-
"Builds `.dfn` vocabulary modules — a grammar for declaring which token names may exist, and in what order — into the JSON Schema files the `schemas` config binds. Contributes the 'schema build'
|
|
33
|
+
"Builds `.dfn` vocabulary modules — a grammar for declaring which token names may exist, and in what order — into the JSON Schema files the `schemas` config binds. Contributes the 'schema build', 'schema lint', and 'schema fmt' commands. A non-view extension: no route, no ribbon entry.",
|
|
34
|
+
settings: SchemaBuilderSettings,
|
|
9
35
|
activation: ['onStartup'],
|
|
10
36
|
} satisfies ExtensionManifest;
|
|
11
37
|
|
package/src/lint.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface DfnDiagnostic {
|
|
|
18
18
|
line: number;
|
|
19
19
|
column: number;
|
|
20
20
|
message: string;
|
|
21
|
+
/** `error` blocks (exit 1); `warning` reports without failing. Absent = error. */
|
|
22
|
+
severity?: 'warning';
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
function toDiagnostic(file: string, error: unknown): DfnDiagnostic {
|
|
@@ -38,15 +40,30 @@ function toDiagnostic(file: string, error: unknown): DfnDiagnostic {
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/** Lint one module. Returns every finding; an empty array means the grammar is sound. */
|
|
41
|
-
export function lintModule(
|
|
43
|
+
export function lintModule(
|
|
44
|
+
path: string,
|
|
45
|
+
sources?: Map<string, string>,
|
|
46
|
+
): DfnDiagnostic[] {
|
|
42
47
|
let resolved: ReturnType<typeof resolveModule>;
|
|
43
48
|
try {
|
|
44
|
-
resolved = resolveModule(path);
|
|
49
|
+
resolved = resolveModule(path, new Set(), sources);
|
|
45
50
|
} catch (error) {
|
|
46
51
|
return [toDiagnostic(path, error)];
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
const out: DfnDiagnostic[] = [];
|
|
55
|
+
const warn = (
|
|
56
|
+
file: string,
|
|
57
|
+
line: number,
|
|
58
|
+
column: number,
|
|
59
|
+
message: string,
|
|
60
|
+
): void => {
|
|
61
|
+
out.push({ file, line, column, message, severity: 'warning' });
|
|
62
|
+
};
|
|
63
|
+
// Deprecated forms the parser honored (e.g. `scope "branch"`) surface as warnings.
|
|
64
|
+
for (const note of resolved.module.notes) {
|
|
65
|
+
warn(resolved.path, note.line, note.column, note.message);
|
|
66
|
+
}
|
|
50
67
|
const collect = (walk: () => void): void => {
|
|
51
68
|
try {
|
|
52
69
|
walk();
|
|
@@ -57,7 +74,7 @@ export function lintModule(path: string): DfnDiagnostic[] {
|
|
|
57
74
|
|
|
58
75
|
for (const [name, node] of resolved.module.productions) {
|
|
59
76
|
collect(() => {
|
|
60
|
-
evaluateProduction(resolved, name);
|
|
77
|
+
evaluateProduction(resolved, name, warn);
|
|
61
78
|
assertOpenSetsAreNameSets(resolved, node);
|
|
62
79
|
});
|
|
63
80
|
}
|
|
@@ -65,7 +82,7 @@ export function lintModule(path: string): DfnDiagnostic[] {
|
|
|
65
82
|
// `build` re-expands referenced productions, so a production's error can surface twice —
|
|
66
83
|
// once from its own walk, once through the root. The dedupe below keeps one.
|
|
67
84
|
collect(() => {
|
|
68
|
-
build(resolved);
|
|
85
|
+
build(resolved, { warn });
|
|
69
86
|
assertOpenSetsAreNameSets(resolved);
|
|
70
87
|
});
|
|
71
88
|
}
|