diff --git a/.changeset/cli-agent-tools.md b/.changeset/cli-agent-tools.md new file mode 100644 index 0000000000..a2986ad40e --- /dev/null +++ b/.changeset/cli-agent-tools.md @@ -0,0 +1,11 @@ +--- +"@quri/squiggle-lang": minor +--- + +CLI improvements for scripting and coding agents: + +- `squiggle run --json` prints a single JSON object with the result, bindings, exports and structured errors (kind, message, location, stack, hints). Distributions are summarized (mean, stdev, percentiles); `--samples N` includes raw samples. +- `squiggle run` accepts `--seed`, `--sample-count` (`SAMPLE_COUNT` still works) and `--watch`. +- New `squiggle check` command: type-checks a file and its imports without running it, with actionable hints for common mistakes and lint warnings. `--run` also evaluates it. +- New `squiggle docs` command: offline documentation for builtin functions (`squiggle docs Dist.normal`, `squiggle docs List`, `squiggle docs --list`). +- New exports: `sqValueToJson`, `sqDistributionSummary`, `getErrorHints`, `getCodeWarnings`, `suggestBuiltinNames` and `getAllFunctionDocumentation`. diff --git a/packages/squiggle-lang/README.md b/packages/squiggle-lang/README.md index cf042a94f0..97097ce216 100644 --- a/packages/squiggle-lang/README.md +++ b/packages/squiggle-lang/README.md @@ -34,6 +34,26 @@ distribution settings. The return type of `run` is a bit complicated. We highly recommend using typescript when using this library to help navigate the return type. +# Command-line interface + +The package ships a `squiggle` binary (`npx @quri/squiggle-lang `, or `squiggle` after a global install). + +```sh +squiggle run model.squiggle # evaluate a file and print the result +squiggle run -e "2 to 10" # evaluate a code string +squiggle run model.squiggle --json # one JSON object: {ok, result, bindings, exports, errors, timeMs} +squiggle run model.squiggle --seed 42 -n 10000 # fixed seed, 10000 samples (SAMPLE_COUNT env also works) +squiggle run model.squiggle --watch # re-run when the file or its relative imports change +squiggle check model.squiggle [--json] # type-check file + imports without running; errors with hints, lint warnings +squiggle check model.squiggle --run # ...and also evaluate to catch runtime errors +squiggle docs --list # builtin namespaces +squiggle docs Dist # functions in a namespace +squiggle docs Dist.normal [--json] # signatures and examples for a function +squiggle docs lognorm # fuzzy search +``` + +All commands exit with code 1 on errors, including in `--json` mode. In JSON output, distributions are summarized as `{type: "Dist", subtype, mean, stdev, p5, p25, p50, p75, p95, min, max, sampleCount}` (add `--samples N` to include the first N raw samples); other non-primitive values are objects with a `type` field. The same conversion is available programmatically as `sqValueToJson(value)`. + # Build for development We assume that you installed turborepo and ran `pnpm i`. diff --git a/packages/squiggle-lang/__tests__/SqValue/toJson_test.ts b/packages/squiggle-lang/__tests__/SqValue/toJson_test.ts new file mode 100644 index 0000000000..eec22580b3 --- /dev/null +++ b/packages/squiggle-lang/__tests__/SqValue/toJson_test.ts @@ -0,0 +1,38 @@ +import { sqValueToJson } from "../../src/public/SqValue/toJson.js"; +import { run } from "../../src/run.js"; + +async function evalToJson(code: string, samples?: number) { + const output = await run(code); + if (!output.result.ok) { + throw new Error(output.result.value.toString()); + } + return sqValueToJson(output.result.value.result, { samples }); +} + +describe("sqValueToJson", () => { + test("primitives", async () => { + expect(await evalToJson("1")).toBe(1); + expect(await evalToJson('"a"')).toBe("a"); + expect(await evalToJson("true")).toBe(true); + expect(await evalToJson("[1, {a: 2}]")).toEqual([1, { a: 2 }]); + }); + + test("dist summary", async () => { + const json = await evalToJson("Sym.uniform(0, 10)"); + expect(json).toMatchObject({ + type: "Dist", + subtype: "Symbolic", + mean: 5, + p50: 5, + min: 0, + max: 10, + }); + }); + + test("samples are opt-in", async () => { + const json = (await evalToJson("normal(0, 1)", 3)) as { + samples: number[]; + }; + expect(json.samples).toHaveLength(3); + }); +}); diff --git a/packages/squiggle-lang/__tests__/cli/check_test.ts b/packages/squiggle-lang/__tests__/cli/check_test.ts new file mode 100644 index 0000000000..9e81f5deb7 --- /dev/null +++ b/packages/squiggle-lang/__tests__/cli/check_test.ts @@ -0,0 +1,89 @@ +import * as path from "path"; +import * as url from "url"; + +import { runCLI, stripAnsi } from "../helpers/cliHelpers.js"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); +const fixturesDir = path.join(__dirname, "..", "fixtures"); + +describe("check command", () => { + test("valid code", async () => { + const result = await runCLI(["check", "-e", "x = 1\nx + 1"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(stripAnsi(result.stdout)).toMatch(/OK: no errors/); + }); + + test("does not run code by default", async () => { + const result = await runCLI(["check", "-e", "List.first([])"]); + expect(result.exitCode).toBe(0); + }); + + test("--run reports runtime errors", async () => { + const result = await runCLI([ + "check", + "--json", + "--run", + "-e", + "List.first([])", + ]); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.ran).toBe(true); + expect(json.errors[0].kind).toBe("runtime"); + }); + + test("type error", async () => { + const result = await runCLI(["check", "--json", "-e", 'x = 1 + "a"']); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.ok).toBe(false); + expect(json.errors[0]).toMatchObject({ + kind: "compile", + location: { start: { line: 1 } }, + }); + expect(json.errors[0].message).toMatch(/Operator '\+'/); + }); + + test("text output with hints", async () => { + const result = await runCLI(["check", "-e", "x = 5\nx += 3"]); + expect(result.exitCode).toBe(1); + const stderr = stripAnsi(result.stderr); + expect(stderr).toMatch(/Compile Error:/); + expect(stderr).toMatch(/Hint: Variables are immutable/); + }); + + test("checks imports and reports missing ones", async () => { + const filename = path.join(fixturesDir, "check-errors", "main.squiggle"); + const result = await runCLI(["check", "--json", filename]); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.files).toHaveLength(2); + const messages = json.errors.map((e: { message: string }) => e.message); + expect(messages).toContain("List.sum is not defined"); + expect(messages.some((m: string) => /missing\.squiggle/.test(m))).toBe( + true + ); + const libError = json.errors.find( + (e: { message: string }) => e.message === "List.sum is not defined" + ); + expect(libError.location.file).toMatch(/lib\.squiggle$/); + }); + + test("warnings", async () => { + const result = await runCLI(["check", "--json", "-e", "X = 1\nx = 2"]); + const json = JSON.parse(result.stdout); + expect(json.warnings.length).toBeGreaterThan(0); + }); + + test("--no-warnings", async () => { + const result = await runCLI([ + "check", + "--json", + "--no-warnings", + "-e", + "y = null", + ]); + expect(JSON.parse(result.stdout).warnings).toEqual([]); + }); +}); diff --git a/packages/squiggle-lang/__tests__/cli/docs_test.ts b/packages/squiggle-lang/__tests__/cli/docs_test.ts new file mode 100644 index 0000000000..4602f29696 --- /dev/null +++ b/packages/squiggle-lang/__tests__/cli/docs_test.ts @@ -0,0 +1,73 @@ +import { runCLI, stripAnsi } from "../helpers/cliHelpers.js"; + +async function docsJson(args: string[]) { + const result = await runCLI(["docs", "--json", ...args]); + return { ...result, json: JSON.parse(result.stdout) }; +} + +describe("docs command", () => { + test("lists namespaces by default", async () => { + const { json, exitCode } = await docsJson([]); + expect(exitCode).toBe(0); + expect(json.type).toBe("namespaces"); + const names = json.namespaces.map((n: { name: string }) => n.name); + expect(names).toContain("Dist"); + expect(names).toContain("List"); + }); + + test("--list", async () => { + const result = await runCLI(["docs", "--list"]); + expect(stripAnsi(result.stdout)).toMatch(/Namespaces:/); + }); + + test("full function name", async () => { + const { json } = await docsJson(["Dist.normal"]); + expect(json.type).toBe("functions"); + expect(json.functions).toHaveLength(1); + const fn = json.functions[0]; + expect(fn).toMatchObject({ + fullName: "Dist.normal", + namespace: "Dist", + aliases: ["normal"], + }); + expect(fn.signatures[0]).toMatch(/^Dist\.normal\(/); + expect(fn.examples.length).toBeGreaterThan(0); + }); + + test("text output for a function", async () => { + const result = await runCLI(["docs", "List.map"]); + const stdout = stripAnsi(result.stdout); + expect(stdout).toMatch(/^List\.map/); + expect(stdout).toMatch(/Signatures:/); + expect(stdout).toMatch(/Examples:/); + }); + + test("namespace", async () => { + const { json } = await docsJson(["List"]); + expect(json.type).toBe("namespace"); + expect(json.namespace).toBe("List"); + const names = json.functions.map((f: { fullName: string }) => f.fullName); + expect(names).toContain("List.map"); + }); + + test("short name finds all namespaces", async () => { + const { json } = await docsJson(["normal"]); + const names = json.functions.map((f: { fullName: string }) => f.fullName); + expect(names).toContain("Dist.normal"); + expect(names).toContain("Sym.normal"); + }); + + test("fuzzy search", async () => { + const { json } = await docsJson(["lognorm"]); + expect(json.type).toBe("search"); + const names = json.matches.map((f: { fullName: string }) => f.fullName); + expect(names).toContain("Dist.lognormal"); + }); + + test("not found with suggestions", async () => { + const result = await runCLI(["docs", "Lst.mapp"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/No documentation found/); + expect(result.stderr).toMatch(/List\.map/); + }); +}); diff --git a/packages/squiggle-lang/__tests__/cli/errors_test.ts b/packages/squiggle-lang/__tests__/cli/errors_test.ts new file mode 100644 index 0000000000..66753f25e6 --- /dev/null +++ b/packages/squiggle-lang/__tests__/cli/errors_test.ts @@ -0,0 +1,186 @@ +import { runCLI, stripAnsi } from "../helpers/cliHelpers.js"; + +describe("run command error handling", () => { + test("parse error exits with code 1", async () => { + const result = await runCLI(["run", "--eval", "2+"]); + expect(result.exitCode).toBe(1); + }); + + test("parse error outputs to stderr", async () => { + const result = await runCLI(["run", "--eval", "2+"]); + expect(result.stdout).toBe(""); + expect(result.stderr).not.toBe(""); + }); + + test("parse error shows 'Compile Error' header", async () => { + const result = await runCLI(["run", "--eval", "2+"]); + expect(stripAnsi(result.stderr)).toMatch(/Compile Error:/); + }); + + test("undefined variable shows 'Compile Error' header", async () => { + const result = await runCLI(["run", "--eval", "undefinedVar"]); + expect(result.exitCode).toBe(1); + expect(stripAnsi(result.stderr)).toMatch(/Compile Error:/); + expect(stripAnsi(result.stderr)).toMatch(/not defined/); + }); + + test("runtime error exits with code 1", async () => { + const result = await runCLI(["run", "--eval", "List.first([])"]); + expect(result.exitCode).toBe(1); + }); + + test("runtime error outputs to stderr", async () => { + const result = await runCLI(["run", "--eval", "List.first([])"]); + expect(result.stdout).toBe(""); + expect(result.stderr).not.toBe(""); + }); + + test("runtime error shows 'Runtime Error' header", async () => { + const result = await runCLI(["run", "--eval", "List.first([])"]); + expect(stripAnsi(result.stderr)).toMatch(/Runtime Error:/); + }); + + test("runtime error includes stack trace", async () => { + const result = await runCLI(["run", "--eval", "List.first([])"]); + const stderr = stripAnsi(result.stderr); + expect(stderr).toMatch(/Stack trace:/); + expect(stderr).toMatch(/List\.first/); + }); + + test("runtime error in function includes stack trace with location", async () => { + const result = await runCLI([ + "run", + "--eval", + "f(x) = List.first(x); f([])", + ]); + const stderr = stripAnsi(result.stderr); + expect(result.exitCode).toBe(1); + expect(stderr).toMatch(/Stack trace:/); + expect(stderr).toMatch(/line 1/); + }); + + test("valid code exits with code 0 and empty stderr", async () => { + const result = await runCLI(["run", "--eval", "2+2"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe("4\n"); + }); + + test("valid void expression exits with code 0", async () => { + const result = await runCLI(["run", "--eval", "x = 5"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + }); + + test("error with --quiet still shows error on stderr", async () => { + const result = await runCLI(["run", "--eval", "List.first([])", "--quiet"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).not.toBe(""); + expect(stripAnsi(result.stderr)).toMatch(/Runtime Error:/); + }); + + test("error with --time still shows error and time", async () => { + const result = await runCLI(["run", "--eval", "2+", "--time"]); + expect(result.exitCode).toBe(1); + expect(stripAnsi(result.stderr)).toMatch(/Compile Error:/); + }); + + test("nonexistent file produces error", async () => { + const result = await runCLI(["run", "nonexistent_file_12345.squiggle"]); + expect(result.exitCode).not.toBe(0); + }); + + test("invalid import path produces error", async () => { + const result = await runCLI([ + "run", + "--eval", + 'import "absolute/path" as x', + ]); + expect(result.exitCode).toBe(1); + }); +}); + +describe("parse command error handling", () => { + test("parse error exits with code 1", async () => { + const result = await runCLI(["parse", "--eval", "2+"]); + expect(result.exitCode).toBe(1); + }); + + test("parse error outputs to stderr", async () => { + const result = await runCLI(["parse", "--eval", "2+"]); + expect(result.stdout).toBe(""); + expect(result.stderr).not.toBe(""); + }); + + test("valid code exits with code 0 and empty stderr", async () => { + const result = await runCLI(["parse", "--eval", "2+2"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).not.toBe(""); + }); + + test("typed mode error exits with code 1", async () => { + const result = await runCLI(["parse", "--eval", "2+", "--mode", "typed"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).not.toBe(""); + }); + + test("raw mode error exits with code 1", async () => { + const result = await runCLI(["parse", "--eval", "2+", "--mode", "raw"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).not.toBe(""); + }); +}); + +describe("print-ir command error handling", () => { + test("parse error exits with code 1", async () => { + const result = await runCLI(["print-ir", "--eval", "2+"]); + expect(result.exitCode).toBe(1); + }); + + test("parse error outputs to stderr", async () => { + const result = await runCLI(["print-ir", "--eval", "2+"]); + expect(result.stdout).toBe(""); + expect(result.stderr).not.toBe(""); + }); + + test("valid code exits with code 0", async () => { + const result = await runCLI(["print-ir", "--eval", "2+2"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + }); +}); + +describe("general CLI error handling", () => { + test("no arguments shows usage and exits with code 1", async () => { + const result = await runCLI([]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/Usage:/); + }); + + test("unknown command exits with non-zero code", async () => { + const result = await runCLI(["nonexistent-command"]); + expect(result.exitCode).not.toBe(0); + }); + + test("run with both filename and --eval produces error", async () => { + const result = await runCLI(["run", "file.squiggle", "--eval", "2+2"]); + expect(result.exitCode).not.toBe(0); + }); + + test("run without filename or --eval produces error", async () => { + const result = await runCLI(["run"]); + expect(result.exitCode).not.toBe(0); + }); + + test("--quiet and --show-bindings conflict produces error", async () => { + const result = await runCLI([ + "run", + "--eval", + "2+2", + "--quiet", + "--show-bindings", + ]); + expect(result.exitCode).not.toBe(0); + }); +}); diff --git a/packages/squiggle-lang/__tests__/cli/help_test.ts b/packages/squiggle-lang/__tests__/cli/help_test.ts new file mode 100644 index 0000000000..44b7ec076e --- /dev/null +++ b/packages/squiggle-lang/__tests__/cli/help_test.ts @@ -0,0 +1,16 @@ +import { runCLI } from "../helpers/cliHelpers.js"; + +test("top-level help lists commands and examples", async () => { + const result = await runCLI(["--help"]); + expect(result.exitCode).toBe(0); + for (const command of ["run", "check", "docs", "playground"]) { + expect(result.stdout).toMatch(new RegExp(`^ ${command} `, "m")); + } + expect(result.stdout).toMatch(/Examples:/); +}); + +test("playground --print-url", async () => { + const result = await runCLI(["playground", "--print-url"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("https://www.squiggle-language.com/playground\n"); +}); diff --git a/packages/squiggle-lang/__tests__/cli/run_json_test.ts b/packages/squiggle-lang/__tests__/cli/run_json_test.ts new file mode 100644 index 0000000000..a8ee0240b4 --- /dev/null +++ b/packages/squiggle-lang/__tests__/cli/run_json_test.ts @@ -0,0 +1,164 @@ +import { runCLI } from "../helpers/cliHelpers.js"; + +async function runJson(args: string[]) { + const result = await runCLI(["run", "--json", ...args]); + return { ...result, json: JSON.parse(result.stdout) }; +} + +describe("run --json", () => { + test("number result", async () => { + const { json, exitCode, stderr } = await runJson(["-e", "x = 2; x + 2"]); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(json).toMatchObject({ + ok: true, + result: 4, + bindings: { x: 2 }, + errors: [], + }); + expect(typeof json.timeMs).toBe("number"); + expect(json.exports).toBeUndefined(); + }); + + test("void result has bindings", async () => { + const { json } = await runJson(["-e", "x = 5"]); + expect(json.result).toBeNull(); + expect(json.bindings).toEqual({ x: 5 }); + }); + + test("distribution summary", async () => { + const { json } = await runJson(["-e", "normal(10, 2)"]); + expect(json.result).toMatchObject({ type: "Dist", subtype: "SampleSet" }); + for (const key of ["mean", "stdev", "p5", "p25", "p50", "p75", "p95"]) { + expect(typeof json.result[key]).toBe("number"); + } + expect(json.result.mean).toBeGreaterThan(9); + expect(json.result.mean).toBeLessThan(11); + expect(json.result.samples).toBeUndefined(); + expect(json.result.sampleCount).toBe(1000); + }); + + test("symbolic distribution", async () => { + const { json } = await runJson(["-e", "Sym.normal(10, 2)"]); + expect(json.result).toMatchObject({ + type: "Dist", + subtype: "Symbolic", + mean: 10, + stdev: 2, + description: "Normal(10,2)", + }); + }); + + test("--samples includes truncated samples", async () => { + const { json } = await runJson(["-e", "normal(10, 2)", "--samples", "5"]); + expect(json.result.samples).toHaveLength(5); + }); + + test("--sample-count", async () => { + const { json } = await runJson(["-e", "normal(10, 2)", "-n", "50"]); + expect(json.result.sampleCount).toBe(50); + }); + + test("--seed makes results reproducible and changes them", async () => { + const a = await runJson(["-e", "normal(0, 1)", "--seed", "a"]); + const a2 = await runJson(["-e", "normal(0, 1)", "--seed", "a"]); + const b = await runJson(["-e", "normal(0, 1)", "--seed", "b"]); + expect(a.json.result.mean).toBe(a2.json.result.mean); + expect(a.json.result.mean).not.toBe(b.json.result.mean); + }); + + test("various value types", async () => { + const { json } = await runJson([ + "-e", + `@name("Population") @doc("people") pop = 5 to 10 +f(x) = x * 2 +d = Date(2024) +dur = 3days +s = "hi" +l = [1, true, {a: 1}] +inf = 1/0 +p = Plot.dist(normal(1, 2)) +c = Calculator(f)`, + ]); + const b = json.bindings; + expect(b.pop).toMatchObject({ + type: "Dist", + name: "Population", + doc: "people", + }); + expect(b.f).toMatchObject({ type: "Lambda", signature: "(x)" }); + expect(b.f.parameters).toEqual([[{ name: "x" }]]); + expect(b.d).toMatchObject({ type: "Date" }); + expect(typeof b.d.iso).toBe("string"); + expect(b.dur).toMatchObject({ type: "Duration", ms: 3 * 24 * 3600 * 1000 }); + expect(b.s).toBe("hi"); + expect(b.l).toEqual([1, true, { a: 1 }]); + expect(b.inf).toBe("Infinity"); + expect(b.p).toMatchObject({ type: "Plot", plotType: "distributions" }); + expect(b.p.distributions[0]).toMatchObject({ type: "Dist" }); + expect(b.c).toMatchObject({ + type: "Calculator", + inputs: [{ name: "x" }], + }); + }); + + test("exports", async () => { + const { json } = await runJson(["-e", "export x = 1\ny = 2"]); + expect(json.exports).toEqual({ x: 1 }); + }); + + test("compile error", async () => { + const { json, exitCode } = await runJson(["-e", "x = List.sum([1])"]); + expect(exitCode).toBe(1); + expect(json.ok).toBe(false); + expect(json.result).toBeNull(); + expect(json.errors).toHaveLength(1); + expect(json.errors[0]).toMatchObject({ + kind: "compile", + message: "List.sum is not defined", + location: { + file: "[eval]", + start: { line: 1, column: 5 }, + }, + }); + expect(json.errors[0].hints.join(" ")).toMatch(/Number\.sum/); + }); + + test("runtime error with stack", async () => { + const { json, exitCode } = await runJson([ + "-e", + "f(x) = List.first(x)\nf([])", + ]); + expect(exitCode).toBe(1); + expect(json.errors[0].kind).toBe("runtime"); + expect(json.errors[0].message).toMatch(/List must not be empty/); + expect(json.errors[0].stack.map((f: { name: string }) => f.name)).toEqual([ + "List.first", + "f", + "", + ]); + }); +}); + +describe("run options", () => { + test("SAMPLE_COUNT env var still works", async () => { + process.env["SAMPLE_COUNT"] = "20"; + try { + const result = await runCLI(["run", "--json", "-e", "normal(0, 1)"]); + expect(JSON.parse(result.stdout).result.sampleCount).toBe(20); + } finally { + delete process.env["SAMPLE_COUNT"]; + } + }); + + test("--watch requires a filename", async () => { + const result = await runCLI(["run", "-e", "1", "--watch"]); + expect(result.exitCode).not.toBe(0); + }); + + test("text errors include hints", async () => { + const result = await runCLI(["run", "-e", "# comment\nx = 1"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/Hint: .*Comments use \/\//); + }); +}); diff --git a/packages/squiggle-lang/__tests__/fixtures/check-errors/lib.squiggle b/packages/squiggle-lang/__tests__/fixtures/check-errors/lib.squiggle new file mode 100644 index 0000000000..c17f968fed --- /dev/null +++ b/packages/squiggle-lang/__tests__/fixtures/check-errors/lib.squiggle @@ -0,0 +1 @@ +export x = List.sum([1, 2]) diff --git a/packages/squiggle-lang/__tests__/fixtures/check-errors/main.squiggle b/packages/squiggle-lang/__tests__/fixtures/check-errors/main.squiggle new file mode 100644 index 0000000000..61fb7b40b0 --- /dev/null +++ b/packages/squiggle-lang/__tests__/fixtures/check-errors/main.squiggle @@ -0,0 +1,3 @@ +import "./lib.squiggle" as lib +import "./missing.squiggle" as missing +lib.x + 1 diff --git a/packages/squiggle-lang/__tests__/helpers/cliHelpers.ts b/packages/squiggle-lang/__tests__/helpers/cliHelpers.ts index 13238b782d..3e5e85291e 100644 --- a/packages/squiggle-lang/__tests__/helpers/cliHelpers.ts +++ b/packages/squiggle-lang/__tests__/helpers/cliHelpers.ts @@ -26,6 +26,10 @@ export async function runCLI(args: string[]) { }, }); + // Capture process.exitCode set by our error handlers + const originalExitCode = process.exitCode; + process.exitCode = undefined; + let exitCode = 0; try { await program.parseAsync(args, { from: "user" }); @@ -37,6 +41,13 @@ export async function runCLI(args: string[]) { } } + // Use process.exitCode if it was set (by our error handlers), + // otherwise use the Commander exit code + if (process.exitCode !== undefined) { + exitCode = process.exitCode; + } + process.exitCode = originalExitCode; + jest.restoreAllMocks(); return { stdout, stderr, exitCode }; } diff --git a/packages/squiggle-lang/__tests__/public/hints_test.ts b/packages/squiggle-lang/__tests__/public/hints_test.ts new file mode 100644 index 0000000000..caeef92791 --- /dev/null +++ b/packages/squiggle-lang/__tests__/public/hints_test.ts @@ -0,0 +1,96 @@ +import { + getCodeWarnings, + getErrorHints, + suggestBuiltinNames, +} from "../../src/public/hints.js"; + +describe("suggestBuiltinNames", () => { + test("same function in another namespace", () => { + expect(suggestBuiltinNames("List.sum")).toContain("Number.sum"); + }); + + test("typo in the same namespace", () => { + expect(suggestBuiltinNames("List.mapp")).toContain("List.map"); + }); +}); + +describe("getErrorHints", () => { + test("null", () => { + expect(getErrorHints({ message: "null is not defined" })[0]).toMatch( + /no 'null'/ + ); + }); + + test("type annotation", () => { + const hints = getErrorHints({ + message: "Number is not defined", + line: "f(x: Number) = x", + }); + expect(hints[0]).toMatch(/Type annotations/); + }); + + test("hash comments", () => { + const hints = getErrorHints({ + message: 'Expected "(", ... or whitespace but "#" found.', + line: "# comment", + }); + expect(hints).toEqual(["Comments use // or /* ... */, not #."]); + }); + + test("no hints for unrelated errors", () => { + expect(getErrorHints({ message: "Something odd" })).toEqual([]); + }); +}); + +describe("getCodeWarnings", () => { + test("clean code", () => { + expect(getCodeWarnings('x = 1\ny = "null => Infinity"\n// null')).toEqual( + [] + ); + }); + + test("detects common mistakes", () => { + const warnings = getCodeWarnings( + [ + "x = 1", + 'import "./a" as a', + "Y = null", + "f = (x) => x", + "<<<<<<< HEAD", + ].join("\n") + ); + const lines = warnings.map((w) => w.line); + expect(lines).toEqual(expect.arrayContaining([2, 3, 4, 5])); + expect(warnings.find((w) => w.line === 3)?.message).toBeDefined(); + }); + + test("ignores code inside strings and comments", () => { + const warnings = getCodeWarnings( + [ + 'a = "Y = null // not code"', + "b = 'it\\'s null'", + 'c = "unterminated \\" null', + "d = 1 /* null */ + /* null */ 2", + "e = null", + ].join("\n") + ); + expect(warnings.map((w) => w.line)).toEqual([5]); + }); + + test("stays fast on pathological input", () => { + const start = Date.now(); + getCodeWarnings('x = "' + '\\"'.repeat(50000)); + getCodeWarnings("x = 1 " + "/*a".repeat(50000)); + expect(Date.now() - start).toBeLessThan(1000); + }); +}); + +describe("getErrorHints: bare expressions", () => { + test("only the final statement may be a bare expression", () => { + const hints = getErrorHints({ + message: 'Expected "->", assignment, or end of input but "y" found.', + line: "y = 1", + }); + expect(hints.join("\n")).toMatch(/Only the final statement/); + }); +}); diff --git a/packages/squiggle-lang/src/cli/CliPrinter.ts b/packages/squiggle-lang/src/cli/CliPrinter.ts index 6195aa8d48..0fe65bd49d 100644 --- a/packages/squiggle-lang/src/cli/CliPrinter.ts +++ b/packages/squiggle-lang/src/cli/CliPrinter.ts @@ -1,3 +1,5 @@ +import { bold, red, yellow } from "./colors.js"; + export class CliPrinter { isFirstSection = true; @@ -9,4 +11,18 @@ export class CliPrinter { this.isFirstSection = false; lines.forEach((line) => console.log(line)); } + + // Prints an error section to stderr with a colored header. + printError(header: string, details: string, color = red) { + if (!this.isFirstSection) { + console.error(); + } + this.isFirstSection = false; + console.error(color(bold(header + ":"))); + console.error(details); + } + + printWarning(header: string, details: string) { + this.printError(header, details, yellow); + } } diff --git a/packages/squiggle-lang/src/cli/StateGraph.tsx b/packages/squiggle-lang/src/cli/StateGraph.tsx new file mode 100644 index 0000000000..5f72b734e6 --- /dev/null +++ b/packages/squiggle-lang/src/cli/StateGraph.tsx @@ -0,0 +1,79 @@ +import { Box, Text } from "ink"; +import { FC } from "react"; + +import { Env } from "../dists/env.js"; +import { ModuleData, ProjectState } from "../public/SqProject/ProjectState.js"; +import { SqModule } from "../public/SqProject/SqModule.js"; + +const ModuleInfo: FC<{ + state: ProjectState; + module: SqModule; + environment: Env; +}> = ({ state, module, environment }) => { + const output = state.getOutput(module, environment); + + return ( + + {module.name} + + {output + ? "✅" + : module.getImportModules({ state }).type === "loading" + ? "⌛ Loading imports" + : module.getImportOutputs({ state, environment }).type === "loading" + ? "🔄 Waiting for import outputs" + : "▶️ Running"} + + + ); +}; + +const ModuleDataInfo: FC<{ + state: ProjectState; + hash: string; + moduleData: ModuleData; + environment: Env; +}> = ({ state, hash, moduleData, environment }) => { + return ( + + + {moduleData.type === "loaded" + ? "✅" + : moduleData.type === "loading" + ? "⌛" + : "❌"} + + {moduleData.type === "loaded" ? ( + + ) : ( + {hash} + )} + + ); +}; + +export const StateGraph: FC<{ state: ProjectState; environment: Env }> = ({ + state, + environment, +}) => { + return ( + + + State + + {[...state.modules.entries()].map(([hash, moduleData]) => ( + + ))} + + ); +}; diff --git a/packages/squiggle-lang/src/cli/commands/check.ts b/packages/squiggle-lang/src/cli/commands/check.ts new file mode 100644 index 0000000000..9c3ca3ba8f --- /dev/null +++ b/packages/squiggle-lang/src/cli/commands/check.ts @@ -0,0 +1,195 @@ +import { Command } from "@commander-js/extra-typings"; + +import { SqError } from "../../public/SqError.js"; +import { SqProject } from "../../public/SqProject/index.js"; +import { SqModule } from "../../public/SqProject/SqModule.js"; +import { CliPrinter } from "../CliPrinter.js"; +import { green } from "../colors.js"; +import { + ErrorJson, + errorToJson, + printErrorJson, + printWarnings, + WarningJson, + warningsForSources, +} from "../errorReport.js"; +import { EVAL_SOURCE_ID, FileLinker, makeFileLinker } from "../linker.js"; +import { loadSrc, myParseInt } from "../utils.js"; +import { makeEnvironment } from "./run.js"; + +export type CheckJsonOutput = { + ok: boolean; + files: string[]; + errors: ErrorJson[]; + warnings: WarningJson[]; + ran: boolean; +}; + +type ErrorReport = { json: ErrorJson; details?: string }; + +function reportFromSqError( + error: SqError, + sources: ReadonlyMap +): ErrorReport { + return { + json: errorToJson(error, sources), + details: error.toStringWithDetails(), + }; +} + +// Parses and type-checks a module and all of its imports, without evaluating anything. +async function staticCheck( + root: SqModule, + linker: FileLinker +): Promise { + const reports: ErrorReport[] = []; + const visited = new Set(); + + const visit = async (module: SqModule) => { + if (visited.has(module.name)) return; + visited.add(module.name); + + const typedAst = module.typedAst(); + if (!typedAst.ok) { + for (const error of typedAst.value) { + reports.push(reportFromSqError(error, linker.sources)); + } + } + + const ast = module.ast(); + if (!ast.ok) return; + for (const importNode of ast.value.imports) { + const location = importNode.path.location; + const fail = (e: unknown) => { + const message = e instanceof Error ? e.message : String(e); + reports.push({ + json: { + kind: "compile", + message, + location: { + file: location.source, + start: { + line: location.start.line, + column: location.start.column, + }, + end: { line: location.end.line, column: location.end.column }, + }, + }, + }); + }; + let name: string; + try { + name = linker.resolve(importNode.path.value, module.name); + } catch (e) { + fail(e); + continue; + } + if (visited.has(name)) continue; + try { + await visit(await linker.loadModule(name)); + } catch (e) { + fail(e); + } + } + }; + + await visit(root); + return reports; +} + +export function addCheckCommand(program: Command) { + program + .command("check") + .description( + "Type-check code and its imports without running; report errors, hints, warnings" + ) + .arguments("[filename]") + .option("-e, --eval ", "check a code string instead of a file") + .option( + "--json", + "print a JSON object {ok, files, errors, warnings, ran} to stdout" + ) + .option( + "--run", + "also evaluate the code to catch runtime errors (only if type-checking passed)" + ) + .option("--seed ", "random seed, with --run") + .option( + "-n, --sample-count ", + "number of samples, with --run", + myParseInt + ) + .option("--no-warnings", "don't report lint warnings") + .addHelpText( + "after", + ` +Examples: + $ squiggle check model.squiggle + $ squiggle check model.squiggle --json + $ squiggle check model.squiggle --run + +Exit code is 1 if there are errors. Warnings are heuristic and don't affect the exit code.` + ) + .action(async (filename, options) => { + const src = loadSrc({ program, filename, inline: options.eval }); + const linker = makeFileLinker(); + const rootName = filename ?? EVAL_SOURCE_ID; + linker.sources.set(rootName, src); + const root = new SqModule({ name: rootName, code: src }); + + const reports = await staticCheck(root, linker); + + let ran = false; + if (options.run && reports.length === 0) { + ran = true; + const project = new SqProject({ + linker, + environment: makeEnvironment({ + seed: options.seed, + sampleCount: options.sampleCount, + }), + }); + project.setHead("root", { module: root }); + const output = await project.waitForOutput("root"); + if (!output.result.ok) { + for (const error of output.result.value.errors) { + reports.push(reportFromSqError(error, linker.sources)); + } + } + } + + const warnings = options.warnings + ? warningsForSources(linker.sources) + : []; + const ok = reports.length === 0; + if (!ok) { + process.exitCode = 1; + } + + const files = [...linker.sources.keys()]; + if (options.json) { + const output: CheckJsonOutput = { + ok, + files, + errors: reports.map((r) => r.json), + warnings, + ran, + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + + const printer = new CliPrinter(); + for (const report of reports) { + printErrorJson(printer, report.json, report.details); + } + printWarnings(printer, warnings); + if (ok) { + printer.printSection( + green( + `OK: no errors in ${files.length} file(s)${ran ? " (type-checked and ran)" : " (type-checked, not run)"}` + ) + ); + } + }); +} diff --git a/packages/squiggle-lang/src/cli/commands/docs.ts b/packages/squiggle-lang/src/cli/commands/docs.ts new file mode 100644 index 0000000000..09879e35e6 --- /dev/null +++ b/packages/squiggle-lang/src/cli/commands/docs.ts @@ -0,0 +1,248 @@ +import { Command } from "@commander-js/extra-typings"; + +import { getRegistry } from "../../library/registry/index.js"; +import { FnDocumentation } from "../../library/registry/core.js"; +import { suggestBuiltinNames } from "../../public/hints.js"; +import { bold, green, yellow } from "../colors.js"; + +export type FnDocJson = { + fullName: string; + name: string; + namespace: string; + aliases: string[]; + section?: string; + description?: string; + signatures: string[]; + examples: string[]; + shorthand?: string; +}; + +function fullName(fn: FnDocumentation) { + return fn.nameSpace ? `${fn.nameSpace}.${fn.name}` : fn.name; +} + +function fnToJson(fn: FnDocumentation): FnDocJson { + const name = fullName(fn); + const aliases = [ + ...(fn.requiresNamespace || !fn.nameSpace ? [] : [fn.name]), + ...(fn.name === "make" ? [fn.nameSpace] : []), + ]; + return { + fullName: name, + name: fn.name, + namespace: fn.nameSpace, + aliases, + ...(fn.displaySection ? { section: fn.displaySection } : {}), + ...(fn.description ? { description: fn.description.trim() } : {}), + signatures: fn.signatures.map((s) => `${name}${s}`), + examples: (fn.examples ?? []).map((e) => e.text), + ...(fn.shorthand + ? { + shorthand: + fn.shorthand.type === "infix" + ? `a ${fn.shorthand.symbol} b` + : `${fn.shorthand.symbol}a`, + } + : {}), + }; +} + +// Documented functions, excluding internal unit helpers like `fromUnit_k`. +function allDocs(): FnDocJson[] { + return getRegistry() + .allFunctionDocumentation() + .filter((fn) => !fn.isUnit && fn.nameSpace !== "") + .map(fnToJson); +} + +function namespaces(docs: FnDocJson[]) { + const counts = new Map(); + for (const doc of docs) { + counts.set(doc.namespace, (counts.get(doc.namespace) ?? 0) + 1); + } + return [...counts.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +type DocsResult = + | { type: "namespaces"; namespaces: { name: string; count: number }[] } + | { type: "namespace"; namespace: string; functions: FnDocJson[] } + | { type: "functions"; query: string; functions: FnDocJson[] } + | { type: "search"; query: string; matches: FnDocJson[] } + | { type: "notFound"; query: string; suggestions: string[] }; + +function lookup(query: string | undefined, docs: FnDocJson[]): DocsResult { + if (!query) { + return { type: "namespaces", namespaces: namespaces(docs) }; + } + const q = query.toLowerCase(); + + // Namespace, e.g. "Dist". + const namespace = namespaces(docs).find((n) => n.name.toLowerCase() === q); + if (namespace) { + return { + type: "namespace", + namespace: namespace.name, + functions: docs.filter((d) => d.namespace === namespace.name), + }; + } + + // Exact function name, e.g. "Dist.normal" or "normal" (which also matches "Sym.normal"). + const exact = docs.filter( + (d) => + d.fullName.toLowerCase() === q || + d.name.toLowerCase() === q || + d.aliases.some((a) => a.toLowerCase() === q) + ); + if (exact.length) { + return { type: "functions", query, functions: exact }; + } + + // Fuzzy: names containing the query, then descriptions containing it. + const score = (d: FnDocJson): number | undefined => { + const name = d.name.toLowerCase(); + if (name === q) return 0; + if (name.startsWith(q)) return 1; + if (d.fullName.toLowerCase().includes(q)) return 2; + if (d.description?.toLowerCase().includes(q)) return 3; + return undefined; + }; + const matches = docs + .map((d) => ({ d, s: score(d) })) + .filter((x): x is { d: FnDocJson; s: number } => x.s !== undefined) + .sort((a, b) => a.s - b.s || a.d.fullName.localeCompare(b.d.fullName)) + .map((x) => x.d); + + if (matches.length === 1) { + return { type: "functions", query, functions: matches }; + } + if (matches.length) { + return { type: "search", query, matches }; + } + const lastSegment = query.split(".").pop() ?? query; + const suggestions = [ + ...new Set([ + ...suggestBuiltinNames(query, 5), + ...suggestBuiltinNames(lastSegment, 5), + ]), + ].slice(0, 5); + return { type: "notFound", query, suggestions }; +} + +function formatFn(doc: FnDocJson): string { + const lines = [bold(doc.fullName)]; + if (doc.aliases.length) { + lines.push(`Also available as: ${doc.aliases.join(", ")}`); + } + if (doc.shorthand) { + lines.push(`Shorthand: ${doc.shorthand}`); + } + if (doc.section) { + lines.push(`Section: ${doc.section}`); + } + if (doc.description) { + lines.push("", doc.description); + } + if (doc.signatures.length) { + lines.push("", bold("Signatures:"), ...doc.signatures.map((s) => " " + s)); + } + if (doc.examples.length) { + lines.push( + "", + bold("Examples:"), + ...doc.examples.map((e) => e.replace(/^/gm, " ")) + ); + } + return lines.join("\n"); +} + +// First sentence of the description, truncated, for one-line listings. +function shortDescription(text: string | undefined): string | undefined { + const line = text?.split("\n").find((l) => l.trim() !== ""); + if (!line) return undefined; + const sentence = line.match(/^.*?[.!?](\s|$)/)?.[0].trim() ?? line; + return sentence.length > 100 ? sentence.slice(0, 97) + "..." : sentence; +} + +function formatSummaryLine(doc: FnDocJson): string { + const summary = shortDescription(doc.description) ?? doc.signatures[0] ?? ""; + return ` ${green(doc.fullName)} ${summary}`; +} + +function printText(result: DocsResult) { + switch (result.type) { + case "namespaces": + console.log(bold("Namespaces:")); + for (const { name, count } of result.namespaces) { + console.log(` ${green(name)} (${count})`); + } + console.log( + "\nRun `squiggle docs ` to list functions, or `squiggle docs ` for details." + ); + break; + case "namespace": { + console.log(bold(`${result.namespace} (${result.functions.length})`)); + const bySection = new Map(); + for (const fn of result.functions) { + const section = fn.section ?? ""; + bySection.set(section, [...(bySection.get(section) ?? []), fn]); + } + for (const [section, fns] of bySection) { + if (section) console.log(`\n ${yellow(section)}`); + fns.forEach((fn) => console.log(formatSummaryLine(fn))); + } + break; + } + case "functions": + console.log(result.functions.map(formatFn).join("\n\n---\n\n")); + break; + case "search": + console.log( + bold(`${result.matches.length} matches for "${result.query}":`) + ); + for (const fn of result.matches) { + console.log(formatSummaryLine(fn)); + } + break; + case "notFound": + console.error(`No documentation found for "${result.query}".`); + if (result.suggestions.length) { + console.error(`Did you mean: ${result.suggestions.join(", ")}?`); + } + console.error("Run `squiggle docs --list` to see all namespaces."); + break; + default: + throw new Error(`Unknown result ${result satisfies never}`); + } +} + +export function addDocsCommand(program: Command) { + program + .command("docs") + .description("Show offline documentation for builtin functions") + .arguments("[query]") + .option("--json", "print JSON instead of text") + .option("-l, --list", "list all namespaces") + .addHelpText( + "after", + ` +Examples: + $ squiggle docs --list # all namespaces + $ squiggle docs Dist # functions in a namespace + $ squiggle docs Dist.normal # one function: signatures, examples + $ squiggle docs lognormal # fuzzy search by name or description + $ squiggle docs List.map --json` + ) + .action((query, options) => { + const result = lookup(options.list ? undefined : query, allDocs()); + if (result.type === "notFound") { + process.exitCode = 1; + } + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + printText(result); + } + }); +} diff --git a/packages/squiggle-lang/src/cli/commands/parse.ts b/packages/squiggle-lang/src/cli/commands/parse.ts index b965e86304..c4dd038121 100644 --- a/packages/squiggle-lang/src/cli/commands/parse.ts +++ b/packages/squiggle-lang/src/cli/commands/parse.ts @@ -9,6 +9,7 @@ import { coloredJson, loadSrc } from "../utils.js"; export function addParseCommand(program: Command) { program .command("parse") + .description("Parse code and print the AST (debugging)") .arguments("[filename]") .option( "-e, --eval ", @@ -33,7 +34,8 @@ export function addParseCommand(program: Command) { if (ast.ok) { console.log(astNodeToString(ast.value, { colored: true })); } else { - console.log(red(ast.value.toString())); + process.exitCode = 1; + console.error(red(ast.value.toString())); } break; } @@ -42,7 +44,8 @@ export function addParseCommand(program: Command) { if (ast.ok) { console.log(coloredJson(ast.value)); } else { - console.log(red(ast.value.toString())); + process.exitCode = 1; + console.error(red(ast.value.toString())); } break; } @@ -56,7 +59,8 @@ export function addParseCommand(program: Command) { }) ); } else { - console.log(red(typedAst.value.toString())); + process.exitCode = 1; + console.error(red(typedAst.value.toString())); } break; } diff --git a/packages/squiggle-lang/src/cli/commands/playground.ts b/packages/squiggle-lang/src/cli/commands/playground.ts index 95b7c555be..41d701a502 100644 --- a/packages/squiggle-lang/src/cli/commands/playground.ts +++ b/packages/squiggle-lang/src/cli/commands/playground.ts @@ -1,7 +1,19 @@ import { Command } from "@commander-js/extra-typings"; +import open from "open"; + +export const PLAYGROUND_URL = "https://www.squiggle-language.com/playground"; export function addPlaygroundCommand(program: Command) { - program.command("playground").action(() => { - open("https://www.squiggle-language.com/playground"); - }); + program + .command("playground") + .description("Open the online Squiggle playground in a browser") + .option("--print-url", "print the playground URL instead of opening it") + .action(async (options) => { + if (options.printUrl) { + console.log(PLAYGROUND_URL); + return; + } + await open(PLAYGROUND_URL); + console.log(`Opened ${PLAYGROUND_URL}`); + }); } diff --git a/packages/squiggle-lang/src/cli/commands/print-ir.ts b/packages/squiggle-lang/src/cli/commands/print-ir.ts index a6cba3f559..6956565207 100644 --- a/packages/squiggle-lang/src/cli/commands/print-ir.ts +++ b/packages/squiggle-lang/src/cli/commands/print-ir.ts @@ -9,6 +9,9 @@ import { loadSrc } from "../utils.js"; export function addPrintIrCommand(program: Command) { program .command("print-ir") + .description( + "Compile code and print the intermediate representation (debugging)" + ) .arguments("[filename]") .option( "-e, --eval ", @@ -30,10 +33,12 @@ export function addPrintIrCommand(program: Command) { if (ir.ok) { console.log(irToString(ir.value, { colored: true })); } else { - console.log(red(ir.value.toString())); + process.exitCode = 1; + console.error(red(ir.value.toString())); } } else { - console.log(red(typedAstR.value.toString())); + process.exitCode = 1; + console.error(red(typedAstR.value.toString())); } }); } diff --git a/packages/squiggle-lang/src/cli/commands/run.tsx b/packages/squiggle-lang/src/cli/commands/run.tsx index 4b6a768b69..7906a970a0 100644 --- a/packages/squiggle-lang/src/cli/commands/run.tsx +++ b/packages/squiggle-lang/src/cli/commands/run.tsx @@ -1,32 +1,37 @@ import { type Command, Option } from "@commander-js/extra-typings"; -import fs from "fs/promises"; -import { Box, render, Text } from "ink"; -import isFinite from "lodash/isFinite.js"; -import path from "path"; -import { FC } from "react"; +import fs from "fs"; +import { render } from "ink"; + +import { type JsonValue } from "@quri/serializer"; import { defaultEnv, Env } from "../../dists/env.js"; -import { SqLinker } from "../../public/SqLinker.js"; import { SqProject } from "../../public/SqProject/index.js"; -import { - ModuleData, - ProjectState, -} from "../../public/SqProject/ProjectState.js"; import { SqModule } from "../../public/SqProject/SqModule.js"; +import { SqModuleOutput } from "../../public/SqProject/SqModuleOutput.js"; +import { SqDict } from "../../public/SqValue/SqDict.js"; +import { sqValueToJson } from "../../public/SqValue/toJson.js"; import { allRunnerNames, runnerByName } from "../../runners/index.js"; import { CliPrinter } from "../CliPrinter.js"; -import { bold, red } from "../colors.js"; +import { bold } from "../colors.js"; +import { ErrorJson, errorToJson, printErrors } from "../errorReport.js"; +import { EVAL_SOURCE_ID, makeFileLinker } from "../linker.js"; +import { StateGraph } from "../StateGraph.js"; import { loadSrc, myParseInt } from "../utils.js"; -type OutputMode = "NONE" | "RESULT_OR_BINDINGS" | "RESULT_AND_BINDINGS"; +type OutputMode = + | "NONE" + | "RESULT_OR_BINDINGS" + | "RESULT_AND_BINDINGS" + | "JSON"; type RunArgs = { src: string; filename?: string; output: OutputMode; measure?: boolean; - sampleCount?: string | number; + sampleCount?: number; seed?: string; + samples?: number; // number of raw samples to include per distribution in JSON output profile?: boolean; runner?: Parameters[0]; runnerThreads?: number; @@ -34,118 +39,34 @@ type RunArgs = { logProjectActions?: boolean; }; -const EVAL_SOURCE_ID = "[eval]"; +export type RunJsonOutput = { + ok: boolean; + result: JsonValue; + bindings: { [name: string]: JsonValue }; + exports?: { [name: string]: JsonValue }; + errors: ErrorJson[]; + timeMs: number; +}; -function getLinker(): SqLinker { +export function makeEnvironment({ + sampleCount, + seed, + profile, +}: { + sampleCount?: number; + seed?: string; + profile?: boolean; +}): Env { return { - resolve(name, fromId) { - if (!name.startsWith("./") && !name.startsWith("../")) { - throw new Error("Only relative paths in imports are allowed"); - } - const dir = - fromId === EVAL_SOURCE_ID ? process.cwd() : path.dirname(fromId); - return path.relative(process.cwd(), path.resolve(dir, name)); - }, - async loadModule(sourceId, hash) { - if (hash) { - throw new Error("Hashes are not supported"); - } - const code = await fs.readFile(sourceId, "utf-8"); - return new SqModule({ - name: sourceId, - code, - }); - }, + sampleCount: sampleCount ?? defaultEnv.sampleCount, + xyPointLength: sampleCount ?? defaultEnv.xyPointLength, + seed: seed || "default-seed", + profile: profile ?? false, }; } -const ModuleInfo: FC<{ - state: ProjectState; - module: SqModule; - environment: Env; -}> = ({ state, module, environment }) => { - const output = state.getOutput(module, environment); - - return ( - - {module.name} - - {output - ? "✅" - : module.getImportModules({ state }).type === "loading" - ? "⌛ Loading imports" - : module.getImportOutputs({ state, environment }).type === "loading" - ? "🔄 Waiting for import outputs" - : "▶️ Running"} - - - ); -}; - -const ModuleDataInfo: FC<{ - state: ProjectState; - hash: string; - moduleData: ModuleData; - environment: Env; -}> = ({ state, hash, moduleData, environment }) => { - return ( - - - {moduleData.type === "loaded" - ? "✅" - : moduleData.type === "loading" - ? "⌛" - : "❌"} - - {moduleData.type === "loaded" ? ( - - ) : ( - {hash} - )} - - ); -}; - -const StateGraph: FC<{ state: ProjectState; environment: Env }> = ({ - state, - environment, -}) => { - return ( - - - State - - {[...state.modules.entries()].map(([hash, moduleData]) => ( - - ))} - - ); -}; - -async function _run( - args: Pick< - RunArgs, - | "src" - | "filename" - | "runner" - | "runnerThreads" - | "showProjectState" - | "logProjectActions" - > & { - environment: Env; - } -) { - const linker = getLinker(); +async function evaluate(args: RunArgs & { environment: Env }) { + const linker = makeFileLinker(); const runner = args.runner ? runnerByName(args.runner, args.runnerThreads ?? 1) : undefined; @@ -156,17 +77,16 @@ async function _run( environment: args.environment, }); - const rootSource = new SqModule({ - name: args.filename ?? EVAL_SOURCE_ID, - code: args.src, - }); - - const showState = () => { - render(); - }; + const rootName = args.filename ?? EVAL_SOURCE_ID; + linker.sources.set(rootName, args.src); + const rootSource = new SqModule({ name: rootName, code: args.src }); if (args.showProjectState) { - project.addEventListener("action", showState); + project.addEventListener("action", () => { + render( + + ); + }); } if (args.logProjectActions) { @@ -176,40 +96,61 @@ async function _run( } project.setHead("root", { module: rootSource }); - return await project.waitForOutput("root"); + const output = await project.waitForOutput("root"); + return { output, sources: linker.sources }; } -async function run(args: RunArgs) { - const environment: Env = { - ...(args.sampleCount && isFinite(Number(args.sampleCount)) - ? { - sampleCount: Number(args.sampleCount), - xyPointLength: Number(args.sampleCount), - } - : { - sampleCount: defaultEnv.sampleCount, - xyPointLength: defaultEnv.xyPointLength, - }), - seed: args.seed || "default-seed", - profile: args.profile ?? false, +function dictToJson(dict: SqDict, samples: number | undefined) { + return Object.fromEntries( + dict.entries().map(([k, v]) => [k, sqValueToJson(v, { samples })]) + ); +} + +export function outputToJson( + output: SqModuleOutput, + sources: ReadonlyMap, + samples?: number +): RunJsonOutput { + const timeMs = output.executionTime; + if (!output.result.ok) { + return { + ok: false, + result: null, + bindings: {}, + errors: output.result.value.errors.map((e) => errorToJson(e, sources)), + timeMs, + }; + } + const { result, bindings, exports } = output.result.value; + return { + ok: true, + result: sqValueToJson(result, { samples }), + bindings: dictToJson(bindings, samples), + ...(exports.isEmpty() ? {} : { exports: dictToJson(exports, samples) }), + errors: [], + timeMs, }; +} - const output = await _run({ - src: args.src, - filename: args.filename, - environment, - runner: args.runner, - runnerThreads: args.runnerThreads, - showProjectState: args.showProjectState, - logProjectActions: args.logProjectActions, - }); +// Runs the code once and prints the output. Returns the files that were loaded, for `--watch`. +async function run(args: RunArgs): Promise { + const environment = makeEnvironment(args); + const { output, sources } = await evaluate({ ...args, environment }); - const printer = new CliPrinter(); if (!output.result.ok) { - printer.printSection( - red("Error:"), - output.result.value.toStringWithDetails() + process.exitCode = 1; + } + + if (args.output === "JSON") { + console.log( + JSON.stringify(outputToJson(output, sources, args.samples), null, 2) ); + return [...sources.keys()]; + } + + const printer = new CliPrinter(); + if (!output.result.ok) { + printErrors(printer, output.result.value.errors, sources); } else { const outputResult = output.result.value; switch (args.output) { @@ -233,58 +174,149 @@ async function run(args: RunArgs) { } if (args.measure) { - printer.printSection(`${bold("Time:")} ${output.executionTime}s`); + printer.printSection(`${bold("Time:")} ${output.executionTime / 1000}s`); } + return [...sources.keys()]; +} + +// Re-runs the code whenever the root file or any of its (transitive) imports change. +async function watch(args: RunArgs, filename: string) { + let watchers: fs.FSWatcher[] = []; + let timer: ReturnType | undefined; + let running = false; + + const rerun = async () => { + if (running) return; + running = true; + watchers.forEach((w) => w.close()); + watchers = []; + + let files = [filename]; + try { + const src = fs.readFileSync(filename, "utf-8"); + if (args.output !== "JSON") console.clear(); + process.exitCode = undefined; + files = await run({ ...args, src }); + } catch (e) { + console.error(e instanceof Error ? e.message : String(e)); + } + if (args.output !== "JSON") { + console.error(`\nWatching ${files.length} file(s) for changes...`); + } + + for (const file of new Set([filename, ...files])) { + if (file === EVAL_SOURCE_ID || !fs.existsSync(file)) continue; + watchers.push( + fs.watch(file, () => { + clearTimeout(timer); + timer = setTimeout(() => void rerun(), 100); + }) + ); + } + running = false; + }; + + await rerun(); } export function addRunCommand(program: Command) { program .command("run") + .description("Evaluate Squiggle code and print the result") .arguments("[filename]") .option( "-e, --eval ", "run a given squiggle code string instead of a file" ) + .option( + "--json", + "print a single JSON object {ok, result, bindings, exports, errors, timeMs} to stdout" + ) + .addOption( + new Option( + "--samples ", + "with --json, include up to n raw samples per sample set distribution" + ).argParser(myParseInt) + ) + .option("--seed ", "random seed (default: 'default-seed')") + .addOption( + new Option( + "-n, --sample-count ", + "number of samples per distribution (default: 1000)" + ) + .env("SAMPLE_COUNT") + .argParser(myParseInt) + ) + .option( + "-w, --watch", + "re-run when the file or its imports change (requires a filename)" + ) .option("-t --time", "output the time it took to evaluate the code") .option("-p --profile", "performance profiler") .option("-q, --quiet", "don't output the results and bindings") // useful for measuring the performance or checking that the code is valid .option("--show-project-state") .option("--log-project-actions") .addOption( - new Option("-r, --runner ", "embedded").choices(allRunnerNames) + new Option( + "-r, --runner ", + "runner to evaluate code with" + ).choices(allRunnerNames) ) .addOption(new Option("--runner-threads ").argParser(myParseInt)) .option( "-b, --show-bindings", "show bindings even if the result is present" ) // incompatible with --quiet + .addHelpText( + "after", + ` +Examples: + $ squiggle run model.squiggle + $ squiggle run -e "normal(10, 2) * 3" + $ squiggle run model.squiggle --json --seed 42 --sample-count 10000 + $ squiggle run model.squiggle --watch + +Exit code is 1 if the code fails to compile or run (also with --json).` + ) .action(async (filename, options) => { let output: OutputMode = "RESULT_OR_BINDINGS"; if (options.quiet && options.showBindings) { program.error( "--quiet and --show-bindings can't be set at the same time." ); + } else if (options.json) { + output = "JSON"; } else if (options.quiet) { output = "NONE"; } else if (options.showBindings) { output = "RESULT_AND_BINDINGS"; } - const src = loadSrc({ program, filename, inline: options.eval }); + if (options.watch && filename === undefined) { + program.error("--watch requires a filename."); + } - const sampleCount = process.env["SAMPLE_COUNT"]; + const src = loadSrc({ program, filename, inline: options.eval }); - await run({ + const args: RunArgs = { src, filename, output, profile: options.profile, measure: options.time, - sampleCount, + sampleCount: options.sampleCount, + seed: options.seed, + samples: options.samples, runner: options.runner, runnerThreads: options.runnerThreads, showProjectState: options.showProjectState, logProjectActions: options.logProjectActions, - }); + }; + + if (options.watch && filename !== undefined) { + await watch(args, filename); + } else { + await run(args); + } }); } diff --git a/packages/squiggle-lang/src/cli/errorReport.ts b/packages/squiggle-lang/src/cli/errorReport.ts new file mode 100644 index 0000000000..6ba1f8ed9a --- /dev/null +++ b/packages/squiggle-lang/src/cli/errorReport.ts @@ -0,0 +1,146 @@ +import { LocationRange } from "../ast/types.js"; +import { + getCodeWarnings, + getErrorHints, + SqCodeWarning, +} from "../public/hints.js"; +import { SqError } from "../public/SqError.js"; +import { CliPrinter } from "./CliPrinter.js"; +import { yellow } from "./colors.js"; + +export type LocationJson = { + file: string; + start: { line: number; column: number }; + end: { line: number; column: number }; +}; + +export type ErrorJson = { + kind: "compile" | "runtime" | "other"; + message: string; + location?: LocationJson; + stack?: { name: string; location?: LocationJson }[]; + importChain?: string[]; + hints?: string[]; +}; + +export type WarningJson = SqCodeWarning & { file: string }; + +function locationToJson(location: LocationRange): LocationJson { + return { + file: location.source, + start: { line: location.start.line, column: location.start.column }, + end: { line: location.end.line, column: location.end.column }, + }; +} + +// For import errors, returns the original error and the chain of imports that led to it. +function unwrapError(error: SqError): { + inner: Exclude; + importChain?: string[]; +} { + if (error.tag !== "import") { + return { inner: error }; + } + return { + inner: error.wrappedError() as Exclude, + importChain: error.getFrameArray().map((frame) => frame.name()), + }; +} + +export function errorToJson( + error: SqError, + // Source code by source id, used to look up the offending line for hints. + sources: ReadonlyMap +): ErrorJson { + const { inner, importChain } = unwrapError(error); + const json: ErrorJson = { kind: inner.tag, message: inner.toString() }; + + const location = inner.tag === "other" ? undefined : inner.location(); + if (location) { + json.location = locationToJson(location); + } + + if (inner.tag === "runtime") { + json.stack = inner.getFrameArray().map((frame) => { + const frameLocation = frame.location(); + return { + name: frame.name(), + ...(frameLocation ? { location: locationToJson(frameLocation) } : {}), + }; + }); + } + + if (importChain?.length) { + json.importChain = importChain; + } + + const line = location + ? sources.get(location.source)?.split("\n")[location.start.line - 1] + : undefined; + const hints = getErrorHints({ message: json.message, line }); + if (hints.length) { + json.hints = hints; + } + return json; +} + +export function warningsForSources( + sources: ReadonlyMap +): WarningJson[] { + return [...sources.entries()].flatMap(([file, code]) => + getCodeWarnings(code).map((w) => ({ file, ...w })) + ); +} + +function errorHeader(kind: ErrorJson["kind"]): string { + switch (kind) { + case "compile": + return "Compile Error"; + case "runtime": + return "Runtime Error"; + case "other": + return "Error"; + } +} + +// Prints an error to stderr in the human-readable format. `details` defaults to the message with its location. +export function printErrorJson( + printer: CliPrinter, + error: ErrorJson, + details?: string +) { + const loc = error.location; + const text = + details ?? + (loc + ? `${loc.file}:${loc.start.line}:${loc.start.column}: ${error.message}` + : error.message); + printer.printError( + errorHeader(error.kind), + [text, ...(error.hints ?? []).map((hint) => yellow("Hint: ") + hint)].join( + "\n" + ) + ); +} + +export function printErrors( + printer: CliPrinter, + errors: SqError[], + sources: ReadonlyMap +) { + for (const error of errors) { + printErrorJson( + printer, + errorToJson(error, sources), + error.toStringWithDetails() + ); + } +} + +export function printWarnings(printer: CliPrinter, warnings: WarningJson[]) { + if (!warnings.length) return; + printer.printWarning( + "Warnings", + warnings.map((w) => `${w.file}:${w.line}: ${w.message}`).join("\n") + ); +} diff --git a/packages/squiggle-lang/src/cli/index.ts b/packages/squiggle-lang/src/cli/index.ts index dd27aa6488..daab043894 100644 --- a/packages/squiggle-lang/src/cli/index.ts +++ b/packages/squiggle-lang/src/cli/index.ts @@ -4,4 +4,9 @@ const main = async () => { await makeProgram().parseAsync(); }; -main(); +main().catch((error) => { + console.error( + error instanceof Error ? `Error: ${error.message}` : String(error) + ); + process.exitCode = 1; +}); diff --git a/packages/squiggle-lang/src/cli/linker.ts b/packages/squiggle-lang/src/cli/linker.ts new file mode 100644 index 0000000000..2877c8e8eb --- /dev/null +++ b/packages/squiggle-lang/src/cli/linker.ts @@ -0,0 +1,45 @@ +import fs from "fs/promises"; +import path from "path"; + +import { SqLinker } from "../public/SqLinker.js"; +import { SqModule } from "../public/SqProject/SqModule.js"; + +export const EVAL_SOURCE_ID = "[eval]"; + +export type FileLinker = SqLinker & { + // Source code of every module loaded through this linker, keyed by source id (path relative to cwd). + sources: Map; +}; + +// Linker that resolves relative imports from the filesystem. +export function makeFileLinker(): FileLinker { + const sources = new Map(); + return { + sources, + resolve(name, fromId) { + if (!name.startsWith("./") && !name.startsWith("../")) { + throw new Error( + `Only relative paths in imports are allowed, got "${name}"` + ); + } + const dir = + fromId === EVAL_SOURCE_ID ? process.cwd() : path.dirname(fromId); + return path.relative(process.cwd(), path.resolve(dir, name)); + }, + async loadModule(sourceId, hash) { + if (hash) { + throw new Error("Hashes are not supported in CLI imports"); + } + let code: string; + try { + code = await fs.readFile(sourceId, "utf-8"); + } catch (e) { + throw new Error( + `Failed to read import "${sourceId}": ${e instanceof Error ? e.message : String(e)}` + ); + } + sources.set(sourceId, code); + return new SqModule({ name: sourceId, code }); + }, + }; +} diff --git a/packages/squiggle-lang/src/cli/makeProgram.ts b/packages/squiggle-lang/src/cli/makeProgram.ts index 17e1ec2ae1..ba75bc2a21 100644 --- a/packages/squiggle-lang/src/cli/makeProgram.ts +++ b/packages/squiggle-lang/src/cli/makeProgram.ts @@ -1,5 +1,7 @@ import { Command } from "@commander-js/extra-typings"; +import { addCheckCommand } from "./commands/check.js"; +import { addDocsCommand } from "./commands/docs.js"; import { addParseCommand } from "./commands/parse.js"; import { addPlaygroundCommand } from "./commands/playground.js"; import { addPrintIrCommand } from "./commands/print-ir.js"; @@ -8,7 +10,27 @@ import { addRunCommand } from "./commands/run.js"; export function makeProgram() { const program = new Command(); + program + .name("squiggle") + .description( + "Squiggle: a language for probabilistic estimation. Run models, check them for errors, and look up builtin docs." + ) + .addHelpText( + "after", + ` +Examples: + $ squiggle run model.squiggle # evaluate a file, print the result + $ squiggle run -e "2 to 10" --json # machine-readable output (dists summarized) + $ squiggle check model.squiggle --json # errors + hints + warnings, without running + $ squiggle docs Dist.normal # signatures and examples for a builtin + $ squiggle docs --list # all builtin namespaces + +Run \`squiggle --help\` for command options.` + ); + addRunCommand(program); + addCheckCommand(program); + addDocsCommand(program); addParseCommand(program); addPrintIrCommand(program); addPlaygroundCommand(program); diff --git a/packages/squiggle-lang/src/cli/utils.ts b/packages/squiggle-lang/src/cli/utils.ts index cbd14fa87e..e57f8b5644 100644 --- a/packages/squiggle-lang/src/cli/utils.ts +++ b/packages/squiggle-lang/src/cli/utils.ts @@ -19,7 +19,13 @@ export function loadSrc({ if (filename !== undefined && inline !== undefined) { program.error("Only one of filename and eval string should be set."); } else if (filename !== undefined) { - src = fs.readFileSync(filename, "utf-8"); + try { + src = fs.readFileSync(filename, "utf-8"); + } catch (e) { + program.error( + `Failed to read file "${filename}": ${e instanceof Error ? e.message : String(e)}` + ); + } } else if (inline !== undefined) { src = inline; } else { diff --git a/packages/squiggle-lang/src/index.ts b/packages/squiggle-lang/src/index.ts index 59bac06a54..2d8468bfd6 100644 --- a/packages/squiggle-lang/src/index.ts +++ b/packages/squiggle-lang/src/index.ts @@ -59,12 +59,23 @@ export { SqScatterPlot, } from "./public/SqValue/SqPlot.js"; export { SqSpecification } from "./public/SqValue/SqSpecification.js"; +export { + sqDistributionSummary, + sqValueToJson, + type SqValueToJsonOptions, +} from "./public/SqValue/toJson.js"; export { SqTableChart } from "./public/SqValue/SqTableChart.js"; export { SqCalculator } from "./public/SqValue/SqCalculator.js"; export { SqDict } from "./public/SqValue/SqDict.js"; export { SqScale } from "./public/SqValue/SqScale.js"; export { SqValuePath, SqValuePathEdge } from "./public/SqValuePath.js"; export { parse } from "./public/parse.js"; +export { + getCodeWarnings, + getErrorHints, + type SqCodeWarning, + suggestBuiltinNames, +} from "./public/hints.js"; export { fmap as resultMap, type result } from "./utility/result.js"; export { @@ -112,6 +123,11 @@ export function getFunctionDocumentation(name: string) { return getRegistry().getFunctionDocumentation(name); } +// Documentation for all builtin functions, including ones with the same name in different namespaces. +export function getAllFunctionDocumentation() { + return getRegistry().allFunctionDocumentation(); +} + export function getAllFunctionNames() { return getRegistry().allNames(); } diff --git a/packages/squiggle-lang/src/library/registry/core.ts b/packages/squiggle-lang/src/library/registry/core.ts index e0d9830e27..bcbc1d2aba 100644 --- a/packages/squiggle-lang/src/library/registry/core.ts +++ b/packages/squiggle-lang/src/library/registry/core.ts @@ -44,6 +44,43 @@ type FnNameDict = Map; export type FnDocumentation = FRFunction & { signatures: string[] }; +function getShorthandName(name: string): Shorthand | undefined { + const infix: string | undefined = get( + invert(infixFunctions), + name, + undefined + ); + if (infix) { + return { type: "infix", symbol: infix }; + } + const unary: string | undefined = get( + invert(unaryFunctions), + name, + undefined + ); + if (unary) { + return { type: "unary", symbol: unary }; + } + return undefined; +} + +function fnToDocumentation(fn: FRFunction): FnDocumentation { + return { + name: fn.name, + nameSpace: fn.nameSpace, + requiresNamespace: fn.requiresNamespace, + description: fn.description, + definitions: fn.definitions, + examples: fn.examples, + signatures: fn.definitions + .filter((d) => d.showInDocumentation()) + .map((d) => d.toString()), + isUnit: fn.isUnit, + shorthand: getShorthandName(fn.name), + displaySection: fn.displaySection, + }; +} + export class Registry { private constructor( private functions: FRFunction[], @@ -107,38 +144,12 @@ export class Registry { } }); - if (!fn) return; - - const _infixes = invert(infixFunctions); - const _unary = invert(unaryFunctions); - - function getShorthandName(name: string): Shorthand | undefined { - const infix: string | undefined = get(_infixes, name, undefined); - if (infix) { - return { type: "infix", symbol: infix }; - } else { - const unary: string | undefined = get(_unary, name, undefined); - if (unary) { - return { type: "unary", symbol: unary }; - } - return undefined; - } - } + return fn ? fnToDocumentation(fn) : undefined; + } - return { - name: fn.name, - nameSpace: fn.nameSpace, - requiresNamespace: fn.requiresNamespace, - description: fn.description, - definitions: fn.definitions, - examples: fn.examples, - signatures: fn.definitions - .filter((d) => d.showInDocumentation()) - .map((d) => d.toString()), - isUnit: fn.isUnit, - shorthand: getShorthandName(fn.name), - displaySection: fn.displaySection, - }; + // Documentation for every registered function, in registration order. + allFunctionDocumentation(): FnDocumentation[] { + return this.functions.map(fnToDocumentation); } makeLambda(fnName: string): Lambda { diff --git a/packages/squiggle-lang/src/public/SqValue/toJson.ts b/packages/squiggle-lang/src/public/SqValue/toJson.ts new file mode 100644 index 0000000000..dd94a9b651 --- /dev/null +++ b/packages/squiggle-lang/src/public/SqValue/toJson.ts @@ -0,0 +1,207 @@ +import { type JsonValue } from "@quri/serializer"; + +import { SqDistribution, SqDistributionTag } from "./SqDistribution/index.js"; +import { type SqValue } from "./index.js"; + +export type SqValueToJsonOptions = { + // Include up to this many raw samples for sample set distributions. Defaults to 0 (no samples). + samples?: number; + // Values nested deeper than this are replaced with their string representation. + maxDepth?: number; +}; + +type JsonObject = { [key: string]: JsonValue }; + +// JSON has no representation for Infinity or NaN; we encode them as strings. +function jsonNumber(n: number): number | string { + return Number.isFinite(n) ? n : String(n); +} + +function finiteOrUndefined(n: number): number | undefined { + return Number.isFinite(n) ? n : undefined; +} + +const PERCENTILES = [ + ["p5", 0.05], + ["p25", 0.25], + ["p50", 0.5], + ["p75", 0.75], + ["p95", 0.95], +] as const; + +// Drops `undefined` fields, so that optional fields are omitted from the JSON. +function compact(obj: { [key: string]: JsonValue | undefined }): JsonObject { + const result: JsonObject = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined) result[key] = value; + } + return result; +} + +export function sqDistributionSummary( + dist: SqDistribution, + options: SqValueToJsonOptions = {} +): JsonObject { + const inner = dist._value; + const stdev = inner.stdev(); + const samples = + dist.tag === SqDistributionTag.SampleSet ? dist.getSamples() : undefined; + + return compact({ + type: "Dist", + subtype: dist.tag, + mean: finiteOrUndefined(inner.mean()), + stdev: stdev.ok ? finiteOrUndefined(stdev.value) : undefined, + ...Object.fromEntries( + PERCENTILES.map(([key, p]) => [key, finiteOrUndefined(inner.inv(p))]) + ), + min: finiteOrUndefined(inner.min()), + max: finiteOrUndefined(inner.max()), + sampleCount: samples?.length, + samples: + samples && options.samples && options.samples > 0 + ? samples.slice(0, options.samples) + : undefined, + // Symbolic and point set distributions have a short description, e.g. "Normal(5,2)". + description: samples ? undefined : dist.toString(), + }); +} + +function withTags(value: SqValue, json: JsonObject): JsonObject { + return compact({ + ...json, + name: value.tags.name(), + doc: value.tags.doc(), + }); +} + +function toJson( + value: SqValue, + options: SqValueToJsonOptions, + depth: number +): JsonValue { + if (options.maxDepth !== undefined && depth > options.maxDepth) { + return value.toString(); + } + const recurse = (v: SqValue) => toJson(v, options, depth + 1); + + switch (value.tag) { + case "Number": + return jsonNumber(value.value); + case "String": + return value.value; + case "Bool": + return value.value; + case "Void": + return null; + case "Array": + return value.value.getValues().map(recurse); + case "Dict": + return Object.fromEntries( + value.value.entries().map(([k, v]) => [k, recurse(v)]) + ); + case "Date": + return withTags(value, { + type: "Date", + iso: value.value.toDate().toISOString(), + ms: value.value.toMs(), + }); + case "Duration": + return withTags(value, { + type: "Duration", + ms: value.value.toMs(), + display: value.value.toString(), + }); + case "Dist": + return withTags(value, sqDistributionSummary(value.value, options)); + case "Lambda": { + const lambda = value.value; + return withTags(value, { + type: "Lambda", + kind: lambda.type, + signature: `(${lambda.parameterString()})`, + parameters: lambda + .signatures() + .map((signature) => + signature.map((p) => compact({ name: p.name, type: p.typeName })) + ), + }); + } + case "Plot": { + const plot = value.value; + return withTags( + value, + compact({ + type: "Plot", + plotType: plot.tag, + title: plot.title, + distributions: + plot.tag === "distributions" + ? plot.distributions.map(({ name, distribution }) => + compact({ + name, + ...sqDistributionSummary(distribution, options), + }) + ) + : undefined, + }) + ); + } + case "TableChart": { + const table = value.value; + return withTags(value, { + type: "TableChart", + rowCount: table.rowCount, + columnCount: table.columnCount, + columnNames: table.columnNames.map((n) => n ?? null), + }); + } + case "Calculator": { + const calc = value.value; + return withTags( + value, + compact({ + type: "Calculator", + title: calc.title, + description: calc.description, + inputs: calc.inputs.map((input) => + compact({ + name: input.name, + inputType: input.tag, + description: input.description, + }) + ), + }) + ); + } + case "Specification": { + const spec = value.value; + return withTags( + value, + compact({ + type: "Specification", + specName: spec.name, + documentation: spec.documentation, + }) + ); + } + case "Scale": + case "Domain": + case "Input": + return withTags(value, { type: value.tag, value: value.toString() }); + default: + throw new Error(`Unknown value ${value satisfies never}`); + } +} + +/** + * Converts a Squiggle value to a compact, JSON-compatible representation intended for programmatic consumers (CLI `--json`, agents, APIs). + * + * Primitives map to JSON primitives, arrays and dicts map to arrays and objects, and everything else becomes an object with a `type` field. Distributions are summarized (mean, stdev, percentiles) instead of dumping all samples. + */ +export function sqValueToJson( + value: SqValue, + options: SqValueToJsonOptions = {} +): JsonValue { + return toJson(value, options, 0); +} diff --git a/packages/squiggle-lang/src/public/hints.ts b/packages/squiggle-lang/src/public/hints.ts new file mode 100644 index 0000000000..a388c22073 --- /dev/null +++ b/packages/squiggle-lang/src/public/hints.ts @@ -0,0 +1,307 @@ +/** + * Deterministic hints and warnings for common Squiggle mistakes. + * + * Ported (and condensed) from the heuristics that Squiggle AI uses to steer LLMs (`internal-packages/ai/src/squiggle/squiggleErrorSuggestions.ts` and `squiggleCodeWarnings.ts`). These are heuristics: they can be wrong, so they are reported as hints and warnings, never as errors. + */ +import { getStdLib } from "../library/index.js"; + +export type SqCodeWarning = { + line: number; // 1-based + message: string; +}; + +const TYPE_NAMES = new Set([ + "Number", + "String", + "Boolean", + "Bool", + "Date", + "Duration", + "Dist", + "List", + "Dict", + "Array", + "Int", + "Float", +]); + +// Removes string literals and line comments so that heuristics don't fire on text inside them. +// A single pass rather than regexes, so that it stays linear on unterminated strings. +// An unterminated string extends to the end of the line. +function stripStringsAndComments(line: string): string { + let result = ""; + let i = 0; + while (i < line.length) { + const ch = line[i]; + if (ch === '"' || ch === "'") { + let j = i + 1; + while (j < line.length && line[j] !== ch) { + j += line[j] === "\\" ? 2 : 1; + } + result += ch + ch; + i = j + 1; + } else if (ch === "/" && line[i + 1] === "/") { + break; + } else { + result += ch; + i++; + } + } + return result; +} + +function levenshtein(a: string, b: string): number { + const prev = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + let diagonal = prev[0]; + prev[0] = i; + for (let j = 1; j <= b.length; j++) { + const tmp = prev[j]; + prev[j] = Math.min( + prev[j] + 1, + prev[j - 1] + 1, + diagonal + (a[i - 1] === b[j - 1] ? 0 : 1) + ); + diagonal = tmp; + } + } + return prev[b.length]; +} + +/** + * Suggests builtin names similar to `name`. For `List.sum`, this finds `Number.sum` (same function name in another namespace) and close spellings like `List.uniq`. + */ +export function suggestBuiltinNames(name: string, limit = 3): string[] { + const allNames = [...getStdLib().keys()].filter( + (n) => /^[A-Za-z]/.test(n) && n !== name + ); + const [namespace, fnName] = name.includes(".") + ? (name.split(".", 2) as [string, string]) + : [undefined, name]; + + const scored: { name: string; score: number }[] = []; + for (const candidate of allNames) { + const [cNamespace, cFnName] = candidate.includes(".") + ? (candidate.split(".", 2) as [string, string]) + : [undefined, candidate]; + + if (cFnName.toLowerCase() === fnName.toLowerCase()) { + // Same function, different namespace (or different capitalization). + scored.push({ name: candidate, score: 0 }); + continue; + } + if (namespace !== undefined && cNamespace !== namespace) continue; + const distance = levenshtein(fnName.toLowerCase(), cFnName.toLowerCase()); + const threshold = Math.max(1, Math.floor(fnName.length / 3)); + if (distance <= threshold) { + scored.push({ name: candidate, score: distance }); + } + } + scored.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name)); + return scored.slice(0, limit).map((s) => s.name); +} + +/** + * Returns short, actionable hints for an error message. `line` is the source line where the error happened, if known; some hints depend on it. + */ +export function getErrorHints({ + message, + line, +}: { + message: string; + line?: string; +}): string[] { + const hints: string[] = []; + const code = line !== undefined ? stripStringsAndComments(line) : ""; + + const notDefined = message.match(/^([\w.]+) is not defined/); + if (notDefined) { + const name = notDefined[1]; + if (["null", "nil", "undefined", "None", "none"].includes(name)) { + hints.push( + `Squiggle has no '${name}'. Use false, an empty string, or a dict field to represent a missing value.` + ); + } else if (["Infinity", "INFINITY", "NaN"].includes(name)) { + hints.push( + "There is no Infinity constant. Use Number.maxValue / Number.minValue, or 1/0." + ); + } else if (name === "Date.now") { + hints.push("Use Danger.now() for the current time."); + } else if (TYPE_NAMES.has(name) && /\w\s*:\s*[A-Z]/.test(code)) { + hints.push( + `Type annotations like 'x: ${name}' are not supported. Omit the annotation, or use a domain such as 'x: [0, 100]' or 'd: [Date(2020), Date(2030)]'.` + ); + } else { + const suggestions = suggestBuiltinNames(name); + if (suggestions.length) { + hints.push(`Did you mean ${suggestions.join(", ")}?`); + } + if (/^[a-z]/.test(name) && !name.includes(".")) { + hints.push( + "Variables must be defined before use (above this line), and recursion is not supported." + ); + } else if (name.includes(".")) { + hints.push( + `Run \`squiggle docs ${name.split(".")[0]}\` to list the functions in this namespace.` + ); + } + } + return hints; + } + + if (/but "#" found/.test(message)) { + hints.push("Comments use // or /* ... */, not #."); + } + if (/\b(return)\b/.test(code)) { + hints.push( + "There is no 'return' keyword: the last expression in a block or function is its value." + ); + } + if (/[-+*/^]=/.test(code)) { + hints.push( + "Variables are immutable and compound assignment (+=, -=, ...) is not supported. Write 'x = x + 1' (shadowing) instead." + ); + } + if (/=>/.test(code)) { + hints.push( + "Arrow functions are not supported. Use 'f(x) = x + 1' or a lambda '{|x| x + 1}'." + ); + } + if (/^\s*[A-Z]\w*\s*(=|\()/.test(code) && /but "[=(]" found/.test(message)) { + hints.push( + "Variable and function names must start with a lowercase letter." + ); + } + if (/^\s*import\b/.test(code)) { + hints.push("Imports must come before any other statements."); + } + if (/\bif\b/.test(code) && !/\belse\b/.test(code)) { + hints.push( + "'if' always needs 'then' and 'else': 'if cond then a else b' (or 'if cond { a } else { b }')." + ); + } + // Equivalent to /^Expected "->".*end of input.* but "[\w"]/, without the + // polynomial backtracking. + const endOfInput = message.indexOf("end of input"); + if ( + message.startsWith('Expected "->"') && + endOfInput !== -1 && + / but "[\w"]/.test(message.slice(endOfInput)) && + !/^\s*import\b/.test(code) + ) { + hints.push( + "Only the final statement of a file or block may be a bare expression; every earlier statement must be an assignment like 'x = ...'." + ); + } + if (/but "}" found/.test(message)) { + hints.push( + "A block must end with an expression that is its value, e.g. '{ a = 1; a + 1 }', not with an assignment." + ); + } + return hints; +} + +/** + * Line-based checks for code that is likely wrong or not idiomatic Squiggle. Unlike errors, these can be false positives. + */ +export function getCodeWarnings(code: string): SqCodeWarning[] { + const warnings: SqCodeWarning[] = []; + const lines = code.split("\n"); + let seenNonImport = false; + let inBlockComment = false; + + lines.forEach((rawLine, index) => { + const lineNumber = index + 1; + + if (/^(<<<<<<< |=======$|>>>>>>> )/.test(rawLine)) { + warnings.push({ + line: lineNumber, + message: `Merge/diff artifact "${rawLine.trim()}" should be removed.`, + }); + return; + } + + let line = rawLine; + if (inBlockComment) { + const end = line.indexOf("*/"); + if (end === -1) return; + line = line.slice(end + 2); + inBlockComment = false; + } + // Remove block comments that end on this line. + for ( + let start = line.indexOf("/*"); + start !== -1; + start = line.indexOf("/*", start) + ) { + const end = line.indexOf("*/", start + 2); + if (end === -1) break; + line = line.slice(0, start) + line.slice(end + 2); + } + if (line.includes("/*")) { + line = line.slice(0, line.indexOf("/*")); + inBlockComment = true; + } + line = stripStringsAndComments(line); + const trimmed = line.trim(); + if (trimmed === "") return; + + if (/^import\b/.test(trimmed)) { + if (seenNonImport) { + warnings.push({ + line: lineNumber, + message: + "Import statement after other code; move imports to the top.", + }); + } + } else { + seenNonImport = true; + } + + const nullish = trimmed.match(/\b(null|nil|undefined)\b/); + if (nullish) { + warnings.push({ + line: lineNumber, + message: `'${nullish[1]}' does not exist in Squiggle; use false, "" or a dict field instead.`, + }); + } + if (/=>/.test(trimmed)) { + warnings.push({ + line: lineNumber, + message: + "Arrow function syntax '=>' is not supported; use 'f(x) = ...' or '{|x| ...}'.", + }); + } + if (/\bDate\.now\b/.test(trimmed)) { + warnings.push({ + line: lineNumber, + message: "'Date.now' does not exist; use 'Danger.now()'.", + }); + } + if (/\b(Infinity|INFINITY|NEGATIVE_INFINITY)\b/.test(trimmed)) { + warnings.push({ + line: lineNumber, + message: + "Infinity constants don't exist; use Number.maxValue or Number.minValue.", + }); + } + const capitalized = trimmed.match(/^([A-Z]\w*)\s*(=(?!=)|\(.*\)\s*=(?!=))/); + if (capitalized) { + warnings.push({ + line: lineNumber, + message: `'${capitalized[1]}' starts with a capital letter; variable and function names must start lowercase.`, + }); + } + if (!trimmed.startsWith("@")) { + const annotation = trimmed.match(/\b(\w+\s*:\s*([A-Z]\w*))\s*[,)]/); + if (annotation && TYPE_NAMES.has(annotation[2])) { + warnings.push({ + line: lineNumber, + message: `Type annotation '${annotation[1]}' is not supported; omit it or use a domain like [0, 100].`, + }); + } + } + }); + + return warnings; +}