Skip to content

Commit f366e1f

Browse files
committed
MeTTa TS 1.0.9
1 parent ed242af commit f366e1f

12 files changed

Lines changed: 320 additions & 76 deletions

File tree

RELEASE_NOTES.md

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
# MeTTa TS 1.0.8
1+
# MeTTa TS 1.0.9
22

33
A pure-TypeScript implementation of [MeTTa](https://metta-lang.dev) (Meta Type Talk), the OpenCog Hyperon language. It runs anywhere TypeScript runs: the browser, Node, Deno, Bun, and edge or serverless functions. No native addons, no WASM, no Rust.
44

55
## Tested on Linux
66

77
This release is tested on Linux (Node 20, the CI matrix): lint, format, typecheck, the full test suite, and the build all run there. Because the engine is pure TypeScript with no native addon and no WASM, it is meant to be cross-platform and should run unchanged on any JavaScript runtime. Other operating systems are not yet part of the tested matrix.
88

9-
## What's new: a static analyzer and `metta-ts --check`
9+
## What's new: a recovering CST for editors and language servers
1010

11-
This release adds a static analyzer that catches mistakes before a program runs and reports them the way a modern compiler does: the exact source span underlined, an error code, a message, and a suggested fix. It ships in `@metta-ts/core` as a library and on the `metta-ts` CLI as `--check`.
11+
1.0.8 added a span-tracking parse for the static analyzer. This release turns it into a concrete syntax tree an editor can build on. `parseCst` never throws, so a language server can keep offering features while a document is mid-edit: an unclosed `(` closes at end of input, an unexpected `)` and an unterminated string each become a diagnostic instead of an exception, and deep nesting is bounded without a recursive overflow. The tree also carries what an editor needs and the analyzer did not: the comments, a syntactic kind per node, the paren spans, and the span of a top-level `!` query.
1212

13-
The analyzer reads the interpreter's own signature table, so it flags exactly the calls the interpreter itself would reject. A builtin called with the wrong number of arguments is checked against the same `(-> ...)` declaration the evaluator uses at run time, not against a second copy of the arity rules that could drift from it:
13+
Leaf atoms still come from the interpreter's own reader primitives, so on valid input the CST is byte-identical to `parseAll`. A 1000-run differential checks the atoms and bang flags against the plain reader, and a 2000-run fuzz checks that the parser never throws on arbitrary input. The diagnostics are shaped as Language Server Protocol `Diagnostic`s with a range, a severity, and a stable code, so an editor consumes them directly. The static analyzer and `metta-ts --check` from 1.0.8 are unchanged and still read the interpreter's own signature table:
1414

1515
```
1616
error[arity-mismatch]: prog.metta:1:2
@@ -19,11 +19,7 @@ error[arity-mismatch]: prog.metta:1:2
1919
| ^^^^^^^^^^^^^^ car-atom expects 1 argument, got 2
2020
```
2121

22-
An opt-in `--undefined-symbols` pass adds a "did you mean" on an unknown head, suggesting the closest defined name by edit distance. It is off by default because MeTTa's add-mode makes an unknown head legal: `(foo 1 2)` with no rule for `foo` is data added to the space, not a typo. With the flag on, `(fibonaci 10)` next to a defined `fibonacci` becomes a warning that carries the fix, never an error.
23-
24-
Precise spans come from a span-tracking parse that reuses the interpreter's own reader primitives, so the analyzer cannot disagree with the real parser about where a token starts or ends. `--json` emits the findings as a Language Server Protocol `Diagnostic[]`, each with a range, a severity, a code, and suggestions that carry an applicability tier, so an editor or a language server can consume them directly.
25-
26-
None of this changes the evaluator. The only edit to existing run-time code is a refactor of the parser that exports the primitives the span parser reuses, and it is guarded by the parser's own tests. So the 270-assertion Hyperon oracle and the corpus benchmark below are byte-identical to 1.0.7, and the analyzer adds no dependency.
22+
None of this touches the evaluator. `parseCst` is off the `runFile` hot path; the only change to shared code is that `readStringAt` now reports whether a string was terminated instead of throwing, and the plain parser re-throws exactly as before. The 270-assertion Hyperon oracle and every byte-identical experimental suite still pass, and a parse/eval microbench is within noise of 1.0.8.
2723

2824
## Corpus benchmark
2925

packages/browser/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@metta-ts/browser",
3-
"version": "1.0.8",
3+
"version": "1.0.9",
44
"license": "MIT",
55
"type": "module",
66
"main": "./dist/index.js",

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@metta-ts/core",
3-
"version": "1.0.8",
3+
"version": "1.0.9",
44
"license": "MIT",
55
"type": "module",
66
"main": "./dist/index.js",

packages/core/src/cst.test.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// SPDX-FileCopyrightText: 2026 MesTTo
22
// SPDX-License-Identifier: MIT
3+
import fc from "fast-check";
34
import { describe, it, expect } from "vitest";
4-
import { parseAllSpanned } from "./cst";
5+
import { parseAllSpanned, parseCst } from "./cst";
56
import { standardTokenizer } from "./runner";
6-
import { format } from "./parser";
7+
import { format, parseAll } from "./parser";
78

89
describe("parseAllSpanned", () => {
910
it("records the span of a nested symbol precisely", () => {
@@ -36,3 +37,98 @@ describe("parseAllSpanned", () => {
3637
expect(src.slice(strNode.span.start, strNode.span.end)).toBe('"hi"');
3738
});
3839
});
40+
41+
describe("parseCst — editor CST", () => {
42+
it("collects line comments with their spans, out of the atom tree", () => {
43+
const src = "; hi\n(f 1) ; trailing\n(g 2)";
44+
const cst = parseCst(src, standardTokenizer());
45+
expect(cst.comments.map((c) => src.slice(c.span.start, c.span.end))).toEqual([
46+
"; hi",
47+
"; trailing",
48+
]);
49+
expect(cst.nodes.map((n) => format(n.atom))).toEqual(["(f 1)", "(g 2)"]);
50+
expect(cst.diagnostics).toEqual([]);
51+
});
52+
53+
it("marks a top-level bang and records the ! span separately from the form", () => {
54+
const src = "!(f 2)";
55+
const [top] = parseCst(src, standardTokenizer()).nodes;
56+
expect(top!.bang).toBe(true);
57+
expect(src.slice(top!.bangSpan!.start, top!.bangSpan!.end)).toBe("!");
58+
expect(src.slice(top!.span.start, top!.span.end)).toBe("(f 2)");
59+
expect(format(top!.atom)).toBe("(f 2)");
60+
});
61+
62+
it("records open and close paren spans on an expression", () => {
63+
const src = "(a b)";
64+
const [top] = parseCst(src, standardTokenizer()).nodes;
65+
expect(src.slice(top!.open!.start, top!.open!.end)).toBe("(");
66+
expect(src.slice(top!.close!.start, top!.close!.end)).toBe(")");
67+
});
68+
69+
it("classifies leaf kinds from the atom", () => {
70+
const [top] = parseCst('(f $x 42 "s" True)', standardTokenizer()).nodes;
71+
expect(top!.children!.map((c) => c.kind)).toEqual([
72+
"symbol",
73+
"variable",
74+
"number",
75+
"string",
76+
"symbol",
77+
]);
78+
});
79+
80+
it("recovers from an unclosed paren without throwing and closes at end of input", () => {
81+
const cst = parseCst("(f (g 1)", standardTokenizer());
82+
expect(cst.diagnostics.map((d) => d.code)).toContain("syntax.unclosedDelimiter");
83+
expect(format(cst.nodes[0]!.atom)).toBe("(f (g 1))");
84+
expect(cst.nodes[0]!.close).toBeUndefined();
85+
});
86+
87+
it("recovers from an unexpected closing paren", () => {
88+
const cst = parseCst("(f 1)) (g 2)", standardTokenizer());
89+
expect(cst.diagnostics.map((d) => d.code)).toContain("syntax.unexpectedClose");
90+
expect(cst.nodes.map((n) => format(n.atom))).toEqual(["(f 1)", "(g 2)"]);
91+
});
92+
93+
it("recovers from an unterminated string", () => {
94+
const cst = parseCst('(f "oops)', standardTokenizer());
95+
expect(cst.diagnostics.map((d) => d.code)).toContain("syntax.unterminatedString");
96+
});
97+
98+
it("never throws on arbitrary input", () => {
99+
fc.assert(
100+
fc.property(fc.string(), (src) => {
101+
parseCst(src, standardTokenizer());
102+
}),
103+
{ numRuns: 2000 },
104+
);
105+
});
106+
107+
it("produces the same atoms and bang flags as parseAll on valid programs", () => {
108+
const leaf = fc.oneof(
109+
fc.constantFrom("foo", "bar", "f", "g", "map", "+", "-", "=", ":", "cons"),
110+
fc.constantFrom("$x", "$y", "$acc", "$xs"),
111+
fc.integer({ min: -999, max: 999 }).map(String),
112+
fc.constantFrom('"hi"', '"a b"', '"x"'),
113+
);
114+
const { atomSrc } = fc.letrec((tie) => ({
115+
atomSrc: fc.oneof({ maxDepth: 4 }, leaf, tie("exprSrc")),
116+
exprSrc: fc
117+
.array(tie("atomSrc"), { minLength: 0, maxLength: 4 })
118+
.map((items) => `(${items.join(" ")})`),
119+
}));
120+
const programArb = fc
121+
.array(fc.tuple(fc.boolean(), atomSrc), { minLength: 1, maxLength: 6 })
122+
.map((tops) => tops.map(([bang, s]) => (bang ? `!${s}` : s)).join("\n"));
123+
fc.assert(
124+
fc.property(programArb, (src) => {
125+
const cst = parseCst(src, standardTokenizer());
126+
const plain = parseAll(src, standardTokenizer());
127+
expect(cst.diagnostics).toEqual([]);
128+
expect(cst.nodes.map((n) => format(n.atom))).toEqual(plain.map((t) => format(t.atom)));
129+
expect(cst.nodes.map((n) => n.bang === true)).toEqual(plain.map((t) => t.bang));
130+
}),
131+
{ numRuns: 1000 },
132+
);
133+
});
134+
});

0 commit comments

Comments
 (0)