@jigs.design/compiler 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Bates
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,29 @@
1
- # Temporary Holding Version
1
+ # @jigs.design/compiler
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Today's Pickle compiler: the parser, the checker against a catalog, the code generator, recipe libraries, and the SEARCH/REPLACE edit helpers. `@jigs.design/server` and `@jigs.design/prompt` use it. You import it for tooling, not to run a session.
4
+
5
+ It parses embedded code with [acorn](https://github.com/acornjs/acorn) in strict mode, the mode programs run in, and checks names and the JavaScript subset. It has no built-in catalog. Every caller passes one, and its prop kinds are boolean, enum, handler, number, rows and string.
6
+
7
+ The [language RFC](../../docs/language-v2/rfc.md) plans its next changes. It evolves in place, behind the same server API.
8
+
9
+ ## Exports
10
+
11
+ | Export | What it is |
12
+ | ---------------------------------------------------------- | ---------------------------------------------------------------- |
13
+ | `parsePickle` | Source to a document tree. Tolerates a prefix cut off mid-stream |
14
+ | `compilePickle` | Checks a document against a catalog and generates its program |
15
+ | `compileLibrary` | Compiles recipes into a library a document can use |
16
+ | `recipeSignature` | A recipe's props and slot, as the prompt lists them |
17
+ | `isEditReply`, `parseEdits`, `applyEdits`, `SEARCH_MARKER` | Recognise and apply a reply made of SEARCH/REPLACE edits |
18
+ | `CompileCatalog`, `ComponentSpec`, `PropSpec` | What the compiler checks against |
19
+
20
+ ## Source
21
+
22
+ - `src/parse.ts`, `src/ast.ts`: the parser and its tree.
23
+ - `src/compile.ts`: checking, recipes and diagnostics.
24
+ - `src/script.ts`, `src/check-code.ts`: embedded code read with acorn, and the checks on it.
25
+ - `src/suggest.ts`: the nearest name, for "did you mean".
26
+ - `src/codegen.ts`: the JavaScript the sandbox runs.
27
+ - `src/patch.ts`: SEARCH/REPLACE edits.
28
+ - `src/spec.ts`, `src/signature.ts`: catalog specs and recipe signatures.
29
+ - `src/test-catalog.ts`: the catalog the tests run against.
package/dist/ast.d.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { OffsetMap, Range } from "./offsets.js";
2
+ //#region src/ast.d.ts
3
+ export type Literal = boolean | number | string;
4
+ export type PropValue =
5
+ /** `at` places the code in the source; a value the compiler made up has none. */
6
+ {
7
+ at?: OffsetMap;
8
+ code: string;
9
+ kind: "expr";
10
+ } | {
11
+ kind: "literal";
12
+ value: Literal;
13
+ };
14
+ export interface PropNode {
15
+ /** Just past the attribute in the source; a prop the parser made up has none. */
16
+ end?: number;
17
+ name: string;
18
+ /**
19
+ * The attribute was written without `=`. Catalog tags read that as `true`;
20
+ * `<define>` reads it as a required prop with no default.
21
+ */
22
+ shorthand?: boolean;
23
+ /** The attribute's first character in the source. */
24
+ start?: number;
25
+ value: PropValue;
26
+ /** The attribute as the source wrote it, such as `gap="3"`, for messages. */
27
+ written?: string;
28
+ }
29
+ /** `start` and `end` cover the opening tag, or the Markdown that made the element. */
30
+ export interface ElementNode extends Range {
31
+ children: Array<PickleNode>;
32
+ /** Just past its closing tag, or its own end when self-closing; absent when the parser closed it. */
33
+ closeEnd?: number;
34
+ line: number;
35
+ name: string;
36
+ props: Array<PropNode>;
37
+ type: "element";
38
+ }
39
+ export interface TextNode {
40
+ type: "text";
41
+ value: string;
42
+ }
43
+ export interface ExprNode {
44
+ at: OffsetMap;
45
+ code: string;
46
+ line: number;
47
+ type: "expr";
48
+ }
49
+ /**
50
+ * A `{@body ...}` line: JavaScript run before rendering (state, derived
51
+ * values). `start` and `end` cover it from `{@body` to its closing `}`.
52
+ */
53
+ export interface Statement extends Range {
54
+ at: OffsetMap;
55
+ code: string;
56
+ line: number;
57
+ }
58
+ /** A `{@body ...}` line inside a tag; it belongs to the nearest enclosing `<each>`. */
59
+ interface StatementNode extends Statement {
60
+ type: "statement";
61
+ }
62
+ export type PickleNode = ElementNode | ExprNode | StatementNode | TextNode;
63
+ /** Something the parser fixed or dropped so a partial document still renders. */
64
+ export interface Repair {
65
+ end?: number;
66
+ line: number;
67
+ message: string;
68
+ start?: number;
69
+ }
70
+ export interface PickleDocument {
71
+ children: Array<PickleNode>;
72
+ /** Text that could not be read even though the input is complete there. */
73
+ errors: Array<Repair>;
74
+ repairs: Array<Repair>;
75
+ statements: Array<Statement>;
76
+ }
77
+ //#endregion
78
+ export {};
@@ -0,0 +1,237 @@
1
+ import { analyze } from "./script.js";
2
+ import { statementProblem } from "./codegen.js";
3
+ //#region src/block-hoist.ts
4
+ /** Tags whose `{@body}` lines are their own, not the enclosing body's. */
5
+ const OWN_SCOPE = /* @__PURE__ */ new Set([
6
+ "define",
7
+ "each",
8
+ "if"
9
+ ]);
10
+ /** `{@body}` lines directly in `nodes` and in tags that share the scope. */
11
+ function ownStatements(nodes) {
12
+ return nodes.flatMap((node) => {
13
+ if (node.type === "statement") return [node];
14
+ return node.type === "element" && !OWN_SCOPE.has(node.name) ? ownStatements(node.children) : [];
15
+ });
16
+ }
17
+ function factsOf(statement) {
18
+ if (statementProblem(statement.code) !== null) return null;
19
+ const analysis = analyze(statement.code, "statement");
20
+ return analysis.ok ? analysis : null;
21
+ }
22
+ function literalProp(node, name) {
23
+ const prop = node.props.find((entry) => entry.name === name);
24
+ return prop?.value.kind === "literal" ? String(prop.value.value) : null;
25
+ }
26
+ function codeProp(node, name) {
27
+ const prop = node.props.find((entry) => entry.name === name);
28
+ if (prop === void 0) return null;
29
+ return prop.value.kind === "expr" ? prop.value.code : JSON.stringify(prop.value.value);
30
+ }
31
+ /** A line's declarations with their line, for `Body.names` and `Block.locals`. */
32
+ function declarations(statements) {
33
+ return statements.flatMap((statement) => (factsOf(statement)?.declared ?? []).map((name) => ({
34
+ line: statement.line,
35
+ name
36
+ })));
37
+ }
38
+ function blockOf(node, statements) {
39
+ const locals = declarations(statements);
40
+ if (node.name === "if") return {
41
+ items: [],
42
+ kind: "if",
43
+ locals,
44
+ start: node.start,
45
+ test: codeProp(node, "test")
46
+ };
47
+ return {
48
+ items: [literalProp(node, "as") ?? "item", literalProp(node, "index")].filter((item) => item !== null),
49
+ kind: "each",
50
+ locals,
51
+ start: node.start,
52
+ test: null
53
+ };
54
+ }
55
+ /** Every candidate line in `nodes`, which belong to `body`, inside the `blocks` given. */
56
+ function collect(nodes, body, blocks, into) {
57
+ for (const node of nodes) {
58
+ if (node.type !== "element" || node.name === "define") continue;
59
+ if (node.name !== "each" && node.name !== "if") {
60
+ collect(node.children, body, blocks, into);
61
+ continue;
62
+ }
63
+ const statements = ownStatements(node.children);
64
+ const inner = [...blocks, blockOf(node, statements)];
65
+ for (const statement of statements) {
66
+ const facts = factsOf(statement);
67
+ if (facts !== null && facts.declared.length > 0) into.push({
68
+ blocks: inner,
69
+ body,
70
+ facts,
71
+ statement
72
+ });
73
+ }
74
+ collect(node.children, body, inner, into);
75
+ }
76
+ }
77
+ /** `const name = value` or `let { a, b } = value`: what an `<if>` test can guard. */
78
+ const SIMPLE_DECLARATION = /^(const|let)\s+([A-Za-z_$][\w$]*|\{[^={}]*\}|\[[^=[\]]*\])\s*=\s*([^=][\s\S]*)$/;
79
+ /** The line's code, moved above its blocks: guarded by their `<if>` tests when it has any and is not a hook. */
80
+ function movedCode(candidate, hook) {
81
+ const tests = candidate.blocks.flatMap((block) => block.test === null ? [] : [block.test]);
82
+ if (hook || tests.length === 0) return { code: candidate.statement.code };
83
+ const match = SIMPLE_DECLARATION.exec(candidate.statement.code);
84
+ if (match === null || candidate.facts.declared.length === 0) return { reason: "is not a single `const name = value` line the <if> test could guard" };
85
+ const [, keyword, pattern, value] = match;
86
+ const fallback = pattern?.startsWith("{") ? "{}" : pattern?.startsWith("[") ? "[]" : "undefined";
87
+ const guard = tests.map((test) => `(${test.trim()})`).join(" && ");
88
+ const trimmed = value?.trim() ?? "";
89
+ return { code: `${keyword} ${pattern} = ${guard} ? (${trimmed.includes("//") ? `${trimmed}\n` : trimmed}) : ${fallback}` };
90
+ }
91
+ /** The free names `code`, an expression, reads; null when it can't be read. */
92
+ function readsOf(code) {
93
+ const analysis = analyze(code, "expression");
94
+ return analysis.ok ? analysis.references.map((reference) => reference.name) : null;
95
+ }
96
+ /**
97
+ * Why `candidate` can't move out of its blocks, or null when it can: it
98
+ * reads a loop item, a name another line of a block declares, or an
99
+ * `<if>` test it would be guarded by does; it declares a name the body or
100
+ * another block line declares too; or it keeps per-item state.
101
+ */
102
+ function blocked(candidate, hook, elsewhere) {
103
+ for (const name of candidate.facts.declared) {
104
+ const outer = candidate.body.names.get(name);
105
+ if (outer !== void 0) return `declares "${name}", which line ${outer} declares outside the block too`;
106
+ const twin = (elsewhere.get(name) ?? []).find((other) => other.statement.start !== candidate.statement.start);
107
+ if (twin !== void 0) return `declares "${name}", which line ${twin.statement.line} declares inside a block too`;
108
+ }
109
+ const items = /* @__PURE__ */ new Map();
110
+ const locals = /* @__PURE__ */ new Map();
111
+ for (const block of candidate.blocks) {
112
+ for (const [index, item] of block.items.entries()) items.set(item, index === 0 ? "item" : "index");
113
+ for (const local of block.locals) if (local.line !== candidate.statement.line) locals.set(local.name, local.line);
114
+ }
115
+ const bound = (names) => {
116
+ for (const name of names) {
117
+ const item = items.get(name);
118
+ if (item !== void 0) return `"${name}", the loop's ${item}`;
119
+ const local = locals.get(name);
120
+ if (local !== void 0) return `"${name}", which line ${local} declares inside the block`;
121
+ }
122
+ return null;
123
+ };
124
+ const own = bound(candidate.facts.references.map((reference) => reference.name));
125
+ if (own !== null) return `reads ${own}`;
126
+ if (!hook) for (const block of candidate.blocks) {
127
+ if (block.test === null) continue;
128
+ const names = readsOf(block.test);
129
+ if (names === null) return "sits in an <if> whose test can't be read";
130
+ const read = bound(names);
131
+ if (read !== null) return `sits in an <if> whose test reads ${read}`;
132
+ }
133
+ return null;
134
+ }
135
+ /** Where a line moving out of `blocks` goes: after the last body line above them, or the body's start. */
136
+ function insertion(body, blocks) {
137
+ const top = blocks[0]?.start ?? 0;
138
+ const above = body.statements.findLast((statement) => statement.end <= top);
139
+ return above === void 0 ? body.origin : {
140
+ offset: above.end,
141
+ text: (line) => `\n${line}`
142
+ };
143
+ }
144
+ function hoistOf(candidate, elsewhere) {
145
+ const { blocks, facts, statement } = candidate;
146
+ const base = {
147
+ block: blocks.at(-1)?.kind ?? "if",
148
+ declared: facts.declared,
149
+ line: statement.line,
150
+ start: statement.start
151
+ };
152
+ const hook = facts.hookCalls.length > 0;
153
+ const reason = blocked(candidate, hook, elsewhere);
154
+ if (reason !== null) return {
155
+ ...base,
156
+ fix: null,
157
+ reason
158
+ };
159
+ if (facts.hookCalls.some((call) => call.hook === "useState") && blocks.some((block) => block.kind === "each")) return {
160
+ ...base,
161
+ fix: null,
162
+ reason: null
163
+ };
164
+ const moved = movedCode(candidate, hook);
165
+ if ("reason" in moved) return {
166
+ ...base,
167
+ fix: null,
168
+ reason: moved.reason
169
+ };
170
+ const [outer] = blocks;
171
+ const place = insertion(candidate.body, blocks);
172
+ const guarded = !hook && blocks.some((block) => block.test !== null);
173
+ return {
174
+ ...base,
175
+ fix: {
176
+ edits: [{
177
+ end: statement.end,
178
+ start: statement.start,
179
+ text: ""
180
+ }, {
181
+ end: place.offset,
182
+ start: place.offset,
183
+ text: place.text(`{@body ${moved.code}}`)
184
+ }],
185
+ title: `Move the {@body} line on line ${statement.line} above the <${outer?.kind ?? "if"}>${guarded ? ", guarded by its test" : ""}`
186
+ },
187
+ reason: null
188
+ };
189
+ }
190
+ /**
191
+ * The `{@body}` lines inside `<if>` and `<each>` blocks of the page and
192
+ * its `<define>`s that declare a name, each with the fix that moves it
193
+ * above its outermost block, or the reason it can't move: see `BlockHoist`.
194
+ */
195
+ function blockHoists(document) {
196
+ const candidates = [];
197
+ const pageStatements = [...document.statements, ...ownStatements(document.children)].toSorted((a, b) => a.start - b.start);
198
+ const page = {
199
+ names: new Map(declarations(pageStatements).map((entry) => [entry.name, entry.line])),
200
+ origin: {
201
+ offset: 0,
202
+ text: (line) => `${line}\n`
203
+ },
204
+ statements: pageStatements
205
+ };
206
+ collect(document.children, page, [], candidates);
207
+ for (const node of document.children) {
208
+ if (node.type !== "element" || node.name !== "define") continue;
209
+ const statements = ownStatements(node.children);
210
+ const names = new Map(node.props.filter((prop) => prop.name !== "name").map((prop) => [prop.name, node.line]));
211
+ for (const entry of declarations(statements)) names.set(entry.name, entry.line);
212
+ const define = {
213
+ names,
214
+ origin: {
215
+ offset: node.end,
216
+ text: (line) => `\n${line}`
217
+ },
218
+ statements
219
+ };
220
+ collect(node.children, define, [], candidates);
221
+ }
222
+ const elsewhere = /* @__PURE__ */ new Map();
223
+ for (const candidate of candidates) for (const name of candidate.facts.declared) elsewhere.set(name, [...elsewhere.get(name) ?? [], candidate]);
224
+ const byName = /* @__PURE__ */ new Map();
225
+ const byStart = /* @__PURE__ */ new Map();
226
+ for (const candidate of candidates) {
227
+ const hoist = hoistOf(candidate, elsewhere);
228
+ byStart.set(hoist.start, hoist);
229
+ for (const name of hoist.declared) byName.set(name, [...byName.get(name) ?? [], hoist]);
230
+ }
231
+ return {
232
+ byName,
233
+ byStart
234
+ };
235
+ }
236
+ //#endregion
237
+ export { blockHoists };
@@ -0,0 +1,46 @@
1
+ import { Diagnostic } from "./compile.js";
2
+ import { ProcedureCall } from "./script.js";
3
+ //#region src/check-code.d.ts
4
+ /** A procedure call in the document, by line, for the server to check against its host. */
5
+ export interface ProcedureUse extends Omit<ProcedureCall, "end" | "offset"> {
6
+ /** Just past the call, as an offset into the source, when the code has a place there. */
7
+ end?: number;
8
+ line: number;
9
+ /** The call's first character, as an offset into the source. */
10
+ start?: number;
11
+ }
12
+ /** Where a diagnostic points: a line, and a range in the source when the code has one. */
13
+ type Place = Pick<Diagnostic, "end" | "line" | "start">;
14
+ /** A step of `DataRead.path` into each element of an array, as an `<each>` item takes. */
15
+ export declare const ELEMENT = "[]";
16
+ /**
17
+ * Fields read from a procedure's data, as `orders.data.total` or, through
18
+ * `const { data } = useQuery("orders")`, `data.total`, for the server to
19
+ * check against the procedure's output schema. A read of an `<each>` item,
20
+ * as `order.total` in `<each of={orders.data} as="order">`, starts with the
21
+ * list's path and an `ELEMENT` step.
22
+ */
23
+ export interface DataRead {
24
+ /** The read as written up to the data, such as `orders.data`, for messages. */
25
+ data: string;
26
+ /**
27
+ * For a read of an `<each>` item: its name, its list as written, such as
28
+ * `orders.data`, and how many steps of `path` lead to the item.
29
+ */
30
+ item?: {
31
+ list: string;
32
+ name: string;
33
+ steps: number;
34
+ };
35
+ /** The fields read from the data, in order, as far as they are written out. */
36
+ path: Array<string>;
37
+ /**
38
+ * Where the read through each field of `path` is: `places[i]` ends at
39
+ * `path[i]`. Null for the steps that lead to an `<each>` item, which the
40
+ * read in its `of` checks.
41
+ */
42
+ places: Array<Place | null>;
43
+ procedure: string;
44
+ }
45
+ //#endregion
46
+ export {};