Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/cli-agent-tools.md
Original file line number Diff line number Diff line change
@@ -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`.
20 changes: 20 additions & 0 deletions packages/squiggle-lang/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>`, 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`.
Expand Down
38 changes: 38 additions & 0 deletions packages/squiggle-lang/__tests__/SqValue/toJson_test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
89 changes: 89 additions & 0 deletions packages/squiggle-lang/__tests__/cli/check_test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
73 changes: 73 additions & 0 deletions packages/squiggle-lang/__tests__/cli/docs_test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading
Loading