@archcode-io/engine 0.2.0-preview.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 +31 -0
- package/LICENSE +202 -0
- package/README.md +88 -0
- package/dist/src/capacity.d.ts +67 -0
- package/dist/src/capacity.js +227 -0
- package/dist/src/check.d.ts +21 -0
- package/dist/src/check.js +192 -0
- package/dist/src/compiled.d.ts +61 -0
- package/dist/src/compiled.js +91 -0
- package/dist/src/cst.d.ts +76 -0
- package/dist/src/cst.js +1 -0
- package/dist/src/diagnostics.d.ts +9 -0
- package/dist/src/diagnostics.js +1 -0
- package/dist/src/edit.d.ts +48 -0
- package/dist/src/edit.js +442 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.js +24 -0
- package/dist/src/layout/balance.d.ts +34 -0
- package/dist/src/layout/balance.js +327 -0
- package/dist/src/layout/elk.d.ts +64 -0
- package/dist/src/layout/elk.js +267 -0
- package/dist/src/layout/host.d.ts +23 -0
- package/dist/src/layout/host.js +23 -0
- package/dist/src/layout/label.d.ts +49 -0
- package/dist/src/layout/label.js +113 -0
- package/dist/src/layout/measure.d.ts +11 -0
- package/dist/src/layout/measure.js +27 -0
- package/dist/src/layout/ortho.d.ts +54 -0
- package/dist/src/layout/ortho.js +206 -0
- package/dist/src/layout/route.d.ts +57 -0
- package/dist/src/layout/route.js +230 -0
- package/dist/src/lens.d.ts +83 -0
- package/dist/src/lens.js +377 -0
- package/dist/src/lexer.d.ts +7 -0
- package/dist/src/lexer.js +135 -0
- package/dist/src/model.d.ts +63 -0
- package/dist/src/model.js +114 -0
- package/dist/src/parser.d.ts +7 -0
- package/dist/src/parser.js +305 -0
- package/dist/src/render/svg.d.ts +56 -0
- package/dist/src/render/svg.js +289 -0
- package/dist/src/serialize.d.ts +10 -0
- package/dist/src/serialize.js +12 -0
- package/dist/src/tokens.d.ts +26 -0
- package/dist/src/tokens.js +15 -0
- package/dist/src/vocab.d.ts +38 -0
- package/dist/src/vocab.js +58 -0
- package/package.json +61 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Token } from './tokens.js';
|
|
2
|
+
/**
|
|
3
|
+
* Tokenize losslessly. Never throws: malformed input produces tokens plus
|
|
4
|
+
* diagnostics from the parser, because a broken file must still yield a
|
|
5
|
+
* partial model (D-58).
|
|
6
|
+
*/
|
|
7
|
+
export declare function lex(src: string): Token[];
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// `@` starts a word so that owner handles (`@team-sales`) are one token rather
|
|
2
|
+
// than a stray symbol followed by a name.
|
|
3
|
+
const isWordStart = (c) => /[A-Za-z_@]/.test(c);
|
|
4
|
+
const isWordChar = (c) => /[A-Za-z0-9_.\-]/.test(c);
|
|
5
|
+
const isDigit = (c) => c >= '0' && c <= '9';
|
|
6
|
+
/**
|
|
7
|
+
* Tokenize losslessly. Never throws: malformed input produces tokens plus
|
|
8
|
+
* diagnostics from the parser, because a broken file must still yield a
|
|
9
|
+
* partial model (D-58).
|
|
10
|
+
*/
|
|
11
|
+
export function lex(src) {
|
|
12
|
+
const out = [];
|
|
13
|
+
let i = 0, line = 1, col = 1;
|
|
14
|
+
const advance = (n) => {
|
|
15
|
+
for (let k = 0; k < n; k++) {
|
|
16
|
+
if (src[i] === '\n') {
|
|
17
|
+
line++;
|
|
18
|
+
col = 1;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
col++;
|
|
22
|
+
}
|
|
23
|
+
i++;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
while (true) {
|
|
27
|
+
// ---- trivia: spaces, tabs, carriage returns, line comments ----
|
|
28
|
+
const triviaStart = i;
|
|
29
|
+
for (;;) {
|
|
30
|
+
const c = src[i];
|
|
31
|
+
if (c === ' ' || c === '\t' || c === '\r') {
|
|
32
|
+
advance(1);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (c === '#') {
|
|
36
|
+
while (i < src.length && src[i] !== '\n')
|
|
37
|
+
advance(1);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
const trivia = src.slice(triviaStart, i);
|
|
43
|
+
const tokLine = line, tokCol = col, tokStart = i;
|
|
44
|
+
const push = (kind, text) => {
|
|
45
|
+
out.push({ kind, text, trivia, start: triviaStart, end: tokStart + text.length, line: tokLine, col: tokCol });
|
|
46
|
+
};
|
|
47
|
+
if (i >= src.length) {
|
|
48
|
+
push('eof', '');
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
const c = src[i];
|
|
52
|
+
if (c === '\n') {
|
|
53
|
+
advance(1);
|
|
54
|
+
push('newline', '\n');
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (c === '{') {
|
|
58
|
+
advance(1);
|
|
59
|
+
push('lbrace', '{');
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (c === '}') {
|
|
63
|
+
advance(1);
|
|
64
|
+
push('rbrace', '}');
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (c === ',') {
|
|
68
|
+
advance(1);
|
|
69
|
+
push('comma', ',');
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (c === '"') {
|
|
73
|
+
const s = i;
|
|
74
|
+
advance(1);
|
|
75
|
+
while (i < src.length && src[i] !== '"' && src[i] !== '\n') {
|
|
76
|
+
if (src[i] === '\\' && i + 1 < src.length)
|
|
77
|
+
advance(2);
|
|
78
|
+
else
|
|
79
|
+
advance(1);
|
|
80
|
+
}
|
|
81
|
+
if (src[i] === '"')
|
|
82
|
+
advance(1); // unterminated string is tolerated
|
|
83
|
+
push('string', src.slice(s, i));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// `+1y`, `+30d`: a relative moment is one value (§6.3), the sign included
|
|
87
|
+
if (isDigit(c) || (c === '+' && isDigit(src[i + 1] ?? ''))) {
|
|
88
|
+
const s = i;
|
|
89
|
+
if (c === '+')
|
|
90
|
+
advance(1);
|
|
91
|
+
const numeral = () => {
|
|
92
|
+
// digits, then a unit suffix; `24x7`, `3x`, `p99`-style mixes stay one token —
|
|
93
|
+
// a value written without spaces is one value
|
|
94
|
+
while (i < src.length && /[0-9._]/.test(src[i]))
|
|
95
|
+
advance(1);
|
|
96
|
+
while (i < src.length && /[A-Za-z0-9%]/.test(src[i]))
|
|
97
|
+
advance(1);
|
|
98
|
+
};
|
|
99
|
+
numeral();
|
|
100
|
+
// a date `2026-12-01` and a ratio `0.7/2` or `800Mi/2Gi` stay one token:
|
|
101
|
+
// they are one value, and a reader never splits them either
|
|
102
|
+
// …and so does a rate `800Gi/mo`, `20000/day`
|
|
103
|
+
while ((src[i] === '-' && isDigit(src[i + 1] ?? '')) || (src[i] === '/' && /[0-9A-Za-z]/.test(src[i + 1] ?? ''))) {
|
|
104
|
+
advance(1);
|
|
105
|
+
if (isDigit(src[i]))
|
|
106
|
+
numeral();
|
|
107
|
+
else
|
|
108
|
+
while (i < src.length && /[A-Za-z]/.test(src[i]))
|
|
109
|
+
advance(1);
|
|
110
|
+
}
|
|
111
|
+
push('number', src.slice(s, i));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (isWordStart(c)) {
|
|
115
|
+
const s = i;
|
|
116
|
+
advance(1); // always consume the first character, or a
|
|
117
|
+
// start-only character like `@` loops forever
|
|
118
|
+
while (i < src.length && isWordChar(src[i]))
|
|
119
|
+
advance(1);
|
|
120
|
+
// pointer: scheme://rest
|
|
121
|
+
if (src.slice(i, i + 3) === '://') {
|
|
122
|
+
advance(3);
|
|
123
|
+
while (i < src.length && /[^\s,{}]/.test(src[i]))
|
|
124
|
+
advance(1);
|
|
125
|
+
push('pointer', src.slice(s, i));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
push('word', src.slice(s, i));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// any other byte becomes a one-character word token so nothing is lost
|
|
132
|
+
advance(1);
|
|
133
|
+
push('word', src.slice(tokStart, i));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Doc } from './cst.js';
|
|
2
|
+
/** The semantic model: a flat catalogue plus relations, lowered from the CST. */
|
|
3
|
+
/** Sizing at a later moment: `at 2026-12 cpu 12 mem 24Gi`. */
|
|
4
|
+
export interface Moment {
|
|
5
|
+
when: string;
|
|
6
|
+
attrs: Record<string, string[]>;
|
|
7
|
+
}
|
|
8
|
+
/** `exposes http openapi://checkout.yaml @9f3c2e1` — a contract the object offers, by pointer (spec §4.3). */
|
|
9
|
+
export interface Interface {
|
|
10
|
+
verb: 'exposes' | 'stores';
|
|
11
|
+
kind: string;
|
|
12
|
+
pointer?: string;
|
|
13
|
+
rev?: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
attrs: Record<string, string[]>;
|
|
16
|
+
line: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ModelObject {
|
|
19
|
+
id: string;
|
|
20
|
+
/** The identifier exactly as declared, without the scope prefix. */
|
|
21
|
+
localId: string;
|
|
22
|
+
kind: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
attrs: Record<string, string[]>;
|
|
25
|
+
/** Contracts this object exposes or stores, in document order. */
|
|
26
|
+
interfaces: Interface[];
|
|
27
|
+
/** Later moments of the same figures, in document order. */
|
|
28
|
+
timeline: Moment[];
|
|
29
|
+
parent?: string;
|
|
30
|
+
line: number;
|
|
31
|
+
}
|
|
32
|
+
export interface ModelRelation {
|
|
33
|
+
from: string;
|
|
34
|
+
to: string;
|
|
35
|
+
verb: string;
|
|
36
|
+
over?: string;
|
|
37
|
+
via?: string[];
|
|
38
|
+
spec?: string;
|
|
39
|
+
as?: string;
|
|
40
|
+
port?: string;
|
|
41
|
+
label?: string;
|
|
42
|
+
/** Attributes from the relation body: `{ phase transit timeout 2s }`. */
|
|
43
|
+
attrs: Record<string, string[]>;
|
|
44
|
+
line: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* `run checkout replicas 6 cpu 2 mem 4Gi` inside a placement block: the logical
|
|
48
|
+
* object `checkout` runs on `host`. One object may run in many places; the
|
|
49
|
+
* object is never copied (spec §6).
|
|
50
|
+
*/
|
|
51
|
+
export interface Placement {
|
|
52
|
+
host: string;
|
|
53
|
+
ref: string;
|
|
54
|
+
attrs: Record<string, string[]>;
|
|
55
|
+
timeline: Moment[];
|
|
56
|
+
line: number;
|
|
57
|
+
}
|
|
58
|
+
export interface Model {
|
|
59
|
+
objects: Map<string, ModelObject>;
|
|
60
|
+
relations: ModelRelation[];
|
|
61
|
+
placements: Placement[];
|
|
62
|
+
}
|
|
63
|
+
export declare function lower(doc: Doc): Model;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { OPAQUE_KINDS } from './vocab.js';
|
|
2
|
+
/** Repeated keys accumulate: two `subscriber` lines mean two subscribers. */
|
|
3
|
+
const mergeAttrs = (into, list) => {
|
|
4
|
+
// own keys only: an attribute called `constructor` must not meet Object.prototype
|
|
5
|
+
for (const a of list)
|
|
6
|
+
into[a.key] = [...(Object.prototype.hasOwnProperty.call(into, a.key) ? into[a.key] : []), ...a.values];
|
|
7
|
+
return into;
|
|
8
|
+
};
|
|
9
|
+
const attrsOf = (list) => mergeAttrs({}, list);
|
|
10
|
+
const isMoment = (v) => /^(\d{4}(-\d{2}){0,2}|\+\d+[dwmy])$/.test(v);
|
|
11
|
+
/** `at 2026-12 cpu 12 mem 24Gi` → { when: '2026-12', attrs: { cpu: ['12'], mem: ['24Gi'] } } */
|
|
12
|
+
function momentOf(values) {
|
|
13
|
+
const [when, ...rest] = values;
|
|
14
|
+
if (!when || !isMoment(when))
|
|
15
|
+
return undefined;
|
|
16
|
+
// `count 3 cpu 16 disk data 15Ti` — a sizing key starts a pair, everything
|
|
17
|
+
// up to the next sizing key is its value (`disk data 15Ti`, `capacity size 7Ti`)
|
|
18
|
+
const KEYS = new Set(['replicas', 'count', 'nodes', 'cpu', 'mem', 'disk', 'gpu', 'capacity']);
|
|
19
|
+
const attrs = {};
|
|
20
|
+
let k;
|
|
21
|
+
for (const w of rest) {
|
|
22
|
+
if (KEYS.has(w) || k === undefined) {
|
|
23
|
+
k = w;
|
|
24
|
+
if (!Object.prototype.hasOwnProperty.call(attrs, k))
|
|
25
|
+
attrs[k] = [];
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
attrs[k].push(w);
|
|
29
|
+
}
|
|
30
|
+
return { when, attrs };
|
|
31
|
+
}
|
|
32
|
+
/** Body lines split into plain attributes and `at` moments. */
|
|
33
|
+
function bodyAttrs(body, into) {
|
|
34
|
+
const timeline = [];
|
|
35
|
+
for (const c of body ?? []) {
|
|
36
|
+
if (c.type !== 'attr')
|
|
37
|
+
continue;
|
|
38
|
+
const a = c;
|
|
39
|
+
const m = a.key === 'at' ? momentOf(a.values) : undefined;
|
|
40
|
+
if (m)
|
|
41
|
+
timeline.push(m);
|
|
42
|
+
else
|
|
43
|
+
mergeAttrs(into, [a]);
|
|
44
|
+
}
|
|
45
|
+
return timeline;
|
|
46
|
+
}
|
|
47
|
+
export function lower(doc) {
|
|
48
|
+
const objects = new Map();
|
|
49
|
+
const relations = [];
|
|
50
|
+
const placements = [];
|
|
51
|
+
// an interface with an explicit subject may name an object declared later
|
|
52
|
+
const pendingIfaces = [];
|
|
53
|
+
const walk = (nodes, parent) => {
|
|
54
|
+
for (const n of nodes) {
|
|
55
|
+
if (n.type === 'decl') {
|
|
56
|
+
const d = n;
|
|
57
|
+
// a declaration without an id was reported by the parser (AC002); the
|
|
58
|
+
// statement stays in the tree, but there is no object to build from it
|
|
59
|
+
if (!d.id) {
|
|
60
|
+
if (d.body)
|
|
61
|
+
walk(d.body, parent);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const id = parent && !d.id.includes('.') ? `${parent}.${d.id}` : d.id;
|
|
65
|
+
const attrs = attrsOf(d.inline);
|
|
66
|
+
const timeline = bodyAttrs(d.body, attrs);
|
|
67
|
+
objects.set(id, {
|
|
68
|
+
id, localId: d.id, kind: d.kind, name: d.name, attrs, timeline, parent, interfaces: [],
|
|
69
|
+
line: d.tokens[0]?.line ?? 0,
|
|
70
|
+
});
|
|
71
|
+
if (d.body && !OPAQUE_KINDS.has(d.kind))
|
|
72
|
+
walk(d.body, id); // a view or a reserved block holds no objects
|
|
73
|
+
}
|
|
74
|
+
else if (n.type === 'iface') {
|
|
75
|
+
const f = n;
|
|
76
|
+
const attrs = {};
|
|
77
|
+
bodyAttrs(f.body, attrs);
|
|
78
|
+
pendingIfaces.push({ owner: f.subject ?? parent ?? '', scope: parent, iface: {
|
|
79
|
+
verb: f.verb, kind: f.kind, pointer: f.pointer, rev: f.rev, label: f.label, attrs, line: f.tokens[0]?.line ?? 0,
|
|
80
|
+
} });
|
|
81
|
+
}
|
|
82
|
+
else if (n.type === 'rel') {
|
|
83
|
+
const r = n;
|
|
84
|
+
const from = r.subject ?? parent ?? '';
|
|
85
|
+
const attrs = {};
|
|
86
|
+
bodyAttrs(r.body, attrs);
|
|
87
|
+
relations.push({
|
|
88
|
+
from, to: r.object, verb: r.canonicalVerb,
|
|
89
|
+
over: r.over, via: r.via, spec: r.spec, as: r.as, port: r.port, label: r.label, attrs,
|
|
90
|
+
line: r.tokens[0]?.line ?? 0,
|
|
91
|
+
});
|
|
92
|
+
if (r.body)
|
|
93
|
+
walk(r.body, parent);
|
|
94
|
+
}
|
|
95
|
+
else if (n.type === 'run') {
|
|
96
|
+
const r = n;
|
|
97
|
+
// an `at …` written inline (`run x replicas 2 at 2027-01 replicas 4`) is a moment like one in the body
|
|
98
|
+
const attrs = attrsOf(r.attrs.filter(a => !(a.key === 'at' && momentOf(a.values))));
|
|
99
|
+
const timeline = [...r.attrs.filter(a => a.key === 'at').map(a => momentOf(a.values)).filter((m) => !!m), ...bodyAttrs(r.body, attrs)];
|
|
100
|
+
// `run` outside a host is reported (AC108); it places nothing
|
|
101
|
+
if (parent)
|
|
102
|
+
for (const ref of r.refs)
|
|
103
|
+
placements.push({ host: parent, ref, attrs, timeline, line: r.tokens[0]?.line ?? 0 });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
walk(doc.body);
|
|
108
|
+
for (const { owner, scope, iface } of pendingIfaces) {
|
|
109
|
+
const target = objects.get(owner) ?? (scope ? objects.get(`${scope}.${owner}`) : undefined)
|
|
110
|
+
?? [...objects.values()].find(o => o.localId === owner);
|
|
111
|
+
target?.interfaces.push(iface);
|
|
112
|
+
}
|
|
113
|
+
return { objects, relations, placements };
|
|
114
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { lex } from './lexer.js';
|
|
2
|
+
import { ATTR_KEYS, FLAG_ATTRS, OBJECT_KINDS, IFACE_VERBS, OPAQUE_KINDS, PAIR_KEYS, RELATION_MODIFIERS, RUN, RUN_KEYS, VERBS, VERB_SUGAR } from './vocab.js';
|
|
3
|
+
import { diag } from './diagnostics.js';
|
|
4
|
+
const unquote = (s) => s.startsWith('"') ? s.slice(1, s.endsWith('"') && s.length > 1 ? -1 : undefined).replace(/\\(.)/g, '$1') : s;
|
|
5
|
+
class Parser {
|
|
6
|
+
t;
|
|
7
|
+
i = 0;
|
|
8
|
+
diagnostics = [];
|
|
9
|
+
/** Every id declared anywhere in the document — one cheap pass before parsing. */
|
|
10
|
+
declared = new Set();
|
|
11
|
+
constructor(t) {
|
|
12
|
+
this.t = t;
|
|
13
|
+
for (let k = 0; k + 1 < t.length; k++)
|
|
14
|
+
if (t[k].kind === 'word' && OBJECT_KINDS.has(t[k].text) && t[k + 1].kind === 'word')
|
|
15
|
+
this.declared.add(t[k + 1].text);
|
|
16
|
+
}
|
|
17
|
+
peek(k = 0) { return this.t[Math.min(this.i + k, this.t.length - 1)]; }
|
|
18
|
+
at(kind, k = 0) { return this.peek(k).kind === kind; }
|
|
19
|
+
next() { return this.t[this.i++] ?? this.t[this.t.length - 1]; }
|
|
20
|
+
slice(from) { return this.t.slice(from, this.i); }
|
|
21
|
+
/** Words on the current logical line, used to decide the statement shape. */
|
|
22
|
+
lineWords() {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (let k = this.i; k < this.t.length; k++) {
|
|
25
|
+
const tok = this.t[k];
|
|
26
|
+
if (tok.kind === 'newline' || tok.kind === 'eof' || tok.kind === 'lbrace' || tok.kind === 'rbrace')
|
|
27
|
+
break;
|
|
28
|
+
out.push(tok);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
parseDoc() {
|
|
33
|
+
const body = [];
|
|
34
|
+
while (!this.at('eof'))
|
|
35
|
+
body.push(this.parseStatement());
|
|
36
|
+
const eof = this.next();
|
|
37
|
+
return { type: 'doc', body, tokens: this.t.slice(), tail: [eof] };
|
|
38
|
+
}
|
|
39
|
+
/** Consume the newline that terminates a statement, if present. */
|
|
40
|
+
eatEol() { if (this.at('newline'))
|
|
41
|
+
this.next(); }
|
|
42
|
+
/** True while parsing a body opened and closed on one line: `{ transit auth oidc }`. */
|
|
43
|
+
oneLine = false;
|
|
44
|
+
parseStatement() {
|
|
45
|
+
if (this.at('newline')) {
|
|
46
|
+
const from = this.i;
|
|
47
|
+
this.next();
|
|
48
|
+
return { type: 'blank', tokens: this.slice(from) };
|
|
49
|
+
}
|
|
50
|
+
const words = this.lineWords();
|
|
51
|
+
const first = words[0];
|
|
52
|
+
if (!first) { // stray '{' or '}' at statement position
|
|
53
|
+
const from = this.i;
|
|
54
|
+
this.next();
|
|
55
|
+
this.eatEol();
|
|
56
|
+
const t = this.t[from];
|
|
57
|
+
this.diagnostics.push(diag('error', 'AC001', `unexpected \`${t.text}\``, t.line, t.col));
|
|
58
|
+
return { type: 'blank', tokens: this.slice(from) };
|
|
59
|
+
}
|
|
60
|
+
if (first.kind === 'word' && OBJECT_KINDS.has(first.text))
|
|
61
|
+
return this.parseDecl();
|
|
62
|
+
if (first.kind === 'word' && first.text === RUN && words[1]?.kind === 'word')
|
|
63
|
+
return this.parseRun();
|
|
64
|
+
const second = words[1];
|
|
65
|
+
// `checkout calls billing` is a relation; `capacity calls 20000/day` is an
|
|
66
|
+
// attribute whose value happens to be a verb. A declared id or a dotted
|
|
67
|
+
// path is a subject; a known attribute key is not.
|
|
68
|
+
const subjectLike = !ATTR_KEYS.has(first.text) || this.declared.has(first.text) || first.text.includes('.');
|
|
69
|
+
if (second && second.kind === 'word' && VERBS.has(second.text) && subjectLike)
|
|
70
|
+
return this.parseRel(true);
|
|
71
|
+
if (first.kind === 'word' && VERBS.has(first.text))
|
|
72
|
+
return this.parseRel(false);
|
|
73
|
+
if (second && second.kind === 'word' && IFACE_VERBS.has(second.text) && subjectLike)
|
|
74
|
+
return this.parseIface(true);
|
|
75
|
+
if (first.kind === 'word' && IFACE_VERBS.has(first.text))
|
|
76
|
+
return this.parseIface(false);
|
|
77
|
+
return this.parseAttr(this.oneLine);
|
|
78
|
+
}
|
|
79
|
+
parseDecl() {
|
|
80
|
+
const from = this.i;
|
|
81
|
+
const kind = this.next().text;
|
|
82
|
+
let id = '';
|
|
83
|
+
if (this.at('word'))
|
|
84
|
+
id = this.next().text;
|
|
85
|
+
else {
|
|
86
|
+
const t = this.peek();
|
|
87
|
+
this.diagnostics.push(diag('error', 'AC002', `\`${kind}\` needs an identifier`, t.line, t.col));
|
|
88
|
+
}
|
|
89
|
+
let name;
|
|
90
|
+
if (this.at('string'))
|
|
91
|
+
name = unquote(this.next().text);
|
|
92
|
+
const inline = [];
|
|
93
|
+
while (!this.at('newline') && !this.at('eof') && !this.at('lbrace') && !this.at('rbrace')) {
|
|
94
|
+
inline.push(this.parseAttr(/*inline*/ true));
|
|
95
|
+
}
|
|
96
|
+
let body;
|
|
97
|
+
if (this.at('lbrace'))
|
|
98
|
+
body = OPAQUE_KINDS.has(kind) ? this.parseRawBlock() : this.parseBlock(id);
|
|
99
|
+
else
|
|
100
|
+
this.eatEol();
|
|
101
|
+
return { type: 'decl', kind, id, name, inline, body, tokens: this.slice(from) };
|
|
102
|
+
}
|
|
103
|
+
/** `{ … }` of a reserved or `view` block: balanced braces, nothing interpreted. */
|
|
104
|
+
parseRawBlock() {
|
|
105
|
+
const open = this.next(); // '{'
|
|
106
|
+
const from = this.i;
|
|
107
|
+
let depth = 1;
|
|
108
|
+
while (!this.at('eof')) {
|
|
109
|
+
if (this.at('lbrace'))
|
|
110
|
+
depth++;
|
|
111
|
+
else if (this.at('rbrace') && --depth === 0)
|
|
112
|
+
break;
|
|
113
|
+
this.next();
|
|
114
|
+
}
|
|
115
|
+
const raw = { type: 'raw', tokens: this.slice(from) };
|
|
116
|
+
if (this.at('rbrace')) {
|
|
117
|
+
this.next();
|
|
118
|
+
this.eatEol();
|
|
119
|
+
}
|
|
120
|
+
else
|
|
121
|
+
this.diagnostics.push(diag('error', 'AC003', 'unclosed `{`', open.line, open.col));
|
|
122
|
+
return [raw];
|
|
123
|
+
}
|
|
124
|
+
/** `run <ref>[, <ref>…] [sizing…] [{ body }]` */
|
|
125
|
+
parseRun() {
|
|
126
|
+
const from = this.i;
|
|
127
|
+
this.next(); // `run`
|
|
128
|
+
const refs = [this.next().text];
|
|
129
|
+
while (this.at('comma')) {
|
|
130
|
+
this.next();
|
|
131
|
+
if (this.at('word'))
|
|
132
|
+
refs.push(this.next().text);
|
|
133
|
+
}
|
|
134
|
+
const attrs = [];
|
|
135
|
+
// Inline, a run takes its sizing (`run api 3 cpu 2`) and its phase; in a
|
|
136
|
+
// one-line host body (`{ run s3 agent restic }`) anything else is the host's.
|
|
137
|
+
while (!this.at('newline') && !this.at('eof') && !this.at('lbrace') && !this.at('rbrace')
|
|
138
|
+
&& (!this.oneLine || RUN_KEYS.has(this.peek().text)))
|
|
139
|
+
attrs.push(this.parseAttr(/*inline*/ true));
|
|
140
|
+
let body;
|
|
141
|
+
if (this.at('lbrace'))
|
|
142
|
+
body = this.parseBlock(refs[0]);
|
|
143
|
+
else if (!this.oneLine)
|
|
144
|
+
this.eatEol();
|
|
145
|
+
return { type: 'run', refs, attrs, body, tokens: this.slice(from) };
|
|
146
|
+
}
|
|
147
|
+
parseBlock(_owner) {
|
|
148
|
+
this.next(); // '{'
|
|
149
|
+
const body = [];
|
|
150
|
+
// A body that starts on the same line as its `{` holds attributes side by
|
|
151
|
+
// side, exactly like the declaration line does; a body that opens with a
|
|
152
|
+
// newline holds one statement per line.
|
|
153
|
+
const outer = this.oneLine;
|
|
154
|
+
this.oneLine = !this.at('newline');
|
|
155
|
+
while (!this.at('rbrace') && !this.at('eof'))
|
|
156
|
+
body.push(this.parseStatement());
|
|
157
|
+
this.oneLine = outer;
|
|
158
|
+
if (this.at('rbrace')) {
|
|
159
|
+
this.next();
|
|
160
|
+
this.eatEol();
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const t = this.peek();
|
|
164
|
+
this.diagnostics.push(diag('error', 'AC003', 'unclosed `{`', t.line, t.col));
|
|
165
|
+
}
|
|
166
|
+
return body;
|
|
167
|
+
}
|
|
168
|
+
/** `key value[, value…]`. In inline mode, stops after one key/value group. */
|
|
169
|
+
parseAttr(inline = false) {
|
|
170
|
+
const from = this.i;
|
|
171
|
+
const keyTok = this.next();
|
|
172
|
+
const values = [];
|
|
173
|
+
const readValue = () => {
|
|
174
|
+
const v = this.next();
|
|
175
|
+
values.push(v.kind === 'string' ? unquote(v.text) : v.text);
|
|
176
|
+
};
|
|
177
|
+
// A flag takes no value when it sits beside other attributes (`transit auth oidc`)
|
|
178
|
+
const flag = inline && FLAG_ATTRS.has(keyTok.text) && !/^(true|false)$/.test(this.peek().text);
|
|
179
|
+
const stop = () => this.at('newline') || this.at('eof') || this.at('lbrace') || this.at('rbrace');
|
|
180
|
+
// Several pairs may share a line — `dc dc1 vlan 3076`, `host x ip y os "z"`,
|
|
181
|
+
// `tech Go 1.21 cpu 2` — so a value list ends where the next known key
|
|
182
|
+
// begins (spec §6.1, D-305/A3). `at` is the exception: its line is one
|
|
183
|
+
// moment with its own pairs inside. On its own line an unknown word is
|
|
184
|
+
// still a value (`capacity rps 300 p99 250ms`); inline, beside other
|
|
185
|
+
// attributes, an unknown word starts the next attribute (`auth oidc`) —
|
|
186
|
+
// there, only numbers and strings continue a value (`tech Go 1.21`).
|
|
187
|
+
// `capacity` carries its own open-ended pairs (`rps 300 p99 250ms`), inline too
|
|
188
|
+
const greedy = keyTok.text === 'at', open = keyTok.text === 'capacity';
|
|
189
|
+
const opensPair = () => !greedy && values.length > 0 && this.peek().kind === 'word' && (PAIR_KEYS.has(this.peek().text) || (inline && !open));
|
|
190
|
+
if (!flag && !stop())
|
|
191
|
+
readValue();
|
|
192
|
+
while (!flag && !stop()) {
|
|
193
|
+
if (this.at('comma')) {
|
|
194
|
+
this.next();
|
|
195
|
+
if (!stop())
|
|
196
|
+
readValue();
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (opensPair())
|
|
200
|
+
break;
|
|
201
|
+
readValue();
|
|
202
|
+
}
|
|
203
|
+
if (!inline)
|
|
204
|
+
this.eatEol();
|
|
205
|
+
return { type: 'attr', key: keyTok.text, values, tokens: this.slice(from) };
|
|
206
|
+
}
|
|
207
|
+
/** `exposes http openapi://checkout.yaml @9f3c2e1 "label" { … }` — see Iface. */
|
|
208
|
+
parseIface(explicitSubject) {
|
|
209
|
+
const from = this.i;
|
|
210
|
+
const subject = explicitSubject ? this.next().text : undefined;
|
|
211
|
+
const verbTok = this.next();
|
|
212
|
+
const verb = verbTok.text;
|
|
213
|
+
let kind = '';
|
|
214
|
+
if (this.at('word'))
|
|
215
|
+
kind = this.next().text;
|
|
216
|
+
else
|
|
217
|
+
this.diagnostics.push(diag('error', 'AC004', `\`${verb}\` needs an interface kind (http, grpc, schema, cli…)`, verbTok.line, verbTok.col));
|
|
218
|
+
let pointer, rev, label;
|
|
219
|
+
while (!this.at('newline') && !this.at('eof') && !this.at('lbrace') && !this.at('rbrace')) {
|
|
220
|
+
const t = this.next();
|
|
221
|
+
if (t.kind === 'string') {
|
|
222
|
+
label = unquote(t.text);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (t.text.startsWith('@') && pointer !== undefined) {
|
|
226
|
+
rev = t.text.slice(1);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (pointer === undefined) {
|
|
230
|
+
const at = t.text.includes('://') ? t.text.lastIndexOf('@') : -1; // `openapi://x.yaml@a1b2c3d`
|
|
231
|
+
if (at > t.text.indexOf('://')) {
|
|
232
|
+
pointer = t.text.slice(0, at);
|
|
233
|
+
rev = t.text.slice(at + 1);
|
|
234
|
+
}
|
|
235
|
+
else
|
|
236
|
+
pointer = t.text;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
this.diagnostics.push(diag('warning', 'AC005', `unexpected \`${t.text}\` after the pointer`, t.line, t.col));
|
|
240
|
+
}
|
|
241
|
+
let body;
|
|
242
|
+
if (this.at('lbrace'))
|
|
243
|
+
body = this.parseBlock(kind);
|
|
244
|
+
else if (!this.oneLine)
|
|
245
|
+
this.eatEol();
|
|
246
|
+
return { type: 'iface', subject, verb, kind, pointer, rev, label, body, tokens: this.slice(from) };
|
|
247
|
+
}
|
|
248
|
+
parseRel(explicitSubject) {
|
|
249
|
+
const from = this.i;
|
|
250
|
+
const subject = explicitSubject ? this.next().text : undefined;
|
|
251
|
+
const verbTok = this.next();
|
|
252
|
+
const verb = verbTok.text;
|
|
253
|
+
let object = '';
|
|
254
|
+
if (!this.at('newline') && !this.at('eof') && !this.at('lbrace'))
|
|
255
|
+
object = this.next().text;
|
|
256
|
+
else
|
|
257
|
+
this.diagnostics.push(diag('error', 'AC004', `\`${verb}\` needs an object`, verbTok.line, verbTok.col));
|
|
258
|
+
let over, via, spec, as, label, port;
|
|
259
|
+
while (!this.at('newline') && !this.at('eof') && !this.at('lbrace') && !this.at('rbrace')) {
|
|
260
|
+
const t = this.peek();
|
|
261
|
+
if (t.kind === 'string') {
|
|
262
|
+
label = unquote(this.next().text);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (t.kind === 'word' && RELATION_MODIFIERS.has(t.text)) {
|
|
266
|
+
const mod = this.next().text;
|
|
267
|
+
const vals = [];
|
|
268
|
+
if (!this.at('newline') && !this.at('eof'))
|
|
269
|
+
vals.push(this.next().text);
|
|
270
|
+
while (this.at('comma')) {
|
|
271
|
+
this.next();
|
|
272
|
+
if (!this.at('newline') && !this.at('eof'))
|
|
273
|
+
vals.push(this.next().text);
|
|
274
|
+
}
|
|
275
|
+
if (mod === 'over')
|
|
276
|
+
over = vals[0];
|
|
277
|
+
else if (mod === 'via')
|
|
278
|
+
via = vals;
|
|
279
|
+
else if (mod === 'spec')
|
|
280
|
+
spec = vals[0];
|
|
281
|
+
else if (mod === 'as')
|
|
282
|
+
as = vals[0];
|
|
283
|
+
else if (mod === 'port')
|
|
284
|
+
port = vals[0];
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
this.diagnostics.push(diag('warning', 'AC005', `unexpected \`${t.text}\` in relation`, t.line, t.col));
|
|
288
|
+
this.next();
|
|
289
|
+
}
|
|
290
|
+
let body;
|
|
291
|
+
if (this.at('lbrace'))
|
|
292
|
+
body = this.parseBlock(object);
|
|
293
|
+
else
|
|
294
|
+
this.eatEol();
|
|
295
|
+
return {
|
|
296
|
+
type: 'rel', subject, verb, canonicalVerb: VERB_SUGAR[verb] ?? verb,
|
|
297
|
+
object, over, via, spec, as, port, label, body, tokens: this.slice(from),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
export function parse(src) {
|
|
302
|
+
const p = new Parser(lex(src));
|
|
303
|
+
const doc = p.parseDoc();
|
|
304
|
+
return { doc, diagnostics: p.diagnostics };
|
|
305
|
+
}
|