@jigs.design/compiler 0.1.2 → 0.3.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/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # @jigs.design/compiler
2
2
 
3
- Today's Jigs 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.
3
+ Today's Jigs compiler: the parser, the checker against a catalog, the code generator, jig 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
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.
5
+ Its diagnostics are the `Diagnostic` type from `@jigs.design/protocol`, the shape the server sends; it imports that package's types and nothing else from it. 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
6
 
7
- The [language RFC](../../docs/language-v2/rfc.md) plans its next changes. It evolves in place, behind the same server API.
7
+ It evolves in place, behind the same server API. The [language plan's record](../../.agent-docs/notes/language-v2-2026-10.md) says what each phase changed, and `.agent-docs/deferred.md` what is set aside.
8
8
 
9
9
  ## Exports
10
10
 
@@ -12,18 +12,18 @@ The [language RFC](../../docs/language-v2/rfc.md) plans its next changes. It evo
12
12
  | ---------------------------------------------------------- | ---------------------------------------------------------------- |
13
13
  | `parseJigs` | Source to a document tree. Tolerates a prefix cut off mid-stream |
14
14
  | `compileJigs` | 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 |
15
+ | `compileLibrary` | Compiles jigs into a library a document can use |
16
+ | `jigSignature` | A jig's props and slot, as the prompt lists them |
17
17
  | `isEditReply`, `parseEdits`, `applyEdits`, `SEARCH_MARKER` | Recognise and apply a reply made of SEARCH/REPLACE edits |
18
18
  | `CompileCatalog`, `ComponentSpec`, `PropSpec` | What the compiler checks against |
19
19
 
20
20
  ## Source
21
21
 
22
22
  - `src/parse.ts`, `src/ast.ts`: the parser and its tree.
23
- - `src/compile.ts`: checking, recipes and diagnostics.
23
+ - `src/compile.ts`: checking, jig libraries and diagnostics.
24
24
  - `src/script.ts`, `src/check-code.ts`: embedded code read with acorn, and the checks on it.
25
25
  - `src/suggest.ts`: the nearest name, for "did you mean".
26
26
  - `src/codegen.ts`: the JavaScript the sandbox runs.
27
27
  - `src/patch.ts`: SEARCH/REPLACE edits.
28
- - `src/spec.ts`, `src/signature.ts`: catalog specs and recipe signatures.
28
+ - `src/spec.ts`, `src/signature.ts`: catalog specs and jig signatures.
29
29
  - `src/test-catalog.ts`: the catalog the tests run against.
@@ -0,0 +1,2 @@
1
+ import "./ast.js";
2
+ import "./compile.js";
@@ -1,4 +1,6 @@
1
- import { Diagnostic } from "./compile.js";
1
+ import "./ast.js";
2
+ import { Finding } from "./compile.js";
3
+ import "./block-hoist.js";
2
4
  import { ProcedureCall } from "./script.js";
3
5
  //#region src/check-code.d.ts
4
6
  /** A procedure call in the document, by line, for the server to check against its host. */
@@ -10,7 +12,7 @@ export interface ProcedureUse extends Omit<ProcedureCall, "end" | "offset"> {
10
12
  start?: number;
11
13
  }
12
14
  /** 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">;
15
+ type Place = Pick<Finding, "end" | "line" | "start">;
14
16
  /** A step of `DataRead.path` into each element of an array, as an `<each>` item takes. */
15
17
  export declare const ELEMENT = "[]";
16
18
  /**
@@ -277,18 +277,18 @@ function readBody(statements, define, outer) {
277
277
  * Checks every piece of code in the page and its defines, adding to
278
278
  * `diagnostics`, and returns the procedure calls it saw, in source order.
279
279
  */
280
- function checkCode(tree, defines, pageStatements, diagnostics, hoists, dropped = /* @__PURE__ */ new Set()) {
280
+ function checkCode(tree, defines, pageStatements, findings, hoists, dropped = /* @__PURE__ */ new Set()) {
281
281
  const procedures = [];
282
282
  const reads = [];
283
283
  const report = (place, code, message, fix) => {
284
- const diagnostic = {
284
+ const finding = {
285
285
  code,
286
286
  ...place,
287
287
  message,
288
- severity: "warning"
288
+ severity: "error"
289
289
  };
290
- if (fix !== void 0) diagnostic.fix = fix;
291
- diagnostics.push(diagnostic);
290
+ if (fix !== void 0) finding.fix = fix;
291
+ findings.push(finding);
292
292
  };
293
293
  const checkSnippet = (snippet, body, scope) => {
294
294
  const facts = factsOf(snippet);
package/dist/codegen.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import "./ast.js";
2
+ import "./compile.js";
1
3
  //#region src/codegen.d.ts
2
4
  /** What the sandbox runs: a JavaScript program plus the text it displays. */
3
5
  export interface ProgramOutput {
package/dist/codegen.js CHANGED
@@ -62,15 +62,15 @@ const fragment = (children) => `__jigs.jsx(${[
62
62
  const documentLines = { line: String };
63
63
  const libraryLines = { line: () => "__jigsAt" };
64
64
  function generateProgram(statements, tree, defines = []) {
65
- const diagnostics = [];
65
+ const findings = [];
66
66
  const constants = {};
67
67
  let nextConstant = 0;
68
68
  const invalid = (code, line, message) => {
69
- diagnostics.push({
69
+ findings.push({
70
70
  code,
71
71
  line,
72
72
  message,
73
- severity: "warning"
73
+ severity: "error"
74
74
  });
75
75
  };
76
76
  /** Statement lines, run in order, plus a `let` for every name they declare. */
@@ -195,7 +195,7 @@ function generateProgram(statements, tree, defines = []) {
195
195
  const functions = defines.map((def) => genDefine(def));
196
196
  const children = genChildren(tree, documentLines);
197
197
  return {
198
- diagnostics,
198
+ findings,
199
199
  output: {
200
200
  code: [
201
201
  "\"use strict\";",
package/dist/compile.d.ts CHANGED
@@ -3,6 +3,7 @@ import { ExprNode, PropValue, Statement, TextNode } from "./ast.js";
3
3
  import { CompileCatalog } from "./spec.js";
4
4
  import { ProgramOutput } from "./codegen.js";
5
5
  import { DataRead, ProcedureUse } from "./check-code.js";
6
+ import { Diagnostic, Severity } from "@jigs.design/protocol";
6
7
  //#region src/compile.d.ts
7
8
  type DiagnosticCode = "coerced" | "handle_field" | "hook_placement" | "invalid_control" | "invalid_define" | "invalid_expression" | "invalid_literal" | "invalid_statement" | "missing_declarations" | "missing_prop" | "mutate_in_render" | "outer_reference" | "query_options" | "repaired" | "shadows_library" | "unclosed" | "undefined_compare" | "undefined_reference" | "unexpected_children" | "unknown_component" | "unknown_prop" | "unstable_input" | "unsupported_syntax" | "use_before_declare";
8
9
  /** Replace `[start, end)` of the source with `text`. */
@@ -21,20 +22,25 @@ export interface Fix {
21
22
  /** What the fix does, such as "Replace with `q.status === "pending"`". */
22
23
  title: string;
23
24
  }
24
- export interface Diagnostic {
25
+ /**
26
+ * A diagnostic while the compiler works: flat offsets into the source being
27
+ * compiled, and a fix as offset edits, so `applyFixes` can apply it. The
28
+ * result reports each as a `Diagnostic`.
29
+ */
30
+ export interface Finding {
25
31
  code: DiagnosticCode;
26
- /** The 1-based column of `start` on its line. */
27
- column?: number;
28
32
  /** Just past what the diagnostic is about, as an offset into the source. */
29
33
  end?: number;
30
34
  fix?: Fix;
31
35
  /** The line of `start` when there is one. */
32
36
  line: number;
33
37
  message: string;
34
- severity: "info" | "warning";
38
+ severity: Severity;
35
39
  /** The first character of what the diagnostic is about, as an offset into the source. */
36
40
  start?: number;
37
41
  }
42
+ /** What `compileJigs` reports: the shape the server sends and the eval results store. */
43
+ type Diagnostic$1 = Diagnostic<DiagnosticCode>;
38
44
  interface RenderElement {
39
45
  children: Array<RenderNode>;
40
46
  component: string;
@@ -104,7 +110,7 @@ export interface CompiledDefine {
104
110
  name: string;
105
111
  origin: "doc" | "library";
106
112
  /**
107
- * The procedure calls in a library recipe's source, by its lines. Empty
113
+ * The procedure calls in a library jig's source, by its lines. Empty
108
114
  * for a document's own define: those are on the document's result.
109
115
  */
110
116
  procedures: ReadonlyArray<ProcedureUse>;
@@ -124,28 +130,28 @@ export interface Program {
124
130
  tree: Array<RenderNode>;
125
131
  }
126
132
  /** A reusable component written in Jigs: source with exactly one `<define>`. */
127
- export interface Recipe {
133
+ export interface Jig {
128
134
  name: string;
129
135
  source: string;
130
136
  }
131
- /** Recipes compiled against one catalog, ready to use in documents. */
137
+ /** Jigs compiled against one catalog, ready to use in documents. */
132
138
  export type Library = ReadonlyMap<string, CompiledDefine>;
133
139
  export interface CompileOptions {
134
140
  /** The components a document may use. */
135
141
  catalog: CompileCatalog;
136
- /** Recipes in scope, from `compileLibrary` with the same catalog. */
142
+ /** Jigs in scope, from `compileLibrary` with the same catalog. */
137
143
  library?: Library;
138
144
  }
139
145
  export interface CompileResult {
140
146
  defines: ReadonlyArray<CompiledDefine>;
141
- diagnostics: Array<Diagnostic>;
142
- /** The recipes this document was compiled against. */
147
+ diagnostics: Array<Diagnostic$1>;
148
+ /** The jigs this document was compiled against. */
143
149
  library: ReadonlyMap<string, CompiledDefine>;
144
150
  output: ProgramOutput;
145
151
  /**
146
152
  * Every `useQuery`, `useMutation` and `useAction` call in the parsed
147
153
  * code of the document and its defines, in line order: nothing in
148
- * prose or in string literals. Recipes' calls are on the library's
154
+ * prose or in string literals. Library jigs' calls are on the library's
149
155
  * defines.
150
156
  */
151
157
  procedures: ReadonlyArray<ProcedureUse>;
@@ -159,19 +165,19 @@ export interface CompileResult {
159
165
  source: string;
160
166
  }
161
167
  /**
162
- * Compiles `recipes` against `catalog`, in order: a recipe may use the ones
163
- * before it. Throws when a recipe has any diagnostic. Cached per catalog and
164
- * recipe list.
168
+ * Compiles `jigs` against `catalog`, in order: a jig may use the ones
169
+ * before it. Throws when a jig has any diagnostic. Cached per catalog and
170
+ * jig list.
165
171
  */
166
- export declare function compileLibrary(recipes: ReadonlyArray<Recipe>, catalog: CompileCatalog): Library;
172
+ export declare function compileLibrary(jigs: ReadonlyArray<Jig>, catalog: CompileCatalog): Library;
167
173
  /**
168
- * Compiles a document. A warning that carries a fix is applied rather than
174
+ * Compiles a document. An error that carries a fix is applied rather than
169
175
  * sent to the model: the fixed source is compiled again, and when it has
170
176
  * exactly the problems left that the fixes did not cover, its program is
171
- * used and those warnings become `info`, as a quoted number on a number prop
177
+ * used and those errors become `info`, as a quoted number on a number prop
172
178
  * already is. Diagnostics, procedures and their spans stay those of `source`,
173
179
  * which is what the model wrote and what the session keeps.
174
180
  */
175
181
  export declare function compileJigs(source: string, options: CompileOptions): CompileResult;
176
182
  //#endregion
177
- export {};
183
+ export { Diagnostic$1 as Diagnostic };
package/dist/compile.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { analyze } from "./script.js";
2
2
  import { generateProgram, isBindingName } from "./codegen.js";
3
- import { columnAt, lineAt, sourceRange } from "./offsets.js";
3
+ import { lineAt, rangeIn, sourceRange, spanIn } from "./offsets.js";
4
4
  import { closest, nearest } from "./suggest.js";
5
5
  import { checkCode } from "./check-code.js";
6
6
  import { parseJigs } from "./parse.js";
@@ -58,7 +58,7 @@ function hookMisuse(code, kind, block) {
58
58
  return null;
59
59
  }
60
60
  const NO_LIBRARY = /* @__PURE__ */ new Map();
61
- /** Libraries by catalog, then by recipe list. */
61
+ /** Libraries by catalog, then by jig list. */
62
62
  const libraryCache = /* @__PURE__ */ new WeakMap();
63
63
  function propSpec(spec, name) {
64
64
  const props = spec.props;
@@ -109,37 +109,37 @@ function treeHasSlot(nodes) {
109
109
  }
110
110
  return false;
111
111
  }
112
- function readDefine(node, diagnostics, libraryNames, components) {
112
+ function readDefine(node, findings, libraryNames, components) {
113
113
  const nameProp = node.props.find((prop) => prop.name === "name");
114
114
  const name = nameProp !== void 0 && nameProp.shorthand !== true && nameProp.value.kind === "literal" && typeof nameProp.value.value === "string" ? nameProp.value.value : null;
115
115
  if (name === null) {
116
- diagnostics.push({
116
+ findings.push({
117
117
  code: "invalid_define",
118
118
  ...placeOf(node),
119
119
  message: `<define> needs name="componentName"; it was removed`,
120
- severity: "warning"
120
+ severity: "error"
121
121
  });
122
122
  return null;
123
123
  }
124
124
  if (componentSpec(components, name) !== void 0 || RESERVED_TAGS.has(name)) {
125
- diagnostics.push({
125
+ findings.push({
126
126
  code: "invalid_define",
127
127
  ...placeOf(node),
128
128
  message: `<define name="${name}"> clashes with <${name}>; it was removed`,
129
- severity: "warning"
129
+ severity: "error"
130
130
  });
131
131
  return null;
132
132
  }
133
133
  if (!isBindingName(name)) {
134
- diagnostics.push({
134
+ findings.push({
135
135
  code: "invalid_define",
136
136
  ...placeOf(node),
137
137
  message: `<define name="${name}"> is not a usable component name; it was removed`,
138
- severity: "warning"
138
+ severity: "error"
139
139
  });
140
140
  return null;
141
141
  }
142
- if (libraryNames.has(name)) diagnostics.push({
142
+ if (libraryNames.has(name)) findings.push({
143
143
  code: "shadows_library",
144
144
  ...placeOf(node),
145
145
  message: `<define name="${name}"> overrides the library component`,
@@ -150,38 +150,38 @@ function readDefine(node, diagnostics, libraryNames, components) {
150
150
  for (const prop of node.props) {
151
151
  if (prop === nameProp) continue;
152
152
  if (prop.name === "key") {
153
- diagnostics.push({
153
+ findings.push({
154
154
  code: "invalid_define",
155
155
  ...placeOf(node, prop),
156
156
  message: `<define name="${name}"> prop "key" is reserved for the instance; it was removed`,
157
- severity: "warning"
157
+ severity: "error"
158
158
  });
159
159
  continue;
160
160
  }
161
161
  if (prop.name === "name") {
162
- diagnostics.push({
162
+ findings.push({
163
163
  code: "invalid_define",
164
164
  ...placeOf(node, prop),
165
165
  message: `<define name="${name}"> cannot take a prop called "name", which names the define itself; call it "title" or "label". It was removed`,
166
- severity: "warning"
166
+ severity: "error"
167
167
  });
168
168
  continue;
169
169
  }
170
170
  if (prop.name === "children" || !isBindingName(prop.name)) {
171
- diagnostics.push({
171
+ findings.push({
172
172
  code: "invalid_define",
173
173
  ...placeOf(node, prop),
174
174
  message: `<define name="${name}"> prop "${prop.name}" is not a usable name; it was removed`,
175
- severity: "warning"
175
+ severity: "error"
176
176
  });
177
177
  continue;
178
178
  }
179
179
  if (seen.has(prop.name)) {
180
- diagnostics.push({
180
+ findings.push({
181
181
  code: "invalid_define",
182
182
  ...placeOf(node, prop),
183
183
  message: `<define name="${name}"> prop "${prop.name}" is duplicated; the later one was removed`,
184
- severity: "warning"
184
+ severity: "error"
185
185
  });
186
186
  continue;
187
187
  }
@@ -355,13 +355,13 @@ function placeOfCode(code) {
355
355
  };
356
356
  }
357
357
  const codeAt = (value) => value?.kind === "expr" ? value.at : void 0;
358
- function validateControl(node, name, children, statements, diagnostics) {
359
- const warn = (code, message, prop) => {
360
- diagnostics.push({
358
+ function validateControl(node, name, children, statements, findings) {
359
+ const reject = (code, message, prop) => {
360
+ findings.push({
361
361
  code,
362
362
  ...placeOf(node, prop),
363
363
  message,
364
- severity: "warning"
364
+ severity: "error"
365
365
  });
366
366
  };
367
367
  const props = new Map(node.props.map((prop) => [prop.name, prop.value]));
@@ -369,21 +369,21 @@ function validateControl(node, name, children, statements, diagnostics) {
369
369
  if (controlProps[name].some((allowed) => allowed === prop.name)) continue;
370
370
  const fix = name === "each" && prop.name === "key" ? dropAttribute(node, index) : null;
371
371
  if (fix === null) {
372
- warn("unknown_prop", `<${name}> has no prop "${prop.name}"`, prop);
372
+ reject("unknown_prop", `<${name}> has no prop "${prop.name}"`, prop);
373
373
  continue;
374
374
  }
375
- diagnostics.push({
375
+ findings.push({
376
376
  code: "unknown_prop",
377
377
  ...placeOf(node, prop),
378
378
  fix,
379
379
  message: "<each> has no prop \"key\": items are keyed by their own `id` or `key`",
380
- severity: "warning"
380
+ severity: "error"
381
381
  });
382
382
  }
383
383
  if (name === "if") {
384
384
  const test = asCode(props.get("test"));
385
385
  if (test === null) {
386
- warn("invalid_control", "<if> needs test={condition}; it was removed");
386
+ reject("invalid_control", "<if> needs test={condition}; it was removed");
387
387
  return [];
388
388
  }
389
389
  const testAt = codeAt(props.get("test"));
@@ -399,14 +399,14 @@ function validateControl(node, name, children, statements, diagnostics) {
399
399
  const items = asCode(props.get("of"));
400
400
  const itemsAt = codeAt(props.get("of"));
401
401
  if (items === null) {
402
- warn("invalid_control", "<each> needs of={list}; it was removed");
402
+ reject("invalid_control", "<each> needs of={list}; it was removed");
403
403
  return [];
404
404
  }
405
405
  const binding = (prop, fallback) => {
406
406
  const value = props.get(prop);
407
407
  if (value === void 0) return fallback;
408
408
  if (value.kind === "literal" && isBindingName(String(value.value))) return String(value.value);
409
- warn("invalid_control", `<each ${prop}> must be a plain variable name like "item"; using ${fallback === null ? "none" : `"${fallback}"`}`);
409
+ reject("invalid_control", `<each ${prop}> must be a plain variable name like "item"; using ${fallback === null ? "none" : `"${fallback}"`}`);
410
410
  return fallback;
411
411
  };
412
412
  return [{
@@ -422,25 +422,25 @@ function validateControl(node, name, children, statements, diagnostics) {
422
422
  }
423
423
  function validateSlot(node, state) {
424
424
  if (!state.inDefine) {
425
- state.diagnostics.push({
425
+ state.findings.push({
426
426
  code: "invalid_define",
427
427
  ...placeOf(node),
428
428
  message: "<slot> is only allowed inside <define>; it was removed",
429
- severity: "warning"
429
+ severity: "error"
430
430
  });
431
431
  return [];
432
432
  }
433
- for (const prop of node.props) state.diagnostics.push({
433
+ for (const prop of node.props) state.findings.push({
434
434
  code: "unknown_prop",
435
435
  ...placeOf(node, prop),
436
436
  message: `<slot> has no prop "${prop.name}"`,
437
- severity: "warning"
437
+ severity: "error"
438
438
  });
439
- if (node.children.length > 0) state.diagnostics.push({
439
+ if (node.children.length > 0) state.findings.push({
440
440
  code: "unexpected_children",
441
441
  ...placeOf(node),
442
442
  message: "<slot> takes no children; they were dropped",
443
- severity: "warning"
443
+ severity: "error"
444
444
  });
445
445
  return [{
446
446
  line: node.line,
@@ -448,15 +448,15 @@ function validateSlot(node, state) {
448
448
  }];
449
449
  }
450
450
  /** Keeps the first `<slot/>` that survived validation and drops the rest. */
451
- function trimExtraSlots(nodes, defineName, diagnostics, seen) {
451
+ function trimExtraSlots(nodes, defineName, findings, seen) {
452
452
  return nodes.flatMap((node) => {
453
453
  if (node.type === "slot") {
454
454
  if (seen.count > 0) {
455
- diagnostics.push({
455
+ findings.push({
456
456
  code: "invalid_define",
457
457
  ...placeOf(node),
458
458
  message: `<${defineName}> already has a <slot>; this one was removed`,
459
- severity: "warning"
459
+ severity: "error"
460
460
  });
461
461
  return [];
462
462
  }
@@ -465,7 +465,7 @@ function trimExtraSlots(nodes, defineName, diagnostics, seen) {
465
465
  }
466
466
  if (node.type === "call" || node.type === "each" || node.type === "element" || node.type === "if") return [{
467
467
  ...node,
468
- children: trimExtraSlots(node.children, defineName, diagnostics, seen)
468
+ children: trimExtraSlots(node.children, defineName, findings, seen)
469
469
  }];
470
470
  return [node];
471
471
  });
@@ -480,11 +480,11 @@ function validateCall(node, def, state, scope) {
480
480
  for (const prop of node.props) {
481
481
  if (prop.name === "key") {
482
482
  if (prop.value.kind === "literal" && typeof prop.value.value !== "string" && typeof prop.value.value !== "number") {
483
- state.diagnostics.push({
483
+ state.findings.push({
484
484
  code: "invalid_literal",
485
485
  ...placeOf(node, prop),
486
486
  message: `<${def.name} key={${JSON.stringify(prop.value.value)}}> must be a string or number`,
487
- severity: "warning"
487
+ severity: "error"
488
488
  });
489
489
  continue;
490
490
  }
@@ -494,17 +494,17 @@ function validateCall(node, def, state, scope) {
494
494
  const spec = known.get(prop.name);
495
495
  if (spec === void 0) {
496
496
  const near = nearest(prop.name, known.keys(), 1)[0];
497
- state.diagnostics.push({
497
+ state.findings.push({
498
498
  code: "unknown_prop",
499
499
  ...placeOf(node, prop),
500
500
  message: near === void 0 ? `<${def.name}> has no prop "${prop.name}"` : `<${def.name}> has no prop "${prop.name}"; did you mean ${near}?`,
501
- severity: "warning"
501
+ severity: "error"
502
502
  });
503
503
  continue;
504
504
  }
505
505
  if (spec.kind === "number" && prop.value.kind === "literal" && typeof prop.value.value === "string" && NUMERIC.test(prop.value.value)) {
506
506
  const value = Number(prop.value.value);
507
- state.diagnostics.push({
507
+ state.findings.push({
508
508
  code: "coerced",
509
509
  ...placeOf(node, prop),
510
510
  message: `<${def.name} ${writtenProp(prop)}> is text; it was read as ${prop.name}={${String(value)}}. Write numbers in braces`,
@@ -517,27 +517,27 @@ function validateCall(node, def, state, scope) {
517
517
  continue;
518
518
  }
519
519
  if (prop.value.kind === "literal" && spec.kind !== null && typeof prop.value.value !== spec.kind) {
520
- state.diagnostics.push({
520
+ state.findings.push({
521
521
  code: "invalid_literal",
522
522
  ...placeOf(node, prop),
523
523
  message: `<${def.name} ${writtenProp(prop)}> does not match a ${spec.kind} value`,
524
- severity: "warning"
524
+ severity: "error"
525
525
  });
526
526
  continue;
527
527
  }
528
528
  props[prop.name] = prop.value;
529
529
  }
530
- for (const spec of def.props) if (spec.required && !Object.hasOwn(props, spec.name)) state.diagnostics.push({
530
+ for (const spec of def.props) if (spec.required && !Object.hasOwn(props, spec.name)) state.findings.push({
531
531
  code: "missing_prop",
532
532
  ...placeOf(node),
533
533
  message: `<${def.name}> is missing required prop "${spec.name}"`,
534
- severity: "warning"
534
+ severity: "error"
535
535
  });
536
- if (!def.hasSlot && def.incomplete !== true && children.length > 0) state.diagnostics.push({
536
+ if (!def.hasSlot && def.incomplete !== true && children.length > 0) state.findings.push({
537
537
  code: "unexpected_children",
538
538
  ...placeOf(node),
539
539
  message: `<${def.name}> has no <slot>, so its children were dropped`,
540
- severity: "warning"
540
+ severity: "error"
541
541
  });
542
542
  return [{
543
543
  children: def.hasSlot ? children : [],
@@ -562,14 +562,14 @@ function validateNodes(nodes, state, scope) {
562
562
  if (misuse !== null) {
563
563
  const inBlock = scope.block === "each" || scope.block === "if";
564
564
  const hoist = inBlock ? state.hoists.byStart.get(node.start) : void 0;
565
- const diagnostic = {
565
+ const finding = {
566
566
  code: inBlock ? "invalid_statement" : "hook_placement",
567
567
  ...placeOfCode(node),
568
568
  message: `${misuse.message}${hoist?.reason === null || hoist?.reason === void 0 ? "" : `; it can't move out by itself, as it ${hoist.reason}`}. The statement was removed`,
569
- severity: "warning"
569
+ severity: "error"
570
570
  };
571
- if (hoist?.fix) diagnostic.fix = hoist.fix;
572
- state.diagnostics.push(diagnostic);
571
+ if (hoist?.fix) finding.fix = hoist.fix;
572
+ state.findings.push(finding);
573
573
  for (const name of misuse.declared) state.dropped.add(name);
574
574
  return [];
575
575
  }
@@ -585,11 +585,11 @@ function validateNodes(nodes, state, scope) {
585
585
  if (node.type === "expr") {
586
586
  const misuse = hookMisuse(node.code, "expression", scope.block);
587
587
  if (misuse !== null) {
588
- state.diagnostics.push({
588
+ state.findings.push({
589
589
  code: "hook_placement",
590
590
  ...placeOfCode(node),
591
591
  message: `${misuse.message}. The expression was removed`,
592
- severity: "warning"
592
+ severity: "error"
593
593
  });
594
594
  return [];
595
595
  }
@@ -597,11 +597,11 @@ function validateNodes(nodes, state, scope) {
597
597
  }
598
598
  if (node.type !== "element") return [node];
599
599
  if (node.name === "define") {
600
- state.diagnostics.push({
600
+ state.findings.push({
601
601
  code: "invalid_define",
602
602
  ...placeOf(node),
603
603
  message: "<define> is only allowed at the top level; it was removed",
604
- severity: "warning"
604
+ severity: "error"
605
605
  });
606
606
  return [];
607
607
  }
@@ -612,18 +612,18 @@ function validateNodes(nodes, state, scope) {
612
612
  block: node.name,
613
613
  statements
614
614
  });
615
- return validateControl(node, node.name, children, statements, state.diagnostics);
615
+ return validateControl(node, node.name, children, statements, state.findings);
616
616
  }
617
617
  const defined = state.defines.get(node.name);
618
618
  if (defined !== void 0) return validateCall(node, defined, state, scope);
619
619
  const children = validateNodes(node.children, state, scope);
620
620
  const spec = componentSpec(state.catalog, node.name);
621
621
  if (spec === void 0) {
622
- state.diagnostics.push({
622
+ state.findings.push({
623
623
  code: "unknown_component",
624
624
  ...placeOf(node),
625
625
  message: unknownComponentMessage(node.name, state.defines, state.catalog),
626
- severity: "warning"
626
+ severity: "error"
627
627
  });
628
628
  return children;
629
629
  }
@@ -634,29 +634,29 @@ function validateNodes(nodes, state, scope) {
634
634
  const propDefinition = propSpec(spec, prop.name);
635
635
  if (propDefinition === void 0) {
636
636
  const wrap = flexWrap(node, prop, state.catalog);
637
- const diagnostic = {
637
+ const finding = {
638
638
  code: "unknown_prop",
639
639
  ...placeOf(node, prop),
640
640
  message: wrap === null ? unknownPropMessage(component, prop.name, spec.props) : `<${component}> has no prop "flex"; wrap it in <box ${wrap.written}>`,
641
- severity: "warning"
641
+ severity: "error"
642
642
  };
643
- if (wrap !== null) diagnostic.fix = wrap.fix;
644
- state.diagnostics.push(diagnostic);
643
+ if (wrap !== null) finding.fix = wrap.fix;
644
+ state.findings.push(finding);
645
645
  continue;
646
646
  }
647
647
  const misuse = prop.value.kind === "expr" ? hookMisuse(prop.value.code, "expression", scope.block) : null;
648
648
  if (misuse !== null) {
649
- state.diagnostics.push({
649
+ state.findings.push({
650
650
  code: "hook_placement",
651
651
  ...placeOf(node, prop),
652
652
  message: `<${component} ${prop.name}={…}>: ${misuse.message}. The prop was removed`,
653
- severity: "warning"
653
+ severity: "error"
654
654
  });
655
655
  continue;
656
656
  }
657
657
  const coerced = coerceNumber(propDefinition, prop.value);
658
658
  if (coerced !== null && coerced.kind === "literal") {
659
- state.diagnostics.push({
659
+ state.findings.push({
660
660
  code: "coerced",
661
661
  ...placeOf(node, prop),
662
662
  message: `<${component} ${writtenProp(prop)}> is text; it was read as ${prop.name}={${String(coerced.value)}}. Write numbers in braces`,
@@ -666,22 +666,22 @@ function validateNodes(nodes, state, scope) {
666
666
  continue;
667
667
  }
668
668
  if (prop.value.kind === "literal" && !isValidValue(propDefinition, prop.value.value)) {
669
- state.diagnostics.push({
669
+ state.findings.push({
670
670
  code: "invalid_literal",
671
671
  ...placeOf(node, prop),
672
672
  message: `<${component} ${writtenProp(prop)}> does not match ${describe(propDefinition, prop.value.value)}`,
673
- severity: "warning"
673
+ severity: "error"
674
674
  });
675
675
  continue;
676
676
  }
677
677
  props[prop.name] = prop.value;
678
678
  }
679
679
  const allowsChildren = spec.children !== "none";
680
- if (!allowsChildren && children.length > 0) state.diagnostics.push({
680
+ if (!allowsChildren && children.length > 0) state.findings.push({
681
681
  code: "unexpected_children",
682
682
  ...placeOf(node),
683
683
  message: `<${component}> takes no children; they were dropped`,
684
- severity: "warning"
684
+ severity: "error"
685
685
  });
686
686
  return [{
687
687
  children: allowsChildren ? children : [],
@@ -738,7 +738,7 @@ function emittedDefines(tree, docDefines, available) {
738
738
  }
739
739
  /**
740
740
  * Static call sites in the document tree and in document-local define bodies.
741
- * A tag inside a library recipe is not counted again.
741
+ * A tag inside a library jig is not counted again.
742
742
  */
743
743
  function countUses(tree, docDefines) {
744
744
  const counts = /* @__PURE__ */ new Map();
@@ -756,7 +756,7 @@ function countUses(tree, docDefines) {
756
756
  }
757
757
  function compileDocument(source, library, components) {
758
758
  const document = parseJigs(source);
759
- const diagnostics = [...document.repairs.map((repair) => ({
759
+ const findings = [...document.repairs.map((repair) => ({
760
760
  code: "repaired",
761
761
  ...placeOf(repair),
762
762
  message: repair.message,
@@ -765,7 +765,7 @@ function compileDocument(source, library, components) {
765
765
  code: "unclosed",
766
766
  ...placeOf(error),
767
767
  message: error.message,
768
- severity: "warning"
768
+ severity: "error"
769
769
  }))];
770
770
  const libraryNames = new Set(library.keys());
771
771
  const docDefines = [];
@@ -774,14 +774,14 @@ function compileDocument(source, library, components) {
774
774
  const docNames = /* @__PURE__ */ new Set();
775
775
  for (const node of document.children) {
776
776
  if (node.type === "element" && node.name === "define") {
777
- const def = readDefine(node, diagnostics, libraryNames, components);
777
+ const def = readDefine(node, findings, libraryNames, components);
778
778
  if (def === null) continue;
779
779
  if (docNames.has(def.name)) {
780
- diagnostics.push({
780
+ findings.push({
781
781
  code: "invalid_define",
782
782
  ...placeOf(node),
783
783
  message: `<define name="${def.name}"> is already defined; this one was removed`,
784
- severity: "warning"
784
+ severity: "error"
785
785
  });
786
786
  continue;
787
787
  }
@@ -801,8 +801,8 @@ function compileDocument(source, library, components) {
801
801
  catalog: components,
802
802
  defineName: null,
803
803
  defines: available,
804
- diagnostics,
805
804
  dropped,
805
+ findings,
806
806
  hoists,
807
807
  inDefine: false,
808
808
  ordinals: /* @__PURE__ */ new Map()
@@ -813,15 +813,15 @@ function compileDocument(source, library, components) {
813
813
  catalog: components,
814
814
  defineName: def.name,
815
815
  defines: available,
816
- diagnostics,
817
816
  dropped,
817
+ findings,
818
818
  hoists,
819
819
  inDefine: true,
820
820
  ordinals: /* @__PURE__ */ new Map()
821
821
  }, {
822
822
  block: "define",
823
823
  statements
824
- }), def.name, diagnostics, { count: 0 });
824
+ }), def.name, findings, { count: 0 });
825
825
  def.statements = statements;
826
826
  }
827
827
  const statements = [];
@@ -831,11 +831,11 @@ function compileDocument(source, library, components) {
831
831
  statements.push(statement);
832
832
  continue;
833
833
  }
834
- diagnostics.push({
834
+ findings.push({
835
835
  code: "hook_placement",
836
836
  ...placeOfCode(statement),
837
837
  message: `${misuse.message}. The statement was removed`,
838
- severity: "warning"
838
+ severity: "error"
839
839
  });
840
840
  for (const name of misuse.declared) dropped.add(name);
841
841
  }
@@ -843,17 +843,14 @@ function compileDocument(source, library, components) {
843
843
  block: null,
844
844
  statements
845
845
  });
846
- const uses = checkCode(tree, docDefines, statements, diagnostics, hoists, dropped);
846
+ const uses = checkCode(tree, docDefines, statements, findings, hoists, dropped);
847
847
  const generated = generateProgram(statements, tree, emittedDefines(tree, docDefines, available));
848
- diagnostics.push(...generated.diagnostics);
849
- for (const diagnostic of diagnostics) if (diagnostic.start !== void 0) {
850
- diagnostic.line = lineAt(source, diagnostic.start);
851
- diagnostic.column = columnAt(source, diagnostic.start);
852
- }
853
- diagnostics.sort((a, b) => a.line - b.line);
848
+ findings.push(...generated.findings);
849
+ for (const finding of findings) if (finding.start !== void 0) finding.line = lineAt(source, finding.start);
850
+ findings.sort((a, b) => a.line - b.line);
854
851
  return {
855
852
  defines: docDefines,
856
- diagnostics,
853
+ findings,
857
854
  library,
858
855
  output: generated.output,
859
856
  procedures: uses.procedures,
@@ -868,14 +865,14 @@ function compileDocument(source, library, components) {
868
865
  }
869
866
  function buildLibrary(list, components) {
870
867
  const built = /* @__PURE__ */ new Map();
871
- for (const recipe of list) {
872
- if (built.has(recipe.name)) throw new Error(`Recipe name "${recipe.name}" is duplicated`);
873
- const result = compileDocument(recipe.source, built, components);
874
- const problem = result.diagnostics[0];
875
- if (problem !== void 0) throw new Error(`Recipe "${recipe.name}" failed to compile: line ${problem.line}: ${problem.message}`);
868
+ for (const jig of list) {
869
+ if (built.has(jig.name)) throw new Error(`Jig name "${jig.name}" is duplicated`);
870
+ const result = compileDocument(jig.source, built, components);
871
+ const problem = result.findings[0];
872
+ if (problem !== void 0) throw new Error(`Jig "${jig.name}" failed to compile: line ${problem.line}: ${problem.message}`);
876
873
  const def = result.defines[0];
877
- if (result.defines.length !== 1 || def === void 0) throw new Error(`Recipe "${recipe.name}" must contain exactly one <define>`);
878
- if (def.name !== recipe.name) throw new Error(`Recipe "${recipe.name}" defines <${def.name}> instead`);
874
+ if (result.defines.length !== 1 || def === void 0) throw new Error(`Jig "${jig.name}" must contain exactly one <define>`);
875
+ if (def.name !== jig.name) throw new Error(`Jig "${jig.name}" defines <${def.name}> instead`);
879
876
  built.set(def.name, {
880
877
  ...def,
881
878
  origin: "library",
@@ -885,17 +882,17 @@ function buildLibrary(list, components) {
885
882
  return built;
886
883
  }
887
884
  /**
888
- * Compiles `recipes` against `catalog`, in order: a recipe may use the ones
889
- * before it. Throws when a recipe has any diagnostic. Cached per catalog and
890
- * recipe list.
885
+ * Compiles `jigs` against `catalog`, in order: a jig may use the ones
886
+ * before it. Throws when a jig has any diagnostic. Cached per catalog and
887
+ * jig list.
891
888
  */
892
- function compileLibrary(recipes, catalog) {
893
- const byRecipes = libraryCache.get(catalog) ?? /* @__PURE__ */ new WeakMap();
894
- libraryCache.set(catalog, byRecipes);
895
- const existing = byRecipes.get(recipes);
889
+ function compileLibrary(jigs, catalog) {
890
+ const byJigs = libraryCache.get(catalog) ?? /* @__PURE__ */ new WeakMap();
891
+ libraryCache.set(catalog, byJigs);
892
+ const existing = byJigs.get(jigs);
896
893
  if (existing !== void 0) return existing;
897
- const built = buildLibrary(recipes, catalog);
898
- byRecipes.set(recipes, built);
894
+ const built = buildLibrary(jigs, catalog);
895
+ byJigs.set(jigs, built);
899
896
  return built;
900
897
  }
901
898
  /** `source` with the fixes' edits applied. */
@@ -917,34 +914,57 @@ function applyFixes(source, fixes) {
917
914
  text
918
915
  };
919
916
  }
920
- const problems = (diagnostics) => diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
917
+ const problems = (findings) => findings.filter((finding) => finding.severity === "error").length;
918
+ /** `finding` as a `Diagnostic`, its spans counted in `source`. */
919
+ function toDiagnostic(source, finding) {
920
+ const diagnostic = {
921
+ code: finding.code,
922
+ message: finding.message,
923
+ severity: finding.severity,
924
+ span: spanIn(source, finding)
925
+ };
926
+ if (finding.fix !== void 0) diagnostic.fix = {
927
+ edits: finding.fix.edits.map((edit) => ({
928
+ span: rangeIn(source, edit.start, edit.end),
929
+ text: edit.text
930
+ })),
931
+ title: finding.fix.title
932
+ };
933
+ return diagnostic;
934
+ }
935
+ /** `document`'s result, reporting `findings` as diagnostics of `source`. */
936
+ function withDiagnostics({ findings: _, ...result }, source, findings) {
937
+ return {
938
+ ...result,
939
+ diagnostics: findings.map((finding) => toDiagnostic(source, finding))
940
+ };
941
+ }
921
942
  /**
922
- * Compiles a document. A warning that carries a fix is applied rather than
943
+ * Compiles a document. An error that carries a fix is applied rather than
923
944
  * sent to the model: the fixed source is compiled again, and when it has
924
945
  * exactly the problems left that the fixes did not cover, its program is
925
- * used and those warnings become `info`, as a quoted number on a number prop
946
+ * used and those errors become `info`, as a quoted number on a number prop
926
947
  * already is. Diagnostics, procedures and their spans stay those of `source`,
927
948
  * which is what the model wrote and what the session keeps.
928
949
  */
929
950
  function compileJigs(source, options) {
930
951
  const library = options.library ?? NO_LIBRARY;
931
952
  const result = compileDocument(source, library, options.catalog);
932
- const fixes = new Set(result.diagnostics.flatMap((diagnostic) => diagnostic.severity === "warning" && diagnostic.fix !== void 0 ? [diagnostic.fix] : []));
933
- if (fixes.size === 0) return result;
953
+ const fixes = new Set(result.findings.flatMap((finding) => finding.severity === "error" && finding.fix !== void 0 ? [finding.fix] : []));
954
+ if (fixes.size === 0) return withDiagnostics(result, source, result.findings);
934
955
  const patched = applyFixes(source, [...fixes]);
935
956
  const fixed = compileDocument(patched.text, library, options.catalog);
936
- const covered = result.diagnostics.filter((diagnostic) => diagnostic.fix !== void 0 && patched.applied.has(diagnostic.fix)).length;
937
- if (problems(fixed.diagnostics) !== problems(result.diagnostics) - covered) return result;
938
- return {
957
+ const covered = result.findings.filter((finding) => finding.fix !== void 0 && patched.applied.has(finding.fix)).length;
958
+ if (problems(fixed.findings) !== problems(result.findings) - covered) return withDiagnostics(result, source, result.findings);
959
+ return withDiagnostics({
939
960
  ...fixed,
940
- diagnostics: result.diagnostics.map((diagnostic) => diagnostic.fix !== void 0 && patched.applied.has(diagnostic.fix) ? {
941
- ...diagnostic,
942
- message: `${diagnostic.message}; the compiler made that change`,
943
- severity: "info"
944
- } : diagnostic),
945
961
  procedures: result.procedures,
946
962
  reads: result.reads
947
- };
963
+ }, source, result.findings.map((finding) => finding.fix !== void 0 && patched.applied.has(finding.fix) ? {
964
+ ...finding,
965
+ message: `${finding.message}; the compiler made that change`,
966
+ severity: "info"
967
+ } : finding));
948
968
  }
949
969
  //#endregion
950
970
  export { compileJigs, compileLibrary };
package/dist/index.d.ts CHANGED
@@ -1,12 +1,12 @@
1
- import { columnAt, lineAt } from "./offsets.js";
1
+ import { columnAt, lineAt, rangeIn, spanIn } from "./offsets.js";
2
2
  import { JigsNode, Statement } from "./ast.js";
3
3
  import { CompileCatalog, ComponentSpec, PropSpec } from "./spec.js";
4
4
  import { ProgramOutput } from "./codegen.js";
5
- import { CompileOptions, CompileResult, CompiledDefine, Diagnostic, Library, Program, Recipe, RenderNode, compileJigs, compileLibrary } from "./compile.js";
5
+ import { CompileOptions, CompileResult, CompiledDefine, Diagnostic, Jig, Library, Program, RenderNode, compileJigs, compileLibrary } from "./compile.js";
6
6
  import { DataRead, ELEMENT, ProcedureUse } from "./check-code.js";
7
7
  import { Hoisted, hoistDeclarations } from "./hoist.js";
8
8
  import { parseJigs } from "./parse.js";
9
9
  import { nearest } from "./suggest.js";
10
- import { recipeSignature } from "./signature.js";
10
+ import { jigSignature } from "./signature.js";
11
11
  import { Edit, EditFailure, EditFailureReason, SEARCH_MARKER, applyEdits, editBreakage, isEditReply, parseEdits } from "./patch.js";
12
- export { type CompileCatalog, type CompileOptions, type CompileResult, type CompiledDefine, type ComponentSpec, type DataRead, type Diagnostic, ELEMENT, type Edit, type EditFailure, type EditFailureReason, type Hoisted, type JigsNode, type Library, type ProcedureUse, type Program, type ProgramOutput, type PropSpec, type Recipe, type RenderNode, SEARCH_MARKER, type Statement, applyEdits, columnAt, compileJigs, compileLibrary, editBreakage, hoistDeclarations, isEditReply, lineAt, nearest, parseEdits, parseJigs, recipeSignature };
12
+ export { type CompileCatalog, type CompileOptions, type CompileResult, type CompiledDefine, type ComponentSpec, type DataRead, type Diagnostic, ELEMENT, type Edit, type EditFailure, type EditFailureReason, type Hoisted, type Jig, type JigsNode, type Library, type ProcedureUse, type Program, type ProgramOutput, type PropSpec, type RenderNode, SEARCH_MARKER, type Statement, applyEdits, columnAt, compileJigs, compileLibrary, editBreakage, hoistDeclarations, isEditReply, jigSignature, lineAt, nearest, parseEdits, parseJigs, rangeIn, spanIn };
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { columnAt, lineAt } from "./offsets.js";
1
+ import { columnAt, lineAt, rangeIn, spanIn } from "./offsets.js";
2
2
  import { nearest } from "./suggest.js";
3
3
  import { ELEMENT } from "./check-code.js";
4
4
  import { parseJigs } from "./parse.js";
5
5
  import { hoistDeclarations } from "./hoist.js";
6
6
  import { compileJigs, compileLibrary } from "./compile.js";
7
- import { recipeSignature } from "./signature.js";
7
+ import { jigSignature } from "./signature.js";
8
8
  import { SEARCH_MARKER, applyEdits, editBreakage, isEditReply, parseEdits } from "./patch.js";
9
- export { ELEMENT, SEARCH_MARKER, applyEdits, columnAt, compileJigs, compileLibrary, editBreakage, hoistDeclarations, isEditReply, lineAt, nearest, parseEdits, parseJigs, recipeSignature };
9
+ export { ELEMENT, SEARCH_MARKER, applyEdits, columnAt, compileJigs, compileLibrary, editBreakage, hoistDeclarations, isEditReply, jigSignature, lineAt, nearest, parseEdits, parseJigs, rangeIn, spanIn };
package/dist/offsets.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Span } from "@jigs.design/protocol";
1
2
  //#region src/offsets.d.ts
2
3
  /**
3
4
  * Where text the parser cut out sits in the source. Each entry is
@@ -15,4 +16,12 @@ export interface Range {
15
16
  export declare function columnAt(source: string, offset: number): number;
16
17
  /** The 1-based line of `offset` in `source`. */
17
18
  export declare function lineAt(source: string, offset: number): number;
19
+ /** The span of `[start, end)` in `source`: its line, column and range. */
20
+ export declare function rangeIn(source: string, start: number, end: number): Span;
21
+ /** The span of `place` in `source`: its line, and its range and column when it has one. */
22
+ export declare function spanIn(source: string, place: {
23
+ end?: number;
24
+ line: number;
25
+ start?: number;
26
+ }): Span;
18
27
  //#endregion
package/dist/offsets.js CHANGED
@@ -36,5 +36,18 @@ function lineAt(source, offset) {
36
36
  for (let newline = source.indexOf("\n"); newline !== -1 && newline < offset; newline = source.indexOf("\n", newline + 1)) line++;
37
37
  return line;
38
38
  }
39
+ /** The span of `[start, end)` in `source`: its line, column and range. */
40
+ function rangeIn(source, start, end) {
41
+ return {
42
+ column: columnAt(source, start),
43
+ end,
44
+ line: lineAt(source, start),
45
+ start
46
+ };
47
+ }
48
+ /** The span of `place` in `source`: its line, and its range and column when it has one. */
49
+ function spanIn(source, place) {
50
+ return place.start === void 0 || place.end === void 0 ? { line: place.line } : rangeIn(source, place.start, place.end);
51
+ }
39
52
  //#endregion
40
- export { columnAt, extendMap, lineAt, sliceMap, sourceOffset, sourceRange };
53
+ export { columnAt, extendMap, lineAt, rangeIn, sliceMap, sourceOffset, sourceRange, spanIn };
package/dist/patch.d.ts CHANGED
@@ -13,11 +13,13 @@ export declare function isEditReply(text: string): boolean | null;
13
13
  export declare function parseEdits(text: string): Array<Edit>;
14
14
  /**
15
15
  * Why an edit was rejected. `missing`: its SEARCH is not in the document.
16
- * `ambiguous`: its SEARCH is there more than once. `malformed`: the block
17
- * itself is broken. `broken`: it applied, but the result needs parser repairs
18
- * the document did not (see `editBreakage`); the server reports this one.
16
+ * `ambiguous`: its SEARCH is there more than once. `overlapping`: its SEARCH
17
+ * spans lines an earlier edit of the same reply also changes. `malformed`:
18
+ * the block itself is broken. `broken`: it applied, but the result needs
19
+ * parser repairs the document did not (see `editBreakage`); the server
20
+ * reports this one.
19
21
  */
20
- export type EditFailureReason = "ambiguous" | "broken" | "malformed" | "missing";
22
+ export type EditFailureReason = "ambiguous" | "broken" | "malformed" | "missing" | "overlapping";
21
23
  export interface EditFailure {
22
24
  /** For `broken`: the repairs the edited document needs and the base did not. */
23
25
  breakage?: ReadonlyArray<{
@@ -25,8 +27,13 @@ export interface EditFailure {
25
27
  message: string;
26
28
  }>;
27
29
  failed: Edit;
28
- /** For `ambiguous`: the line each match starts on. */
30
+ /**
31
+ * For `ambiguous`: the line each match starts on. For `overlapping`: the
32
+ * line the earlier edit's SEARCH starts on, then this one's.
33
+ */
29
34
  lines?: ReadonlyArray<number>;
35
+ /** For `overlapping`: the earlier edit whose lines this one also changes. */
36
+ overlaps?: Edit;
30
37
  reason: EditFailureReason;
31
38
  }
32
39
  export type ApplyResult = (EditFailure & {
@@ -36,10 +43,15 @@ export type ApplyResult = (EditFailure & {
36
43
  text: string;
37
44
  };
38
45
  /**
39
- * Applies edits in order. Each SEARCH must appear in the document exactly
40
- * once, exactly or ignoring indentation; an empty SEARCH appends. Stops at
41
- * the first edit that doesn't match, matches more than once, or whose
42
- * REPLACE still contains edit markers.
46
+ * Applies a reply's edits to the document it was written against. Every
47
+ * SEARCH is located in that document first, exactly or ignoring
48
+ * indentation, and must be there once; the replacements are then made from
49
+ * the end, so one edit's REPLACE never moves or breaks another's anchor. An
50
+ * empty SEARCH appends. A SEARCH that is not in the document, or that spans
51
+ * lines an earlier edit's SEARCH spans, applies to the text as the edits
52
+ * before it leave it when it is there once, as edits written one after
53
+ * another; the edits after it anchor to that text. Otherwise it fails as
54
+ * `missing` or `overlapping`. Stops at the first edit that fails.
43
55
  */
44
56
  export declare function applyEdits(base: string, edits: ReadonlyArray<Edit>): ApplyResult;
45
57
  /**
package/dist/patch.js CHANGED
@@ -40,18 +40,39 @@ function parseEdits(text) {
40
40
  }
41
41
  return edits;
42
42
  }
43
- /** Starts of every line range in `lines` matching `search` when leading and trailing whitespace is ignored. */
44
- function findLoose(lines, search) {
45
- const target = search.map((line) => line.trim());
46
- const starts = [];
47
- for (let start = 0; start + target.length <= lines.length; start++) if (target.every((line, offset) => lines[start + offset]?.trim() === line)) starts.push(start);
48
- return starts;
43
+ /** Every line range of `text` matching `search` when leading and trailing whitespace is ignored. */
44
+ function findLoose(text, search) {
45
+ const lines = text.split("\n");
46
+ const target = search.split("\n").map((line) => line.trim());
47
+ const offsets = [];
48
+ let offset = 0;
49
+ for (const line of lines) {
50
+ offsets.push(offset);
51
+ offset += line.length + 1;
52
+ }
53
+ const ranges = [];
54
+ for (let start = 0; start + target.length <= lines.length; start++) {
55
+ const last = start + target.length - 1;
56
+ if (target.every((line, index) => lines[start + index]?.trim() === line)) ranges.push({
57
+ end: (offsets[last] ?? 0) + (lines[last]?.length ?? 0),
58
+ start: offsets[start] ?? 0
59
+ });
60
+ }
61
+ return ranges;
49
62
  }
50
- /** Every index at which `search` occurs in `text`, without overlaps. */
63
+ /** Every range at which `search` occurs in `text`, without overlaps. */
51
64
  function findExact(text, search) {
52
- const indexes = [];
53
- for (let index = text.indexOf(search); index !== -1; index = text.indexOf(search, index + search.length)) indexes.push(index);
54
- return indexes;
65
+ const ranges = [];
66
+ for (let index = text.indexOf(search); index !== -1; index = text.indexOf(search, index + search.length)) ranges.push({
67
+ end: index + search.length,
68
+ start: index
69
+ });
70
+ return ranges;
71
+ }
72
+ /** Where `search` is in `text`: exactly if it is there, else ignoring indentation. */
73
+ function locate(text, search) {
74
+ const exact = findExact(text, search);
75
+ return exact.length > 0 ? exact : findLoose(text, search);
55
76
  }
56
77
  const lineAt = (text, index) => text.slice(0, index).split("\n").length;
57
78
  const MARKERS = /* @__PURE__ */ new Set([
@@ -61,13 +82,31 @@ const MARKERS = /* @__PURE__ */ new Set([
61
82
  ]);
62
83
  const hasMarker = (text) => text.split("\n").some((line) => MARKERS.has(line.trim()));
63
84
  /**
64
- * Applies edits in order. Each SEARCH must appear in the document exactly
65
- * once, exactly or ignoring indentation; an empty SEARCH appends. Stops at
66
- * the first edit that doesn't match, matches more than once, or whose
67
- * REPLACE still contains edit markers.
85
+ * `text` with the anchored replacements made from the end, so none moves
86
+ * another's range, then the appends in order.
87
+ */
88
+ function applyAnchored(text, anchored, appended) {
89
+ let result = text;
90
+ const fromEnd = anchored.toSorted((a, b) => b.range.start - a.range.start);
91
+ for (const place of fromEnd) result = result.slice(0, place.range.start) + place.edit.replace + result.slice(place.range.end);
92
+ for (const replace of appended) result = `${result.trimEnd()}\n${replace}`;
93
+ return result;
94
+ }
95
+ /**
96
+ * Applies a reply's edits to the document it was written against. Every
97
+ * SEARCH is located in that document first, exactly or ignoring
98
+ * indentation, and must be there once; the replacements are then made from
99
+ * the end, so one edit's REPLACE never moves or breaks another's anchor. An
100
+ * empty SEARCH appends. A SEARCH that is not in the document, or that spans
101
+ * lines an earlier edit's SEARCH spans, applies to the text as the edits
102
+ * before it leave it when it is there once, as edits written one after
103
+ * another; the edits after it anchor to that text. Otherwise it fails as
104
+ * `missing` or `overlapping`. Stops at the first edit that fails.
68
105
  */
69
106
  function applyEdits(base, edits) {
70
107
  let text = base;
108
+ let anchored = [];
109
+ let appended = [];
71
110
  for (const edit of edits) {
72
111
  if (hasMarker(edit.replace)) return {
73
112
  failed: edit,
@@ -75,42 +114,49 @@ function applyEdits(base, edits) {
75
114
  reason: "malformed"
76
115
  };
77
116
  if (edit.search.trim() === "") {
78
- text = `${text.trimEnd()}\n${edit.replace}`;
117
+ appended.push(edit.replace);
79
118
  continue;
80
119
  }
81
- const exact = findExact(text, edit.search);
82
- if (exact.length > 1) return {
120
+ const ranges = locate(text, edit.search);
121
+ if (ranges.length > 1) return {
83
122
  failed: edit,
84
- lines: exact.map((index) => lineAt(text, index)),
123
+ lines: ranges.map((range) => lineAt(text, range.start)),
85
124
  ok: false,
86
125
  reason: "ambiguous"
87
126
  };
88
- const [index] = exact;
89
- if (index !== void 0) {
90
- text = text.slice(0, index) + edit.replace + text.slice(index + edit.search.length);
127
+ const [range] = ranges;
128
+ const overlap = range === void 0 ? void 0 : anchored.find((other) => range.start < other.range.end && other.range.start < range.end);
129
+ if (range !== void 0 && overlap === void 0) {
130
+ anchored.push({
131
+ edit,
132
+ range
133
+ });
91
134
  continue;
92
135
  }
93
- const lines = text.split("\n");
94
- const searchLines = edit.search.split("\n");
95
- const starts = findLoose(lines, searchLines);
96
- if (starts.length > 1) return {
136
+ const edited = applyAnchored(text, anchored, appended);
137
+ const later = locate(edited, edit.search);
138
+ const [found] = later;
139
+ if (found === void 0 || later.length > 1) return range === void 0 || overlap === void 0 ? {
97
140
  failed: edit,
98
- lines: starts.map((start) => start + 1),
99
141
  ok: false,
100
- reason: "ambiguous"
101
- };
102
- const [start] = starts;
103
- if (start === void 0) return {
142
+ reason: "missing"
143
+ } : {
104
144
  failed: edit,
145
+ lines: [lineAt(text, overlap.range.start), lineAt(text, range.start)],
105
146
  ok: false,
106
- reason: "missing"
147
+ overlaps: overlap.edit,
148
+ reason: "overlapping"
107
149
  };
108
- lines.splice(start, searchLines.length, ...edit.replace.split("\n"));
109
- text = lines.join("\n");
150
+ text = edited;
151
+ anchored = [{
152
+ edit,
153
+ range: found
154
+ }];
155
+ appended = [];
110
156
  }
111
157
  return {
112
158
  ok: true,
113
- text
159
+ text: applyAnchored(text, anchored, appended)
114
160
  };
115
161
  }
116
162
  /** Diagnostic codes the parser reports when it changed the source to make it parse. */
@@ -1,5 +1,5 @@
1
- import { Recipe } from "./compile.js";
1
+ import { Jig } from "./compile.js";
2
2
  //#region src/signature.d.ts
3
3
  /** `<statCard label value delta={0} note="">…</statCard>` when the define has a slot. */
4
- export declare function recipeSignature(recipe: Recipe): string;
4
+ export declare function jigSignature(jig: Jig): string;
5
5
  //#endregion
package/dist/signature.js CHANGED
@@ -14,12 +14,12 @@ function hasSlot(nodes) {
14
14
  return false;
15
15
  }
16
16
  /** `<statCard label value delta={0} note="">…</statCard>` when the define has a slot. */
17
- function recipeSignature(recipe) {
18
- const define = parseJigs(recipe.source).children.find((node) => node.type === "element" && node.name === "define");
19
- if (define === void 0) return `<${recipe.name}>`;
17
+ function jigSignature(jig) {
18
+ const define = parseJigs(jig.source).children.find((node) => node.type === "element" && node.name === "define");
19
+ if (define === void 0) return `<${jig.name}>`;
20
20
  const attrs = define.props.filter((prop) => prop.name !== "name").map((prop) => formatAttr(prop));
21
- const open = attrs.length === 0 ? `<${recipe.name}>` : `<${recipe.name} ${attrs.join(" ")}>`;
22
- return hasSlot(define.children) ? `${open}…</${recipe.name}>` : open;
21
+ const open = attrs.length === 0 ? `<${jig.name}>` : `<${jig.name} ${attrs.join(" ")}>`;
22
+ return hasSlot(define.children) ? `${open}…</${jig.name}>` : open;
23
23
  }
24
24
  //#endregion
25
- export { recipeSignature };
25
+ export { jigSignature };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jigs.design/compiler",
3
- "version": "0.1.2",
4
- "description": "Today's Jigs compiler: parser, checker against a catalog, code generator, recipes, and the edit helpers. The v2 rewrite replaces it.",
3
+ "version": "0.3.0",
4
+ "description": "Today's Jigs compiler: parser, checker against a catalog, code generator, jig libraries, and the edit helpers. The v2 rewrite replaces it.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -25,6 +25,7 @@
25
25
  "access": "public"
26
26
  },
27
27
  "dependencies": {
28
- "acorn": "8.19.0"
28
+ "acorn": "8.19.0",
29
+ "@jigs.design/protocol": "0.3.0"
29
30
  }
30
31
  }