diff --git a/gates/repo.ts b/gates/repo.ts index 0b59edcce4..1792989e84 100644 --- a/gates/repo.ts +++ b/gates/repo.ts @@ -84,6 +84,10 @@ allow(/^tests\/[a-z]+\/[a-z0-9_]+\.(c|js)$/); allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\.json|tsconfig\.json)$/); allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); +allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json)$/); +allow(/^tools\/bend-lint\/src\/(lint\.ts|seam\.ts|lint\.test\.ts)$/); +allow(/^tools\/bend-lint\/src\/bend\/(lint\.bend|effects\.js|sample\.bend)$/); +allow(/^tools\/bend-lint\/rules\/[a-z_]+(\.test)?\.(ts|bend)$/); // Gate // ==== diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md new file mode 100644 index 0000000000..b4b9f09103 --- /dev/null +++ b/tools/bend-lint/README.md @@ -0,0 +1,427 @@ +# bend-lint + +A linter and formatter for Bend 2. + +It runs your file through Bend's type checker first. If that passes, it runs +whatever rules you loaded. Rules are TypeScript or Bend files, and they can see +the source text, the types, variable scope and how often each variable is used. +A rule can also suggest a fix. + +bend-lint is also maintained at +[github.com/MattCozendey/bend-lint](https://github.com/MattCozendey/bend-lint). + +## Setup + +You need Bun 1.2 or newer. Run everything from the repo root. Type checking +needs `bun install` in `tools/bend-lint` first. + +## Running it + +```sh +bun tools/bend-lint/src/lint.ts file.bend --rules tools/bend-lint/rules/format.ts +``` + +Nothing runs by default. You have to pass `--rules`, and you can pass it more +than once: + +```sh +bun tools/bend-lint/src/lint.ts file.bend --rules tools/bend-lint/rules/format.ts --rules tools/bend-lint/rules/file_length.bend +``` + +Add `--fix` to write the fixes back to the file. + +``` +bun tools/bend-lint/src/lint.ts [--rules ]... [--config ] + [--fix | --fix-suggested | --fix-dangerously] + [--json] [--bend ] +``` + +Every fix is marked `safe`, `suggested` or `dangerous`: + +- `--fix` applies the safe ones. They shouldn't change what the program does. +- `--fix-suggested` adds the suggested ones, which might. +- `--fix-dangerously` applies everything. Expect breakage sometimes. + +Fixes go into the file you passed in, even if the run had errors. A fix that +also edits another file is skipped. When two fixes overlap, the later one is +skipped. Run it again to get it. + +Exit code is 0 if there were no errors (warnings don't count), 1 for a checker +or rule error, and 2 for bad arguments or a crash. + +`--json` prints the findings as JSON. The format is in +[JSON output](#json-output). + +## Where Bend comes from + +bend-lint loads Bend's TypeScript source and patches it in memory. To find that +source it tries, in order: + +1. `--bend ` or `$BEND_DIR`. A Bend checkout or its `bend2` folder both work. +2. The Bend checkout around it. This is the case in `tools/bend-lint`. +3. A release downloaded from GitHub. It matches `bend version` if Bend is on + your PATH, otherwise it takes the latest. + +Downloads are cached in `~/.cache/bend-lint/`. `XDG_CACHE_HOME` moves that, and +on Windows it's under `LOCALAPPDATA`. With no network it falls back to the +newest cached release. + +## Rules included + +`format/layout` (`tools/bend-lint/rules/format.ts`) is the formatter. It fixes indentation, +spacing, comments, blank lines between declarations, wrapping and the final +newline. Options: `tabWidth` (default 2) and `wrapAtWidth` (default 100). Both +must be positive integers, or `"never"` for `wrapAtWidth`. The width is a target; +long literals and comments can run past it. `endOfLine` is `"lf"` (default), +`"crlf"` or `"preserve"`; preserve uses the first line ending, or LF if there +is none. Line endings inside literals stay as written. Its fix is `safe`. + +Declaration order and literals stay as written. The only comment change is a +space added after `#` when it's missing. Before offering a fix it parses +its own output and compares that to the original program. If they differ you +get a warning and no fix. + +`style/file-length` (`tools/bend-lint/rules/file_length.bend`) warns when the file has more than +`maxLines` lines (default 500). Imports don't count. No fix. + +## Config + +Put a `bend-lint.json`, `bend-lint.js` or `bend-lint.ts` next to the file you're +linting or in any parent folder, or point at one with `--config`. The closest +folder wins. Inside a folder, JSON beats JS beats TS. Only one file is read. + +```json +{ + "rules": { + "format/layout": { "tabWidth": 2, "wrapAtWidth": 100 }, + "style/file-length": { "maxLines": 400, "severity": "warning" } + } +} +``` + +JS and TS configs export the same thing as a named `config`. + +```ts +export const config = { + rules: { + "style/file-length": { maxLines: 400 }, + }, +}; +``` + +Rules still come from `--rules`; config sets their options and severity. +`"off"` turns a rule off. Severities are `error`, `warning`, `information` and `hint`, and an +`error` stops the rules after it. + +If a rule declares defaults for its options, unknown keys and wrong types are +rejected. Otherwise the rule checks its own options. + +## What you get back + +If Bend's checker rejects the file you get a `bend/check` error and no rules +run. + +## Writing a rule + +A rule is a TypeScript or Bend file. Save one under `rules/`, or anywhere, and load it with `--rules`. + +### TypeScript rules + +A module exports `rules: LintRule[]`. A rule has an `id` (`namespace/name`, it +becomes the finding's `code`) and `run(cx, signal)`, which returns diagnostics +and can be async. It can also have `facts` and `options`. If `run` throws or the +signal aborts, the error goes to whoever called the library. + +Build findings with `cx.diag()`. Severity defaults to `warning`. Rules run in +order and see earlier findings in `cx.prior`. The first `error` ends the run: +the rest of that rule's findings and every later rule are dropped. + +What's on `cx`: + +- `root`, `sources`: the linted file and its imports, text as on disk +- `options`: defaults merged with config +- `facts`: just the facts this rule asked for +- `prior`: findings from earlier rules +- `view(fact)`: what the fact's term is (see Facts) +- `type(fact)`: the type the term was checked as +- `binder(fact)`: the declared type of the variable a `Var` uses +- `show`, `same`, `normal`: print, compare and normalize types in a fact's scope +- `uses(fact)`: used variables with their quantities +- `sameDeclarations(text)`: whether `text` declares what the linted file + declares, compared as parsed, not checked +- `body(name)`, `node(fact)`, `shape(node)`, `fact(node)`: the term view (see + Nodes) +- `diag(init)`: make a diagnostic with this rule's ID +- `unstable`: Bend's own objects (`Bend`, the checked `book`, `raw(fact)`). + Code that uses them breaks when Bend changes; nothing else on `cx` does. + +The exported types in [src/lint.ts](src/lint.ts) are the full contract. They +are bend-lint's own: facts, types and nodes are handles, and only `cx` reads +them. + +#### Fixes + +A fix is a title, an applicability (`safe`, `suggested`, `dangerous`) and edits. +An edit swaps a span for text. A span is a source from `cx.sources` and two +offsets. A zero-length span inserts. Offsets are integer UTF-16 units inside the +source, and edits in one fix can't overlap. + +```ts +const span = { file: cx.root, beg: 0, end: 1 }; +return [cx.diag({ + message: "Replace the first character with a space.", + span, + fixes: [{ + title: "Replace character", + applicability: "suggested", + edits: [{ span, text: " " }], + }], +})]; +``` + +(Needs a nonempty file.) + +#### Facts + +A fact is one checked term. `cx.view(fact)` gives its kind (annotations +stripped: `Var`, `Ref`, `App`, ...), the name a `Var` or `Ref` points to, its +enclosing definition (`owner`), how many times it is demanded (`quantity`: +`erased`, `once` or `many`), and its source span (`span`, and `inner` without +the annotations), when there is one. Its type and its scope are reached through +the other `cx` operations. + +Ask for them with `facts: true` (everything in the linted file) or a filter: + +```ts +facts: { + scope: "file", // default. "program" adds imports, not Base + kinds: ["Var"], // term kind without annotations: Var, Ref, App, ... + defs: ["main"], // enclosing definition, instances count as their template + names: ["foo"], // name used by a Var or Ref +} +``` + +All the lists you give must match. Leave one out or empty and it matches +everything. Keep filters narrow, since memory goes up with the number of facts. + +Template bodies get checked as written and again per instance (`generic~0`), +sometimes at the same span. `view(fact).inst` marks instances. Skip them to +avoid double reports: + +```ts +import type { LintRule } from "../src/lint.ts"; + +export const rules: LintRule[] = [{ + id: "demo/var-types", + facts: { kinds: ["Var"] }, + run: (cx) => cx.facts! + .filter((fact) => !cx.view(fact).inst) + .map((fact) => cx.diag({ + message: "type: " + cx.show(fact, cx.type(fact)), + severity: "hint", + span: cx.view(fact).span, + fact, + })), +}]; +``` + +(Saved under `rules/`.) + +#### Nodes + +A node is one term of a def's checked body. `cx.body(name)` gives the root, +`cx.shape(node)` its kind (annotations kept: `Ann`, `Var`, `App`, ...), the name +a `Var` or `Ref` points to, its span and its children, in Bend's order. +`cx.node(fact)` is the node a fact is about, and `cx.fact(node)` is the node's +fact, if this rule asked for it. Kinds and child order are Bend's, so a rule +that reads them may need changes when Bend's terms change; facts alone do not. + +```ts +const nodes = (node: Node): Node[] => [node, ...cx.shape(node).children.flatMap(nodes)]; +const matches = nodes(cx.body("main")!).filter((n) => cx.shape(n).kind === "Mat"); +``` + +#### Options + +`options: { tabWidth: 2, breakLines: false }` sets defaults. They also set which +keys are allowed and their types (`number`, `boolean`, `string`). Unknown keys +and wrong types are errors. A rule with no defaults gets the raw options and has +to check them itself. + +### Bend + +A `.bend` rule imports [src/bend/lint.bend](src/bend/lint.bend). Here's one that reports +nothing, saved under `rules/`: + +```python +import Base +import ../src/bend/lint.bend as Lint + +def id() -> String: + "demo/nothing" + +def facts() -> Lint.Want: + Lint.NoFacts{} + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + IO.pure(List<&2, Lint.Diag>, []) + +def main() -> IO(Unit): + Lint.serve(run) +``` + +`input` has the sources and options. To get facts, return a filter from +`facts()`, e.g. `Lint.Want{Lint.File{}, ["Var"], [], []}`. Empty lists match +everything, and `Lint.Program{}` adds imports (not Base). + +Read facts one by one with `next_fact`, or fold: + +```python +def step(found: List<&2, Lint.Diag>, +f: Lint.Fact) -> IO(List<&2, Lint.Diag>): + ... + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + Lint.fold_facts(~List<&2, Lint.Diag>, ~step, []) +``` + +`step` goes in as a template (`~step`) and takes the fact as `+f`. Effects: +`view`, `type_of`, `binder`, `same`, `show`, `normal`, `uses`, `text`, and the +term view `body`, `node`, `shape` and `fact`, plus Base's. Options come from `Lint.option_number`, `option_flag` and `option_text`, +each with a default. Numbers are whole, 0 to 4294967295 (U32). + +Spans count Unicode code points here. TypeScript counts UTF-16 units. + +The rule is compiled once and runs on Bend's JavaScript runtime. Use tail +recursion over long text or lists to stay inside the stack. Streaming facts +saves building a list of all of them. + +For examples see [file_length.bend](rules/file_length.bend) and +[shared.bend](rules/shared.bend). `COMMA_BEND`, `TYPES_BEND` and `COUNT_BEND` in +[src/lint.test.ts](src/lint.test.ts) cover fixes and facts. + +### As a library + +From a file in `tools/bend-lint`: + +```ts +import { applyFixes, bendRule, lint, render } from "./src/lint.ts"; +import { rules } from "./rules/format.ts"; + +const result = await lint("example.bend", [ + ...rules, + await bendRule("rules/file_length.bend"), +]); +console.log(result.ok, result.diags.map(render)); + +if (result.ok) { + const root = result.sources.find((source) => source.root)!; + const { text, skipped } = applyFixes(root, result.diags); + // text is the edited source; saving it is up to you + console.log(text, skipped); +} +``` + +Set `BEND_DIR` before importing bend-lint to pick a Bend checkout. Import +bend-lint before you load Bend yourself, so the patching happens first. `lint` +takes `{ config, signal, unsaved }` as a third argument. Without `config` it looks +next to the file. `unsaved` maps file paths to editor text that isn't saved yet. +Bend and the rules read that text instead of the file, for that run only. Runs +at the same time wait for each other's check, one at a time; rules still run +side by side. `position(span)` gives an LSP range. + +`findConfig(file)` checks the file's directory, then each parent, for +`bend-lint.json`, `.js` or `.ts`. Closest directory wins, then JSON, JS, TS. +`readConfig(file)` loads a path you give it. Both are synchronous. JS and TS +configs export a named `config` object and go through Bun's loader, cache +included. They can import relative files. Rule settings and option validation +are the same for all three formats. + +### JSON output + +`--json` prints one JSON object on stdout. Status and failure messages can still +show up on stderr. A finding with a fix: + +```json +{ + "ok": true, + "findings": [{ + "code": "style/example", + "severity": "warning", + "message": "Replace this character.", + "def": "main", + "path": "/abs/example.bend", + "range": { + "start": { "line": 2, "character": 0 }, + "end": { "line": 2, "character": 1 } + }, + "fixes": [{ + "title": "Replace character", + "applicability": "suggested", + "edits": [{ + "path": "/abs/example.bend", + "range": { + "start": { "line": 2, "character": 0 }, + "end": { "line": 2, "character": 1 } + }, + "text": " " + }] + }] + }] +} +``` + +`def`, `path` and `range` are left out when there's nothing to put. Lines start +at 0, characters are UTF-16. With a fix flag, findings describe the source as it +was before the fixes. + +## How it works + +### Patching + +Bun patches Bend's modules while they load. `term_infer` and `term_check` are +renamed and wrapped to record what they return to `hook.see` in seam.ts. The +wrappers pass every argument on. The hook is global, so checks run one at a +time. The `fs` and `path` adapters, in `bend.ts` and `main.ts`, turn real paths +into `/` paths so imports resolve on Windows. `comp.ts` also exports +`RUNTIME_MAIN` and `js_sat`, which compiling Bend rules needs. `main.ts` exports +`book_read`, `book_err` and `Check_Fail`: a file is read and checked by the same +code as `bend `, never a copy of it. Imported, `main.ts` also registers +Bend's loader for `import "x.bend"`. + +If a file has a line that's exactly `import Base` (what `bend --checkup` reads), +it reuses one Base, checked once per process. Base facts are left out of rule +requests. + +A failed check is one `bend/check` finding. Its message is Bend's, without the +location: expected and observed, with the names in scope, and Bend's note. + +### Staying compatible + +We don't pin a Bend version. Instead, on load, for checkouts and downloads +alike: + +- every source edit has to match exactly once, the compiler exports we need + have to be declared, and the runtime names a Bend rule's run calls + (`cli_args`, `io_run`) have to be there once +- wrapper signatures are checked against Bend's at type-check time, and argument + counts at runtime +- `book_read`, `book_err` and `Check_Fail` from `main.ts` must take the + arguments bend-lint gives them +- a self-check runs `src/bend/sample.bend` through the check and the rule + operations: `x` in `def id(x: N) -> N: x` must have the right type, binder, + quantity, uses and span, and each wrapper must report the terms only it sees + +Any mismatch throws a drift error (`name` is `DriftError`, and `e[DRIFT]` is +true, with `DRIFT` from `src/seam.ts`) and loading stops. The tests add more, including +drift against the chosen checkout's own tests. + +## Tests + +```sh +bun test tools/bend-lint +bunx tsc -p tools/bend-lint/tsconfig.json --noEmit +``` + +Bun doesn't type check, so run `tsc` as well (`bun install` in `tools/bend-lint` provides `@types/bun`). +`tsc` reports two errors in `bend2/bend.ts` (lines 2040 and 3771), which are in +Bend itself. diff --git a/tools/bend-lint/package.json b/tools/bend-lint/package.json new file mode 100644 index 0000000000..a41758de8c --- /dev/null +++ b/tools/bend-lint/package.json @@ -0,0 +1,22 @@ +{ + "name": "bend-lint", + "version": "0.1.0", + "description": "Diagnostics for Bend 2 from rules outside the language: source text, and the checker's types", + "private": true, + "type": "module", + "bin": { + "bend-lint": "src/lint.ts" + }, + "exports": "./src/lint.ts", + "scripts": { + "test": "bun test", + "typecheck": "tsc -p tsconfig.json" + }, + "devDependencies": { + "@types/bun": "^1.2.0" + }, + "engines": { + "bun": ">=1.2" + }, + "license": "Apache-2.0" +} diff --git a/tools/bend-lint/rules/file_length.bend b/tools/bend-lint/rules/file_length.bend new file mode 100644 index 0000000000..49c956c8f5 --- /dev/null +++ b/tools/bend-lint/rules/file_length.bend @@ -0,0 +1,46 @@ +# style/file-length: warn when a source file exceeds maxLines (default 500). +# Formatting cannot shorten a file's logical contents; no fix is offered. +import Base +import ../src/bend/lint.bend as Lint +import ./shared.bend as Shared + +def id() -> String: + "style/file-length" + +def facts() -> Lint.Want: + Lint.NoFacts{} + +def finding(long: Bool, path: String, max: U32, count: U32) -> List<&2, Lint.Diag>: + match long: + case True{}: + [ + Lint.Diag{ + Lint.Warning{}, + "Keep the file to " ++ U32.show(max) ++ " lines; it has " ++ U32.show(count) ++ ".", + Some{Lint.Span{path, 0, 0}}, + [] + } + ] + case False{}: + [] + +def check_source(+max: U32, path: String, +count: U32) -> List<&2, Lint.Diag>: + finding(U32.is_gt(count, max), path, max, count) + +def check(+max: U32, source: Maybe<&2, Lint.Source>) -> List<&2, Lint.Diag>: + match source: + case None{}: + [] + case Some{Lint.Source{path, text, root}}: + check_source(max, path, Shared.line_count(text)) + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + match input: + case Lint.Input{sources, options}: + IO.pure( + List<&2, Lint.Diag>, + check(Lint.option_number(options, "maxLines", 500), Shared.root(sources)) + ) + +def main() -> IO(Unit): + Lint.serve(run) diff --git a/tools/bend-lint/rules/file_length.test.ts b/tools/bend-lint/rules/file_length.test.ts new file mode 100644 index 0000000000..b936da9672 --- /dev/null +++ b/tools/bend-lint/rules/file_length.test.ts @@ -0,0 +1,76 @@ +import { afterAll, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { bendRule, lint } from "../src/lint.ts"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-file-length-")); +afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); +const rule = await bendRule(fileURLToPath(new URL("./file_length.bend", import.meta.url))); +const fixture = (text: string, name = "main.bend") => { + const file = path.join(dir, name); + fs.writeFileSync(file, text); + return file; +}; +const config = (maxLines: number) => ({ rules: { "style/file-length": { maxLines } } }); + +test("Bend file length uses a default limit of 500 and offers no fix", async () => { + const file = fixture("import Base\n" + "# padding\n".repeat(498) + "def main() -> U32:\n 1\n"); + const result = await lint(file, [rule]); + expect(result.ok).toBe(true); + expect(result.diags.map((d) => [d.code, d.message, d.fixes])).toEqual([ + ["style/file-length", "Keep the file to 500 lines; it has 501.", []], + ]); + expect((await lint(file, [rule], { config: config(501) })).diags).toEqual([]); +}); + +test("physical line counting handles empty files, LF, CRLF and absent final newlines", async () => { + for (const [text, count] of [ + ["", 0], + ["# one", 1], + ["# one\n", 1], + ["# one\r\n", 1], + ["\n", 1], + ["\r\n", 1], + ["# one\n\n", 2], + ["# one\r\n\r\n", 2], + ] as const) { + const result = await lint(fixture(text), [rule], { config: config(count) }); + expect([result.ok, result.diags]).toEqual([true, []]); + if (count > 0) { + const over = await lint(fixture(text), [rule], { config: config(count - 1) }); + expect(over.diags[0].message).toBe( + "Keep the file to " + (count - 1) + " lines; it has " + count + ".", + ); + } + } +}); + +test("only the root file counts, not its imports", async () => { + fixture( + "import Base\n" + "# dependency\n".repeat(600) + "def value() -> U32:\n 1\n", + "dependency.bend", + ); + const file = fixture( + "import Base\nimport ./dependency.bend as D\ndef main() -> U32:\n D.value()\n", + ); + expect((await lint(file, [rule], { config: config(4) })).diags).toEqual([]); +}); + +test("the Bend rule honors disable and severity configuration", async () => { + const file = fixture("# first\n# second\n"); + expect( + (await lint(file, [rule], { config: { rules: { "style/file-length": "off" } } })).diags, + ).toEqual([]); + const result = await lint(file, [rule], { + config: { rules: { "style/file-length": { maxLines: 1, severity: "hint" } } }, + }); + expect(result.diags.map((d) => d.severity)).toEqual(["hint"]); +}); + +test("the Bend line scanner handles long files without overflowing the JS stack", async () => { + const result = await lint(fixture("# padding\n".repeat(6000)), [rule]); + expect(result.ok).toBe(true); + expect(result.diags[0].message).toBe("Keep the file to 500 lines; it has 6000."); +}); diff --git a/tools/bend-lint/rules/format.test.ts b/tools/bend-lint/rules/format.test.ts new file mode 100644 index 0000000000..7325320eec --- /dev/null +++ b/tools/bend-lint/rules/format.test.ts @@ -0,0 +1,295 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { lint, applyFixes, render } from "../src/lint.ts"; +import type { LintRule } from "../src/lint.ts"; +import { format, formatOptions, rules } from "./format.ts"; +import type { FormatOptions } from "./format.ts"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-format-")); +afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); +const opts = { tabWidth: 2, wrapAtWidth: 100, endOfLine: "lf" } as const; +const fixture = (text: string, name = "main.bend") => { + const file = path.join(dir, name); + fs.writeFileSync(file, text); + return file; +}; +async function fixed(text: string, options: FormatOptions = opts) { + const result = await lint(fixture(text), rules, { + config: { rules: { "format/layout": options } }, + }); + if (!result.ok) throw new Error(result.diags.map(render).join("\n")); + expect(result.ok).toBe(true); + expect(result.diags.every((d) => d.fixes.length === 1)).toBe(true); + const source = result.sources.find((s) => s.root)!; + const output = applyFixes(source, result.diags).text; + expect(format(output, options)).toBe(output); + const again = await lint(fixture(output), rules, { + config: { rules: { "format/layout": options } }, + }); + expect([again.ok, again.diags]).toEqual([true, []]); + const Bend = result.unstable.Bend; + const bodies = [result.unstable.book, again.unstable.book].map((book) => + Object.fromEntries( + book.order.flatMap((name) => { + const tld = book.tlds[name]; + return tld.$ === "Def" && tld.e !== undefined && !tld.b + ? [[name, Bend.term_show(tld.e)]] + : []; + }), + ), + ); + expect(bodies[1]).toEqual(bodies[0]); + return output; +} + +describe("format/layout", () => { + test("ordinary names cannot open delimiter groups", () => { + const source = + "import Base\ndef constructor() -> U32: 1\ndef toString() -> U32: 2\ndef valueOf() -> U32: 3\n"; + expect(format(source, opts)).toBe( + "import Base\n\ndef constructor() -> U32:\n 1\n\ndef toString() -> U32:\n 2\n\ndef valueOf() -> U32:\n 3\n", + ); + }); + test("validates its own defaults, union and unknown options", () => { + expect(formatOptions({})).toEqual(opts); + expect(formatOptions({ wrapAtWidth: "never" })).toEqual({ ...opts, wrapAtWidth: "never" }); + for (const bad of [0, -1, 1.5, NaN, Infinity, "2", true]) { + expect(() => formatOptions({ tabWidth: bad })).toThrow("positive integer"); + expect(() => formatOptions({ wrapAtWidth: bad })).toThrow('"never" or a positive integer'); + } + expect(() => formatOptions({ wrapAtWidth: "always" })).toThrow(); + for (const endOfLine of ["lf", "crlf", "preserve"] as const) { + expect(formatOptions({ endOfLine })).toEqual({ ...opts, endOfLine }); + } + for (const endOfLine of ["auto", "LF", "", 1, true]) { + expect(() => formatOptions({ endOfLine })).toThrow("endOfLine must be"); + } + expect(() => formatOptions({ breakLines: true })).toThrow("no option breakLines"); + }); + + test("one sweep formats spacing, indentation, gaps, CRLF and final newline", async () => { + const output = await fixed( + "import Base\r\n\r\n\r\ntype N is Data:\r\n Z{} \r\n S{p:N}\r\ndef id(x:N)->N:\r\n\tx \r\ndef main()->N: id(S{Z{}})", + ); + expect(output).toBe( + "import Base\n\ntype N is Data:\n Z{}\n S{p: N}\n\ndef id(x: N) -> N:\n x\n\ndef main() -> N:\n id(S{Z{}})\n", + ); + }); + + test("CRLF applies to declarations, wrapping, comments and the final newline", async () => { + const source = + "import Base\n#header\ndef f(first: U32, second: U32) -> U32: first\ndef main() -> U32: f(123456789, 234567890)"; + const options = { ...opts, wrapAtWidth: 20 }; + const lf = await fixed(source, options); + expect(await fixed(source, { ...options, endOfLine: "crlf" })).toBe( + lf.replaceAll("\n", "\r\n"), + ); + }); + + test("preserve uses the first ending in mixed files and falls back to LF", async () => { + const options = { ...opts, endOfLine: "preserve" } as const; + const source = "import Base\ndef main() -> U32: 1\n"; + const lf = await fixed(source); + expect(await fixed(source.replace("\n", "\r\n"), options)).toBe(lf.replaceAll("\n", "\r\n")); + expect(await fixed(source.replace(/\n$/, "\r\n"), options)).toBe(lf); + expect(await fixed("def main() -> Type: Type", options)).toBe("def main() -> Type:\n Type\n"); + }); + + test("line ending options preserve literal contents", () => { + const source = 'def main() -> String:\n "first\r\nsecond\nthird"\n'; + for (const endOfLine of ["lf", "crlf", "preserve"] as const) { + const options = { ...opts, endOfLine }; + const newline = endOfLine === "crlf" ? "\r\n" : "\n"; + const output = format(source, options); + expect(output).toBe( + "def main() -> String:" + newline + ' "first\r\nsecond\nthird"' + newline, + ); + expect(format(output, options)).toBe(output); + } + }); + + test("wrapped lists use one item per line and relative indentation", async () => { + const source = + "import Base\ndef choose(first: U32, second: U32, third: U32) -> U32:\n first\ndef main() -> U32:\n choose(123456789, 234567890, 345678901)\n"; + const output = await fixed(source, { tabWidth: 4, wrapAtWidth: 28 }); + expect(output).toContain( + "def choose(\n first: U32,\n second: U32,\n third: U32\n) -> U32:", + ); + expect(output).toContain( + " choose(\n 123456789,\n 234567890,\n 345678901\n )", + ); + }); + + test('"never" collapses optional wrapping but retains statement boundaries', async () => { + const source = + "import Base\ndef f(x: U32, y: U32) -> U32:\n x\ndef main() -> U32:\n x = f(\n 1,\n 2\n )\n f(x, 3)\n"; + expect(await fixed(source, { tabWidth: 2, wrapAtWidth: "never" })).toContain( + " x = f(1, 2)\n f(x, 3)", + ); + }); + + test("preserves strings, escapes, char literals and comments", async () => { + const source = String.raw`import Base +# header,= and http://example.org +def main() -> String: + "a,b=c # quoted \"" ++ "longer-than-width" # trailing,=comment +`; + const output = await fixed(source, { tabWidth: 2, wrapAtWidth: 15 }); + expect(output).toContain('"a,b=c # quoted \\\""'); + expect(output).toContain("# header,= and http://example.org"); + expect(output).toContain("# trailing,=comment"); + expect(await fixed("import Base\ndef main() -> Char:\n '='\n")).toContain(" '='"); + }); + + test("keeps inline argument comments attached when wrapping", async () => { + const source = + "import Base\ndef f(x: U32, y: U32) -> U32:\n x\ndef main() -> U32:\n f(123, # first\n 456 # second\n )\n"; + const output = await fixed(source, { tabWidth: 2, wrapAtWidth: 20 }); + expect(output).toContain("123, # first\n"); + expect(output).toContain("456 # second\n"); + }); + + test("comment markers get a space, while literals and special markers stay intact", async () => { + const source = String.raw`import Base #import-comment +#header +##Heading +#!directive +#|expectation +# +# already spaced +def apostrophe() -> Char: + '\'' +def main() -> String: + "${String.fromCodePoint(0x1f600)}#inside \"#escaped\"" #tail +`; + const output = await fixed(source.replaceAll("\n", "\r\n")); + expect(output).toContain("import Base # import-comment"); + expect(output).toContain( + "# header\n##Heading\n#!directive\n#|expectation\n#\n# already spaced", + ); + const literal = source + .split("\n") + .find((line) => line.includes("#inside"))! + .split(" #tail")[0] + .trim(); + expect(output).toContain(literal); + expect(output).toContain(String.fromCodePoint(0x1f600)); + expect(output).toContain(" # tail"); + const multiline = + 'import Base\ndef main() -> String:\n "first line\n#inside literal\nlast line" #outside\n'; + expect(await fixed(multiline)).toContain('#inside literal\nlast line" # outside'); + }); + + test("handles nested type arguments, successors, quantities and templates", async () => { + await fixed( + "import Base\ndef identity(~T: Type, x: T) -> T:\n x\ndef main() -> List>:\n identity(~List>, [[1, 2], [3]])\n", + { tabWidth: 2, wrapAtWidth: 25 }, + ); + await fixed( + "import Base\ndef successor(+x: Nat) -> Nat:\n 1n+x\ndef main() -> Nat:\n successor(2n)\n", + ); + await fixed( + "import Base\ntype Box<-Value: Data> is Data:\n Box{value: Value}\ndef main() -> Box:\n Box{1}\n", + { tabWidth: 4, wrapAtWidth: 18 }, + ); + await fixed( + "import Base\ndef main() -> List:\n List.map(~U32, ~U32, ~(x => (x + 123456789 : U32)), [1, 2, 3, 4])\n", + { tabWidth: 4, wrapAtWidth: 30 }, + ); + await fixed( + "import Base\ndef identity(-T: Type, x: T) -> T:\n x\ndef main() -> U32:\n identity(U32 {1 : U32})\n", + ); + }); + + test("preserves nested match, do and lambda body scopes", async () => { + await fixed( + "import Base\ndef f(x: Nat) -> Nat:\n match x:\n case 0n:\n 0n\n case 1n+p:\n p\ndef main() -> Nat:\n f(1n)\n", + { tabWidth: 4, wrapAtWidth: 30 }, + ); + await fixed( + 'import Base\ndef main() -> IO(Unit):\n do IO:\n IO.print("a long message")\n', + { tabWidth: 4, wrapAtWidth: 18 }, + ); + await fixed( + "import Base\ndef main() -> U32:\n f: U32 -> U32 = x => (\n y = x\n y\n )\n f(1)\n", + { tabWidth: 4, wrapAtWidth: 20 }, + ); + }); + + test("law/proof and unsafe syntax are preserved", async () => { + await fixed( + "import Base\nlaw same:\n for x: U32\n {x == x : U32}\ndef same(x):\n {==}\n@unsafe\ndef identity(x: U32) -> U32:\n x\ndef main() -> U32:\n identity(1)\n", + ); + }); + + test("imports and imported law fills survive the parser guard", async () => { + fixture("import Base\nlaw same:\n for x: U32\n {x == x : U32}\n", "LAWS.bend"); + await fixed("import Base\nimport ./LAWS.bend as Laws\ndef Laws.same(x):\n {==}\n", opts); + }); + + test("the guard rejects a changed value or scope", async () => { + const guard: LintRule = { + id: "test/guard", + run: (cx) => { + expect(cx.sameDeclarations("import Base\ndef main() -> U32:\n 2\n")).toBe(false); + expect(cx.sameDeclarations(format(cx.root.text, opts))).toBe(true); + return []; + }, + }; + expect((await lint(fixture("import Base\ndef main() -> U32:\n 1\n"), [guard])).ok).toBe(true); + }); + + test("configuration passes the union through the unchanged engine", async () => { + await fixed("import Base\ndef main() -> U32: 1\n", { tabWidth: 2, wrapAtWidth: "never" }); + await expect( + lint(fixture("import Base\ndef main() -> U32: 1\n"), rules, { + config: { rules: { "format/layout": { wrapAtWidth: 0 } } }, + }), + ).rejects.toThrow("positive integer"); + }); + + test("operator chains wrap at boundaries and stay stable", async () => { + const source = 'import Base\ndef main() -> String:\n "aaaaaaaa" ++ "bbbbbbbb" ++ "cccccccc"\n'; + const output = await fixed(source, { tabWidth: 2, wrapAtWidth: 20 }); + expect(output).toContain(' "aaaaaaaa"\n ++ "bbbbbbbb"\n ++ "cccccccc"'); + expect(await fixed(source, { tabWidth: 2, wrapAtWidth: "never" })).toContain( + ' "aaaaaaaa" ++ "bbbbbbbb" ++ "cccccccc"', + ); + }); + + test("empty files, comments and indivisible tokens have a canonical ending", () => { + expect(format("", opts)).toBe("\n"); + expect(format("\n\n# comment\n\n", opts)).toBe("# comment\n"); + const source = 'import Base\ndef main() -> String:\n "an indivisible literal with spaces"\n'; + const output = format(source, { tabWidth: 1, wrapAtWidth: 1 }); + expect(output).toContain(' "an indivisible literal with spaces"'); + expect(format(output, { tabWidth: 1, wrapAtWidth: 1 })).toBe(output); + }); + + test("CLI --fix writes the same whole-file formatting in one sweep", () => { + const file = fixture("import Base\ndef main()->U32: 1 \n"); + const config = fixture( + JSON.stringify({ rules: { "format/layout": { tabWidth: 4, wrapAtWidth: "never" } } }), + "config.json", + ); + const cli = fileURLToPath(new URL("../src/lint.ts", import.meta.url)); + const rule = fileURLToPath(new URL("./format.ts", import.meta.url)); + const args = [cli, file, "--rules", rule, "--config", config, "--fix", "--json"]; + const result = spawnSync(process.execPath, args, { encoding: "utf8" }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("bend-lint: fixed "); + const text = fs.readFileSync(file, "utf8"); + expect(text).toBe("import Base\n\ndef main() -> U32:\n 1\n"); + const again = spawnSync(process.execPath, args, { encoding: "utf8" }); + expect([again.status, again.stderr, JSON.parse(again.stdout)]).toEqual([ + 0, + "", + { ok: true, findings: [] }, + ]); + expect(fs.readFileSync(file, "utf8")).toBe(text); + }); +}); diff --git a/tools/bend-lint/rules/format.ts b/tools/bend-lint/rules/format.ts new file mode 100644 index 0000000000..ab8b4084eb --- /dev/null +++ b/tools/bend-lint/rules/format.ts @@ -0,0 +1,455 @@ +// One formatter owns indentation, spacing, declaration gaps and wrapping. +// No engine option defaults: wrapAtWidth is a number | "never", and the +// existing engine accepts mixed types when the rule validates its options. +// Run: bun src/lint.ts file.bend --rules rules/format.ts --fix +// Config: { "rules": { "format/layout": { "tabWidth": 2, "wrapAtWidth": 100 } } } +// Both numbers are positive integers; "never" disables optional wrapping. +// endOfLine: "lf" (default), "crlf", or "preserve" (first ending, LF fallback). +import type { Options, LintRule } from "../src/lint.ts"; + +export type FormatOptions = { + tabWidth: number; + wrapAtWidth: number | "never"; + endOfLine?: "lf" | "crlf" | "preserve"; +}; +type Token = { + text: string; + beg: number; + end: number; + col: number; + kind: "code" | "literal" | "comment" | "newline"; +}; +type Node = Token | { open: Token; close: Token; children: Node[] }; +type Doc = + | string + | { kind: "line"; flat: string; hard?: boolean; offset?: number } + | { kind: "group" | "nest"; doc: Doc; amount?: number } + | Doc[]; +type Frame = { doc: Doc; indent: number; flat: boolean }; +const DELIMITERS = new Map([ + ["(", ")"], + ["[", "]"], + ["{", "}"], + ["<", ">"], +]); + +export function formatOptions(options: Options): FormatOptions { + const { tabWidth = 2, wrapAtWidth = 100, endOfLine = "lf" } = options; + const unknown = Object.keys(options).find( + (k) => k !== "tabWidth" && k !== "wrapAtWidth" && k !== "endOfLine", + ); + if (unknown) throw new Error("format/layout has no option " + unknown); + const positive = (v: unknown): v is number => + typeof v === "number" && Number.isSafeInteger(v) && v > 0; + if (!positive(tabWidth)) throw new Error("format/layout: tabWidth must be a positive integer"); + if (wrapAtWidth !== "never" && !positive(wrapAtWidth)) { + throw new Error('format/layout: wrapAtWidth must be "never" or a positive integer'); + } + if (endOfLine !== "lf" && endOfLine !== "crlf" && endOfLine !== "preserve") { + throw new Error('format/layout: endOfLine must be "lf", "crlf" or "preserve"'); + } + return { tabWidth, wrapAtWidth, endOfLine }; +} + +// Preserve literals and comment text verbatim. In particular, a quote or # +// inside a string is not syntax; successor notation and quantities are atoms. +function tokens(source: string): Token[] { + const out: Token[] = []; + let at = 0, + start = 0; + while (at < source.length) { + const beg = at, + c = source[at]; + if (c === " " || c === "\t" || c === "\r") { + at++; + continue; + } + let kind: Token["kind"] = "code"; + if (c === "\n") { + at++; + kind = "newline"; + } else if (c === "#") { + while (at < source.length && source[at] !== "\n" && source[at] !== "\r") at++; + kind = "comment"; + } else if (c === '"' || c === "'") { + at++; + while (at < source.length) { + if (source[at] === "\\") { + at += 2; + continue; + } + if (source[at++] === c) break; + } + kind = "literal"; + } else { + const word = /^[A-Za-z_][\w]*(?:\.[A-Za-z_]\w*)*/.exec(source.slice(at)); + const number = + /^\d+(?:n\+?|\.\d+(?:[eE][+-]?\d+)?)/.exec(source.slice(at)) ?? + /^\d+/.exec(source.slice(at)); + const symbol = + /^(?:<&>|\.&\.|\.\|\.|\.\^\.|->|=>|<-|==|!=|<=|>=|<<|&&|\|\||\+\+|<>|&[012])/.exec( + source.slice(at), + ); + if (source.startsWith("<-", at) && /[\w.]/.test(source[at - 1] ?? "")) at++; + else if (source.startsWith(">>", at) && /\s/.test(source[at - 1] ?? "")) at += 2; + else at += (word?.[0] ?? number?.[0] ?? symbol?.[0] ?? c).length; + } + out.push({ text: source.slice(beg, at), beg, end: at, col: beg - start, kind }); + if (kind === "newline") start = at; + else if (kind === "literal") { + const newline = out[out.length - 1].text.lastIndexOf("\n"); + if (newline >= 0) start = beg + newline + 1; + } + } + return out; +} + +function tree(ts: Token[]): Node[] { + const root: Node[] = []; + const stack: Array<{ children: Node[]; group?: Extract }> = [ + { children: root }, + ]; + for (let i = 0; i < ts.length; i++) { + const t = ts[i], + prev = ts[i - 1]; + const close = DELIMITERS.get(t.text); + // Bend distinguishes type arguments from comparisons by a glued '<'. + const angle = t.text === "<" && prev?.end === t.beg && /^[\w.]+$/.test(prev.text); + const frame = stack[stack.length - 1]; + if (close && (t.text !== "<" || angle)) { + const group = { open: t, close: { ...t, text: close }, children: [] as Node[] }; + frame.children.push(group); + stack.push({ children: group.children, group }); + } else if (frame.group?.close.text === t.text) { + frame.group.close = t; + stack.pop(); + } else frame.children.push(t); + } + if (stack.length !== 1) throw new Error("format/layout: unbalanced delimiters"); + return root; +} + +const first = (n: Node): Token => ("open" in n ? n.open : n); +const last = (n: Node): Token => ("open" in n ? n.close : n); +const soft = (flat = " "): Doc => ({ kind: "line", flat }); +const hard = (offset = 0): Doc => ({ kind: "line", flat: "", hard: true, offset }); +const nest = (doc: Doc, amount: number): Doc => ({ kind: "nest", doc, amount }); +const group = (doc: Doc): Doc => ({ kind: "group", doc }); +const OPERATORS = new Set([ + "+", + "-", + "*", + "/", + "%", + "^", + "++", + "&&", + "||", + "<&>", + ".&.", + ".|.", + ".^.", + "<<", + ">>", + "<>", + "<", + ">", + "<=", + ">=", + "==", + "!=", + "&", + "|", +]); + +function gluedPrefix(a: Token, b: Node | undefined): boolean { + return ["+", "-", "%", "&"].includes(a.text) && b !== undefined && a.end === first(b).beg; +} + +function separator(a: Node, b: Node): string { + const x = last(a), + y = first(b), + l = x.text, + r = y.text; + if (y.kind === "comment") return " "; + if (r === "," || r === ";" || r === ":" || r === "?" || r === "!" || r === ".") return ""; + if (l === "," || l === ";" || l === ":") return " "; + if (["~", "@", "?", "!", "\\"].includes(l)) return ""; + if (gluedPrefix(x, b)) return ""; + if (/n\+$/.test(l)) return ""; + // A constructor brace must be glued to its name; a separated brace can + // instead be the next (annotated) argument in a comma-optional call. + if ("open" in b && r === "{" && x.end !== y.beg) return " "; + if ("open" in b && ["(", "[", "{", "<"].includes(r)) { + if ( + (/^[\w.]+$/.test(l) || l === "!" || [")", "]", "}"].includes(l)) && + !["return", "case", "match", "for", "exs", "is"].includes(l) + ) + return ""; + } + // Module paths are printed separately; other atoms need a separator. + return " "; +} + +function sequence( + nodes: Node[], + source: string, + opts: FormatOptions, + base: number, + raw = false, +): Doc { + const out: Doc[] = []; + const levels = [base]; + let continuation: Doc[] | undefined; + let prev: Node | undefined; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i], + t = first(n); + if (!("open" in n) && t.kind === "newline") { + if (raw || (prev && last(prev).kind === "comment")) { + let next = i + 1; + while (next < nodes.length && first(nodes[next]).kind === "newline") next++; + if (next < nodes.length) { + const col = first(nodes[next]).col; + while (levels.length > 1 && col < levels[levels.length - 1]) levels.pop(); + if (col > levels[levels.length - 1]) levels.push(col); + (continuation ?? out).push(hard((levels.length - 1) * opts.tabWidth)); + } else if (prev && last(prev).kind === "comment") (continuation ?? out).push(hard()); + i = next - 1; + prev = undefined; + } + continue; + } + if (prev) { + const prefix = gluedPrefix(t, nodes[i + 1]); + if (!raw && !("open" in n) && OPERATORS.has(t.text) && !prefix) { + if (!continuation) { + continuation = []; + out.push(nest(continuation, opts.tabWidth)); + } + continuation.push(soft()); + } else (continuation ?? out).push(separator(prev, n)); + } + (continuation ?? out).push(nodeDoc(n, source, opts, base)); + prev = n; + } + return group(out); +} + +function nodeDoc(n: Node, source: string, opts: FormatOptions, base: number): Doc { + if (!("open" in n)) + return n.kind === "newline" ? "" : n.kind === "comment" ? commentText(n.text) : n.text; + const children = n.children; + if (!children.length) return n.open.text + n.close.text; + // Embedded statement bodies have significant line/column boundaries. + // They retain those boundaries, while nested ordinary calls still wrap. + const block = children.some( + (c) => !("open" in c) && ["match", "case", "do", "=", ";", "%", "\\"].includes(c.text), + ); + if (block) return [n.open.text, sequence(children, source, opts, base, true), n.close.text]; + const parts: Node[][] = [[]]; + for (let i = 0; i < children.length; i++) { + const c = children[i]; + if (!("open" in c) && c.text === ",") { + const next = children[i + 1]; + if ( + next && + first(next).kind === "comment" && + !source.slice(c.end, first(next).beg).includes("\n") + ) { + parts[parts.length - 1].push(c, next); + i++; + } + parts.push([]); + } else parts[parts.length - 1].push(c); + } + while (parts.length > 1 && parts[parts.length - 1].every((c) => first(c).kind === "newline")) + parts.pop(); + const content: Doc[] = []; + for (let i = 0; i < parts.length; i++) { + if (i) { + const prev = parts[i - 1].filter((n) => first(n).kind !== "newline"); + if (prev.length && last(prev[prev.length - 1]).kind === "comment") content.push(hard()); + else content.push(",", soft()); + } + content.push(sequence(parts[i], source, opts, base)); + } + const comments = children.some((c) => first(c).kind === "comment"); + // A generic closing '>' must stay glued to its preceding term: a spaced + // '>' is parsed as a comparison by Bend, even across a newline. + const closing: Doc = n.close.text === ">" ? "" : comments ? hard() : soft(""); + return group([ + n.open.text, + nest([comments ? hard() : soft(""), content], opts.tabWidth), + closing, + n.close.text, + ]); +} + +function commentText(text: string): string { + // Keep empty comments, whitespace, test expectations, directives and headings. + return /^#[^\s#!|]/u.test(text) ? "# " + text.slice(1) : text; +} + +// A small document printer: a group is entirely flat if it fits; otherwise +// its list separators break together. Nesting is relative, never alignment +// under a function name. The width is a target, not a license to split atoms. +function print(doc: Doc, width: number, indent: number, newline: string): string { + const stack: Frame[] = [{ doc, indent, flat: false }]; + let out = " ".repeat(indent), + col = indent; + const fits = (remaining: number, pending: Frame[]): boolean => { + while (remaining >= 0 && pending.length) { + const f = pending.pop()!, + d = f.doc; + if (typeof d === "string") { + if (d.includes("\n")) return true; + remaining -= d.length; + } else if (Array.isArray(d)) pending.push(...d.map((doc) => ({ ...f, doc })).reverse()); + else if (d.kind === "line") { + if (d.hard || !f.flat) return true; + remaining -= d.flat.length; + } else pending.push({ doc: d.doc, indent: f.indent + (d.amount ?? 0), flat: true }); + } + return remaining >= 0; + }; + while (stack.length) { + const f = stack.pop()!, + d = f.doc; + if (typeof d === "string") { + out += d; + col = d.includes("\n") ? d.length - d.lastIndexOf("\n") - 1 : col + d.length; + } else if (Array.isArray(d)) stack.push(...d.map((doc) => ({ ...f, doc })).reverse()); + else if (d.kind === "line") { + if (!d.hard && (f.flat || width === Infinity)) { + out += d.flat; + col += d.flat.length; + } else { + col = f.indent + (d.offset ?? 0); + out = out.trimEnd() + newline + " ".repeat(col); + } + } else if (d.kind === "nest") + stack.push({ doc: d.doc, indent: f.indent + d.amount!, flat: f.flat }); + else + stack.push({ + doc: d.doc, + indent: f.indent, + flat: + f.flat || + width === Infinity || + fits(width - col, [...stack, { doc: d.doc, indent: f.indent, flat: true }]), + }); + } + return out.trimEnd(); +} + +export function format(source: string, opts: FormatOptions): string { + const newline = + opts.endOfLine === "preserve" + ? (/\r?\n/.exec(source)?.[0] ?? "\n") + : opts.endOfLine === "crlf" + ? "\r\n" + : "\n"; + const nodes = tree(tokens(source)); + const records: Node[][] = [[]]; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + if (!("open" in n) && n.kind === "newline") { + const next = nodes[i + 1], + following = nodes[i + 2]; + const prefix = next && gluedPrefix(first(next), following); + const previous = records[records.length - 1].at(-1); + if ( + next && + previous && + last(previous).kind !== "comment" && + first(next).kind !== "comment" && + ((!prefix && !("open" in next) && OPERATORS.has(next.text)) || + ["->", "=", "++", "&&", "||"].includes(last(previous).text)) + ) + continue; + records.push([]); + } else records[records.length - 1].push(n); + } + const rows: string[] = [], + levels = [0]; + let blank = false, + previous = "", + previousComment = false; + for (const record of records) { + if (!record.length) { + blank = true; + continue; + } + const head = first(record[0]), + col = head.col; + while (levels.length > 1 && col < levels[levels.length - 1]) levels.pop(); + if (col > levels[levels.length - 1]) levels.push(col); + const indent = (levels.length - 1) * opts.tabWidth; + const declaration = /^(def|type|law)$/.test(head.text) || head.text === "@"; + const comment = head.kind === "comment"; + if ( + rows.length && + col === 0 && + ((declaration && !previousComment && previous !== "@unsafe") || (comment && blank)) && + rows[rows.length - 1] !== "" + ) + rows.push(""); + else if (rows.length && blank && col > 0 && rows[rows.length - 1] !== "") rows.push(""); + let doc: Doc; + if (head.text === "import") { + // Paths and aliases use their own grammar; do not treat / or - as operators. + const text = source.slice(head.beg, last(record[record.length - 1]).end).trimEnd(); + const at = text.indexOf("#"); + doc = (at < 0 ? text : text.slice(0, at)) + .trim() + .replace(/^import\s+/, "import ") + .replace(/\s+as\s+/, " as "); + if (at >= 0) doc = [doc, " ", commentText(text.slice(at))]; + } else { + // Canonicalize an inline declaration body into the indented body form. + const colon = + col === 0 && declaration ? record.findIndex((n) => !("open" in n) && n.text === ":") : -1; + if (colon >= 0 && colon < record.length - 1 && first(record[colon + 1]).kind !== "comment") { + doc = [ + sequence(record.slice(0, colon + 1), source, opts, col), + nest([hard(), sequence(record.slice(colon + 1), source, opts, col)], opts.tabWidth), + ]; + } else doc = sequence(record, source, opts, col); + } + rows.push( + print(doc, opts.wrapAtWidth === "never" ? Infinity : opts.wrapAtWidth, indent, newline), + ); + previous = record.map((n) => first(n).text).join(""); + previousComment = comment; + blank = false; + } + return rows.join(newline).trimEnd() + newline; +} + +export const rules: LintRule[] = [ + { + id: "format/layout", + run(cx) { + const opts = formatOptions(cx.options); + const text = format(cx.root.text, opts); + if (text === cx.root.text) return []; + const span = { file: cx.root, beg: 0, end: cx.root.text.length }; + if (!cx.sameDeclarations(text)) + return [ + cx.diag({ + message: + "Cannot safely format this syntax: the result parses differently. No fix was offered.", + span, + }), + ]; + return [ + cx.diag({ + message: "Apply canonical formatting.", + span, + fixes: [{ title: "Format file", applicability: "safe", edits: [{ span, text }] }], + }), + ]; + }, + }, +]; diff --git a/tools/bend-lint/rules/shared.bend b/tools/bend-lint/rules/shared.bend new file mode 100644 index 0000000000..6496509baa --- /dev/null +++ b/tools/bend-lint/rules/shared.bend @@ -0,0 +1,43 @@ +# Helpers for the rules in this folder that are written in Bend. +import Base +import ../src/bend/lint.bend as Lint + +def root_or(root: Bool, s: Lint.Source, later: Maybe<&2, Lint.Source>) -> Maybe<&2, Lint.Source>: + match root: + case True{}: + Some{s} + case False{}: + later + +def is_root(s: Lint.Source) -> Bool: + match s: + case Lint.Source{path, text, root}: + root + +# The source of the file linted. +def root(srcs: List<&2, Lint.Source>) -> Maybe<&2, Lint.Source>: + match srcs: + case Nil{}: + None{} + case Con{+s, rest}: + root_or(is_root(s), s, root(rest)) + +# Count physical lines in one tail-recursive scan, without allocating a +# line list. An empty file has zero lines; a final newline adds no sentinel. +def line_count.go(s: String, count: U32, last: Bool) -> U32: + match s: + case SNil{}: + match last: + case True{}: + count + case False{}: + U32.add(count, 1) + case SCon{c, rest}: + match c: + case '\n': + line_count.go(rest, U32.add(count, 1), True{}) + case _: + line_count.go(rest, count, False{}) + +def line_count(text: String) -> U32: + line_count.go(text, 0, True{}) diff --git a/tools/bend-lint/src/bend/effects.js b/tools/bend-lint/src/bend/effects.js new file mode 100644 index 0000000000..57ad38dc02 --- /dev/null +++ b/tools/bend-lint/src/bend/effects.js @@ -0,0 +1,150 @@ +// The effects of lint.bend. bend-lint puts its channel on +// globalThis.BEND_LINT before it runs a rule, and removes it after. Bool, +// String and U32 are native JS values here; other data is {$: CID(Name), ...}. + +const LINT_SEVERITY = { + [CID(Error)]: "error", + [CID(Warning)]: "warning", + [CID(Information)]: "information", + [CID(Hint)]: "hint", +}; +const LINT_QUANTITY = { erased: CID(Erased), once: CID(Once), many: CID(Many) }; +const LINT_APPLICABILITY = { + [CID(Safe)]: "safe", + [CID(Suggested)]: "suggested", + [CID(Dangerous)]: "dangerous", +}; + +function lint_host() { + const host = globalThis.BEND_LINT; + if (host === undefined) { + throw new Error( + "this program is a rule; run it with bun tools/bend-lint/src/lint.ts --rules ", + ); + } + return host; +} + +function lint_list(xs) { + return xs.reduceRight((tail, head) => ({ $: CID(Con), head, tail }), { $: CID(Nil) }); +} + +function lint_unlist(xs) { + const out = []; + for (; xs.$ === CID(Con); xs = xs.tail) { + out.push(xs.head); + } + return out; +} + +function lint_maybe(x) { + return x === undefined ? { $: CID(None) } : { $: CID(Some), value: x }; +} + +function lint_span(s) { + return { $: CID(Span), path: s.path, beg: s.beg, end: s.end }; +} + +function lint_spot(s) { + return { path: s.path, beg: s.beg, end: s.end }; +} + +function lint_value(v) { + return typeof v === "number" + ? { $: CID(Num), value: v } + : typeof v === "boolean" + ? { $: CID(Flag), value: v } + : { $: CID(Text), value: v }; +} + +function lint_term(id) { + return { $: CID(Term), id }; +} + +function lint_node(id) { + return { $: CID(Node), id }; +} + +function lint_fact(id) { + return lint_maybe(id === undefined ? undefined : { $: CID(Fact), id }); +} + +function lint_input() { + const { sources, options } = lint_host().input(); + return { + $: CID(Input), + sources: lint_list( + sources.map((s) => ({ $: CID(Source), path: s.path, text: s.text, root: s.root })), + ), + options: lint_list( + Object.entries(options).map(([key, v]) => ({ $: CID(Option), key, value: lint_value(v) })), + ), + }; +} + +function lint_report(diags) { + lint_host().report( + lint_unlist(diags).map((d) => ({ + severity: LINT_SEVERITY[d.severity.$], + message: d.message, + span: d.span.$ === CID(Some) ? lint_spot(d.span.value) : undefined, + fixes: lint_unlist(d.fixes).map((f) => ({ + title: f.title, + applicability: LINT_APPLICABILITY[f.applicability.$], + edits: lint_unlist(f.edits).map((e) => ({ span: lint_spot(e.span), text: e.text })), + })), + })), + ); + return { $: CID(Unit) }; +} + +function lint_view(fact) { + const v = lint_host().view(fact.id); + return { + $: CID(View), + owner: v.owner, + inst: v.inst, + kind: v.kind, + name: v.name, + quantity: { $: LINT_QUANTITY[v.quantity] }, + span: lint_maybe(v.span && lint_span(v.span)), + inner: lint_maybe(v.inner && lint_span(v.inner)), + }; +} + +io_eff(CID(input), lint_input); +io_eff(CID(report), lint_report); +io_eff(CID(next_fact), () => lint_fact(lint_host().next())); +io_eff(CID(view), lint_view); +io_eff(CID(type_of), (fact) => lint_term(lint_host().type(fact.id))); +io_eff(CID(binder), (fact) => { + const id = lint_host().binder(fact.id); + return lint_maybe(id === undefined ? undefined : lint_term(id)); +}); +io_eff(CID(same), (fact, a, b) => lint_host().same(fact.id, a.id, b.id)); +io_eff(CID(show), (fact, t) => lint_host().show(fact.id, t.id)); +io_eff(CID(normal), (fact, t) => lint_term(lint_host().normal(fact.id, t.id))); +io_eff(CID(uses), (fact) => + lint_list( + lint_host() + .uses(fact.id) + .map((u) => ({ $: CID(Use), name: u.name, quantity: { $: LINT_QUANTITY[u.quantity] } })), + ), +); +io_eff(CID(text), (span) => lint_host().text(lint_spot(span))); +io_eff(CID(body), (name) => { + const id = lint_host().body(name); + return lint_maybe(id === undefined ? undefined : lint_node(id)); +}); +io_eff(CID(node), (fact) => lint_node(lint_host().node(fact.id))); +io_eff(CID(shape), (n) => { + const s = lint_host().shape(n.id); + return { + $: CID(Shape), + kind: s.kind, + name: s.name, + span: lint_maybe(s.span && lint_span(s.span)), + children: lint_list(s.children.map(lint_node)), + }; +}); +io_eff(CID(fact), (n) => lint_fact(lint_host().fact(n.id))); diff --git a/tools/bend-lint/src/bend/lint.bend b/tools/bend-lint/src/bend/lint.bend new file mode 100644 index 0000000000..13674ca770 --- /dev/null +++ b/tools/bend-lint/src/bend/lint.bend @@ -0,0 +1,233 @@ +# The contract between bend-lint and a rule written in Bend. A rule file +# imports this file and defines: +# +# def id() -> String: # namespace/name, the code of its findings +# def facts() -> Lint.Want: # Lint.NoFacts{}, or the facts it needs +# def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): +# def main() -> IO(Unit): +# Lint.serve(run) +# +# A rule with facts reads them one at a time, with fold_facts or +# next_fact. Offsets count characters (code points) from the start of a +# file. The rule's options come from bend-lint.json; read them with +# option_number, option_flag and option_text, each with a default. +import Base + +# A checked term, a type, and a node of a checked body: indexes into +# bend-lint's tables for one run. Only the effects below read them. +type Fact is Data: + Fact{id: U32} + +type Term is Data: + Term{id: U32} + +type Node is Data: + Node{id: U32} + +type Source is Data: + Source{path: String, text: String, root: Bool} + +# An option's value: a whole number (a U32), a boolean, or text. +type Value is Data: + Num{value: U32} + Flag{value: Bool} + Text{value: String} + +type Option is Data: + Option{key: String, value: Value} + +type Input is Data: + Input{sources: List<&2, Source>, options: List<&2, Option>} + +# File: facts of the linted file. Program: of its imports too, never Base. +type Scope is Data: + File{} + Program{} + +# The facts a rule needs. A fact must match each list; an empty list +# matches all. kinds: the term's kind, annotations stripped (Var, Ref, App, +# ...). defs: the def whose body holds the term (a template's instances +# count as the template). names: the name a Var or Ref points to. +type Want is Data: + NoFacts{} + Want{scope: Scope, kinds: List<&2, String>, defs: List<&2, String>, names: List<&2, String>} + +type Span is Data: + Span{path: String, beg: U32, end: U32} + +type Severity is Data: + Error{} + Warning{} + Information{} + Hint{} + +# Safe keeps behavior; Suggested may change it; Dangerous may break code. +type Applicability is Data: + Safe{} + Suggested{} + Dangerous{} + +type Edit is Data: + Edit{span: Span, text: String} + +type Fix is Data: + Fix{title: String, applicability: Applicability, edits: List<&2, Edit>} + +type Diag is Data: + Diag{severity: Severity, message: String, span: Maybe<&2, Span>, fixes: List<&2, Fix>} + +# How many times a term may be used. +type Quantity is Data: + Erased{} + Once{} + Many{} + +type Use is Data: + Use{name: String, quantity: Quantity} + +# A fact as bend-lint sees it: the def it is in, whether that def is a +# template instance, the kind of its term once an annotation is stripped +# (Var, App, ...), the name a Var or Ref points to (else ""), how many +# times the term is demanded, and the spans of the checked term and of the +# stripped one. +type View is Data: + View{owner: String, inst: Bool, kind: String, name: String, quantity: Quantity, span: Maybe<&2, Span>, inner: Maybe<&2, Span>} + +# A node: its kind, annotations kept (Ann, Var, App, ...), the name a Var +# or Ref points to (else ""), its span, and its children, in bend's order. +type Shape is Data: + Shape{kind: String, name: String, span: Maybe<&2, Span>, children: List<&2, Node>} + +def input() -> IO(Input): + import "./effects.js" + +def report(diags: List<&2, Diag>) -> IO(Unit): + import "./effects.js" + +# The next fact the rule asked for, or None{} when none are left. +def next_fact() -> IO(Maybe<&2, Fact>): + import "./effects.js" + +# One step of fold_facts: stop at None{}, else run step and go on. +def fold_next(~A: Type, ~step: A -> @+f: Fact -> IO(A), m: Maybe<&2, Fact>, acc: A, again: A -> IO(A)) -> IO(A): + match m: + case None{}: + IO.pure(A, acc) + case Some{f}: + IO.bind(A, A, step(acc, f), again) + +# Runs step on each fact the rule asked for, in order, from acc; step is +# a def, given as ~name, that takes its fact as +f. Facts come one at a +# time, so a big file does not build one big list. +@unsafe +def fold_facts(~A: Type, ~step: A -> @+f: Fact -> IO(A), acc: A) -> IO(A): + do IO: + m : Maybe<&2, Fact> <- next_fact() + fold_next(~A, ~step, m, acc, a => fold_facts(~A, ~step, a)) + +def view(fact: Fact) -> IO(View): + import "./effects.js" + +# The type the fact's term was checked against, or inferred. +def type_of(fact: Fact) -> IO(Term): + import "./effects.js" + +# The type of the binder a Var fact refers to. +def binder(fact: Fact) -> IO(Maybe<&2, Term>): + import "./effects.js" + +# Whether two types are equal, as the checker compares them in the fact's +# scope. +def same(fact: Fact, a: Term, b: Term) -> IO(Bool): + import "./effects.js" + +def show(fact: Fact, t: Term) -> IO(String): + import "./effects.js" + +# The normal form of a type, in the fact's scope. +def normal(fact: Fact, t: Term) -> IO(Term): + import "./effects.js" + +# The variables the fact's term uses, each with how many times. +def uses(fact: Fact) -> IO(List<&2, Use>): + import "./effects.js" + +# The text under a span, in the file on disk. +def text(span: Span) -> IO(String): + import "./effects.js" + +# A def's checked body, if it has one. +def body(name: String) -> IO(Maybe<&2, Node>): + import "./effects.js" + +# The node a fact is about. +def node(fact: Fact) -> IO(Node): + import "./effects.js" + +def shape(n: Node) -> IO(Shape): + import "./effects.js" + +# The node's fact, if the rule asked for it. +def fact(n: Node) -> IO(Maybe<&2, Fact>): + import "./effects.js" + +def pick(hit: Bool, v: Value, later: Maybe<&2, Value>) -> Maybe<&2, Value>: + match hit: + case True{}: + Some{v} + case False{}: + later + +# The value of an option, if it is given. +def find(opts: List<&2, Option>, +key: String) -> Maybe<&2, Value>: + match opts: + case Nil{}: + None{} + case Con{Option{k, v}, rest}: + pick(String.eq(k, key), v, find(rest, key)) + +def option_number_or(m: Maybe<&2, Value>, default: U32) -> U32: + match m: + case Some{Num{n}}: + n + case Some{Flag{b}}: + default + case Some{Text{t}}: + default + case None{}: + default + +# An option's value, or `default` when it is not given or of another type. +def option_number(opts: List<&2, Option>, key: String, default: U32) -> U32: + option_number_or(find(opts, key), default) + +def option_flag_or(m: Maybe<&2, Value>, default: Bool) -> Bool: + match m: + case Some{Flag{b}}: + b + case Some{Num{n}}: + default + case Some{Text{t}}: + default + case None{}: + default + +def option_flag(opts: List<&2, Option>, key: String, default: Bool) -> Bool: + option_flag_or(find(opts, key), default) + +def option_text_or(m: Maybe<&2, Value>, default: String) -> String: + match m: + case Some{Text{t}}: + t + case Some{Num{n}}: + default + case Some{Flag{b}}: + default + case None{}: + default + +def option_text(opts: List<&2, Option>, key: String, default: String) -> String: + option_text_or(find(opts, key), default) + +def serve(run: Input -> IO(List<&2, Diag>)) -> IO(Unit): + IO.bind(Input, Unit, input(), i => IO.bind(List<&2, Diag>, Unit, run(i), report)) diff --git a/tools/bend-lint/src/bend/sample.bend b/tools/bend-lint/src/bend/sample.bend new file mode 100644 index 0000000000..6f409a6469 --- /dev/null +++ b/tools/bend-lint/src/bend/sample.bend @@ -0,0 +1,8 @@ +type N is Data: + Z{} + +def id(x: N) -> N: + x + +def main() -> N: + id(Z{}) diff --git a/tools/bend-lint/src/lint.test.ts b/tools/bend-lint/src/lint.test.ts new file mode 100644 index 0000000000..52cfc814d9 --- /dev/null +++ b/tools/bend-lint/src/lint.test.ts @@ -0,0 +1,2004 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import type { Book, LTerm, Span as BendSpan } from "bend2/bend.ts"; +import type * as BendModule from "bend2/bend.ts"; +import { BEND2, applyFixes, bendRule, findConfig, lint, readConfig, render } from "./lint.ts"; +import type { Diag, Edit, Fact, LintRule, Node, RuleContext, Source } from "./lint.ts"; +import { + DRIFT, + bendDir, + fetchBend, + guardMain, + installedTag, + latestTag, + mapper, + patch, + relative, + resolve, + seeCheck, + seeInfer, + walk, +} from "./seam.ts"; +import type { Raw } from "./seam.ts"; + +// Types +// ===== + +type Loose = Raw & { tm: { x?: { $: string; k?: string; i?: number } } }; +type Seen = { kind: string; def: string; name: string; path: string }; + +// Constants +// ========= + +// bend2's modules as bend-lint patched them, for what the tests inspect. +const Bend: typeof BendModule = await import(pathToFileURL(path.join(BEND2, "bend.ts")).href); +const Comp: Record = await import(pathToFileURL(path.join(BEND2, "comp.ts")).href); + +const USERLAND = String.raw`type N is Data: + Z{} + S{p: N} + +def Alias() -> Type: + N + +def id(x: Alias()) -> Alias(): + x + +def generic(~T: Type, x: T) -> T: + x + +def proof(-x: N) -> {x == x : N}: + {==} + +def peel(x: N) -> N: + match x: + case Z{}: + Z{} + case S{p}: + p + +def main() -> N: + a = id(S{Z{}}) + generic(~N,a) +`; + +const ERASURE = String.raw`type N is Data: + Z{} + S{p: N} + +def Alias() -> Type: + N + +def direct(x: N) -> N: + copy : N = x + copy + +def alias(x: N) -> N: + copy : Alias() = x + copy + +def generic(~T: Type, x: T) -> T: + copy : T = x + copy + +def dependent(-x: N, p: {x == x : N}) -> {x == x : N}: + copy : {x == x : N} = p + copy + +def required_lambda(x: N) -> N: + f : N -> N = y => y + f(x) + +def required_constructor() -> N: + value : N = Z{} + value + +def main() -> N: + direct(alias(generic(~N, required_lambda(required_constructor())))) +`; + +// Tests in the bend checkout (bend2/../tests) whose first expected line +// says if the check passes. +const BEND_TESTS = [ + "check/alpha_equivalence", + "check/assert_plain_fill", + "check/dependent_telescope", + "check/beta_ann_body", + "check/ctor_arity", + "check/forward_reference", + "check/hole_todo", + "check/typed_let_mismatch", + "import/base_prelude", + "import/string_literal", + "import/alias_shadow", + "import/duplicate_name", +]; + +// Inside the repo, so a Bend rule here can import ../../bend/lint.bend; +// .tmp/ is ignored by git. +const TMP = fileURLToPath(new URL("./.tmp", import.meta.url)); +const DIR = (fs.mkdirSync(TMP, { recursive: true }), fs.mkdtempSync(path.join(TMP, "run-"))); +const CLI = fileURLToPath(new URL("./lint.ts", import.meta.url)); + +// The comma-space rule, written in Bend: source text only. +const COMMA_BEND = String.raw`import Base +import ../../bend/lint.bend as Lint + +def id() -> String: + "style/comma-space" + +def facts() -> Lint.Want: + Lint.NoFacts{} + +def is_word(+c: Char) -> Bool: + Bool.or(Char.is_alpha(c), Bool.or(Char.is_digit(c), Char.is_eq(c, '_'))) + +def comma_before_word(+c: Char, rest: String) -> Bool: + match rest: + case SNil{}: + False{} + case SCon{n, t}: + Bool.and(Char.is_eq(c, ','), is_word(n)) + +def keep(hit: Bool, +at: U32, tail: List<&2, U32>) -> List<&2, U32>: + match hit: + case True{}: + U32.add(at, 1) <> tail + case False{}: + tail + +# A tail call: bend runs it as a loop, so a long file does not overflow the +# stack. +def commas(s: String, +at: U32, acc: List<&2, U32>) -> List<&2, U32>: + match s: + case SNil{}: + acc + case SCon{+c, +rest}: + commas(rest, U32.add(at, 1), keep(comma_before_word(c, rest), at, acc)) + +def diag_at(+span: Lint.Span) -> Lint.Diag: + Lint.Diag{Lint.Warning{}, "Add a space after the comma.", Some{span}, [Lint.Fix{"Insert space", Lint.Safe{}, [Lint.Edit{span, " "}]}]} + +def diag(path: String, +at: U32) -> Lint.Diag: + diag_at(Lint.Span{path, at, at}) + +def diags(+path: String, ats: List<&2, U32>) -> List<&2, Lint.Diag>: + match ats: + case Nil{}: + [] + case Con{+at, rest}: + diag(path, at) <> diags(path, rest) + +def choose(root: Bool, +path: String, text: String, others: List<&2, Lint.Diag>) -> List<&2, Lint.Diag>: + match root: + case True{}: + diags(path, commas(text, 0, [])) + case False{}: + others + +def pick(s: Lint.Source, others: List<&2, Lint.Diag>) -> List<&2, Lint.Diag>: + match s: + case Lint.Source{+path, text, root}: + choose(root, path, text, others) + +def all(srcs: List<&2, Lint.Source>) -> List<&2, Lint.Diag>: + match srcs: + case Nil{}: + [] + case Con{s, rest}: + pick(s, all(rest)) + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + match input: + case Lint.Input{sources, options}: + IO.pure(List<&2, Lint.Diag>, all(sources)) + +def main() -> IO(Unit): + Lint.serve(run) +`; + +// A typed rule written in Bend: for each Var in def id, its type and normal +// form, whether the checker finds it equal to its binder's, its text, how +// many times it is demanded, and what it uses. +const TYPES_BEND = String.raw`import Base +import ../../bend/lint.bend as Lint + +def id() -> String: + "test/types" + +def facts() -> Lint.Want: + Lint.Want{Lint.File{}, ["Var"], ["id"], []} + +def verdict(same: Bool) -> String: + match same: + case True{}: + " (same as its binder)" + case False{}: + " (unlike its binder)" + +def compare(f: Lint.Fact, b: Maybe<&2, Lint.Term>, t: Lint.Term) -> IO(Bool): + match b: + case None{}: + IO.pure(Bool, False{}) + case Some{x}: + Lint.same(f, x, t) + +def quantity(q: Lint.Quantity) -> String: + match q: + case Lint.Erased{}: + "erased" + case Lint.Once{}: + "once" + case Lint.Many{}: + "many" + +def use_text(u: Lint.Use) -> String: + match u: + case Lint.Use{name, q}: + name ++ " " ++ quantity(q) + +def first_use(us: List<&2, Lint.Use>) -> String: + match us: + case Nil{}: + "nothing" + case Con{u, rest}: + use_text(u) + +def text_of(inner: Maybe<&2, Lint.Span>) -> IO(String): + match inner: + case None{}: + IO.pure(String, "") + case Some{s}: + Lint.text(s) + +def describe(+f: Lint.Fact, name: String, q: Lint.Quantity, span: Maybe<&2, Lint.Span>, inner: Maybe<&2, Lint.Span>) -> IO(Maybe<&2, Lint.Diag>): + do IO>: + t : Lint.Term <- Lint.type_of(f) + shown : String <- Lint.show(f, t) + t2 : Lint.Term <- Lint.type_of(f) + n : Lint.Term <- Lint.normal(f, t2) + nf : String <- Lint.show(f, n) + u : Lint.Term <- Lint.type_of(f) + b : Maybe<&2, Lint.Term> <- Lint.binder(f) + same : Bool <- compare(f, b, u) + here : String <- text_of(inner) + us : List<&2, Lint.Use> <- Lint.uses(f) + return Some{Lint.Diag{Lint.Hint{}, name ++ ": " ++ shown ++ " = " ++ nf ++ verdict(same) ++ ", text " ++ here ++ ", demanded " ++ quantity(q) ++ ", uses " ++ first_use(us), span, []}} + +def wanted(hit: Bool, +f: Lint.Fact, name: String, q: Lint.Quantity, span: Maybe<&2, Lint.Span>, inner: Maybe<&2, Lint.Span>) -> IO(Maybe<&2, Lint.Diag>): + match hit: + case True{}: + describe(f, name, q, span, inner) + case False{}: + IO.pure(Maybe<&2, Lint.Diag>, None{}) + +def fact_diag(+f: Lint.Fact, v: Lint.View) -> IO(Maybe<&2, Lint.Diag>): + match v: + case Lint.View{owner, inst, kind, name, q, span, inner}: + wanted(Bool.and(String.eq(owner, "id"), Bool.and(String.eq(kind, "Var"), Bool.not(inst))), f, name, q, span, inner) + +def prepend(m: Maybe<&2, Lint.Diag>, xs: List<&2, Lint.Diag>) -> List<&2, Lint.Diag>: + match m: + case None{}: + xs + case Some{d}: + d <> xs + +def step(found: List<&2, Lint.Diag>, +f: Lint.Fact) -> IO(List<&2, Lint.Diag>): + do IO>: + v : Lint.View <- Lint.view(f) + here : Maybe<&2, Lint.Diag> <- fact_diag(f, v) + return prepend(here, found) + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + Lint.fold_facts(~List<&2, Lint.Diag>, ~step, []) + +def main() -> IO(Unit): + Lint.serve(run) +`; + +const commaSpace: LintRule = { + id: "style/comma-space", + run: (cx) => + [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { + const span = { file: cx.root, beg: m.index! + 1, end: m.index! + 1 }; + return cx.diag({ + message: "Add a space after the comma.", + severity: "warning", + span, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ span, text: " " }] }], + }); + }), +}; + +const identity: LintRule = { + id: "example/identity", + facts: true, + run: async (cx, signal) => { + await Promise.resolve(); + signal.throwIfAborted(); + const body = facts(cx, "id").find( + (f) => loose(cx, f).dep === 1 && loose(cx, f).tm.x?.$ === "Var", + )!; + const raw = loose(cx, body); + const ann = cx.unstable.Bend.pmap_get(raw.ctx, raw.tm.x!.i!)!; + expect(ann.k).toBe("x"); + expect((cx.unstable.Bend.term_wnf(raw.bok, raw.ty) as { k?: string }).k).toBe("N"); + expect(cx.same(body, cx.type(body), cx.binder(body)!)).toBe(true); + return [ + cx.diag({ + message: "This function returns its parameter.", + severity: "information", + span: cx.view(body).span, + fact: body, + }), + ]; + }, +}; + +const neverRun: LintRule = { + id: "test/never-run", + facts: true, + run: () => { + throw new Error("this rule must not run"); + }, +}; + +// x : T = v, where v is a variable that already has type T. Constructors +// and lambdas keep theirs: checking needs the expected type. Template +// instances repeat the facts of the def as written, so they are skipped. +const redundantAnnotation: LintRule = { + id: "erasure/redundant-local-annotation", + facts: true, + run: (cx) => + cx.facts!.flatMap((fact): Diag[] => { + const { inst, kind, name: used, span, inner } = cx.view(fact); + const declared = cx.binder(fact); + if ( + inst || + kind !== "Var" || + span === undefined || + inner === undefined || + declared === undefined || + !cx.same(fact, declared, cx.type(fact)) + ) { + return []; + } + const prefix = span.file.text.slice(span.beg, inner.beg); + const name = prefix.match(/^([A-Za-z_][A-Za-z_0-9]*)\s*:/); + if ( + span.file !== inner.file || + span.beg >= inner.beg || + prefix.includes("#") || + name === null || + !prefix.trimEnd().endsWith("=") + ) { + return []; + } + const at = { + file: span.file, + beg: span.beg + name[1].length, + end: span.beg + prefix.lastIndexOf("="), + }; + return [ + cx.diag({ + message: + "Remove the redundant annotation: " + + used + + " already has type " + + cx.show(fact, declared) + + ".", + severity: "hint", + span: at, + fact, + fixes: [ + { + title: "Remove redundant local type annotation", + applicability: "suggested", + edits: [{ span: at, text: " " }], + }, + ], + }), + ]; + }), +}; + +// A Bend rule that reports its options, read with defaults. +const OPTIONS_BEND = String.raw`import Base +import ../../bend/lint.bend as Lint + +def id() -> String: + "test/options" + +def facts() -> Lint.Want: + Lint.NoFacts{} + +def summary(+opts: List<&2, Lint.Option>) -> String: + U32.show(Lint.option_number(opts, "width", 2)) ++ " " ++ Bool.show(Lint.option_flag(opts, "wrap", False{})) ++ " " ++ Lint.option_text(opts, "name", "none") + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + match input: + case Lint.Input{sources, +options}: + IO.pure(List<&2, Lint.Diag>, [Lint.Diag{Lint.Hint{}, summary(options), None{}, []}]) + +def main() -> IO(Unit): + Lint.serve(run) +`; + +// A Bend rule that counts the facts it pulls, from the linted file and its +// imports: the Vars. +const COUNT_BEND = String.raw`import Base +import ../../bend/lint.bend as Lint + +def id() -> String: + "test/count" + +def facts() -> Lint.Want: + Lint.Want{Lint.Program{}, ["Var"], [], []} + +def add(n: U32, +f: Lint.Fact) -> IO(U32): + IO.pure(U32, U32.add(n, 1)) + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + do IO>: + n : U32 <- Lint.fold_facts(~U32, ~add, 0) + return [Lint.Diag{Lint.Hint{}, U32.show(n), None{}, []}] + +def main() -> IO(Unit): + Lint.serve(run) +`; + +// A Bend rule that reads the term view: the root of id's body and its +// first child, a Var fact's node, its first child and its fact back, and +// a def that does not exist. +const TREE_BEND = String.raw`import Base +import ../../bend/lint.bend as Lint + +def id() -> String: + "test/tree" + +def facts() -> Lint.Want: + Lint.Want{Lint.File{}, ["Var"], ["id"], []} + +def kind_of(s: Lint.Shape) -> String: + match s: + case Lint.Shape{kind, name, span, children}: + kind + +def first(s: Lint.Shape) -> Maybe<&2, Lint.Node>: + match s: + case Lint.Shape{kind, name, span, children}: + match children: + case Nil{}: + None{} + case Con{c, rest}: + Some{c} + +def child_kind(m: Maybe<&2, Lint.Node>) -> IO(String): + match m: + case None{}: + IO.pure(String, "nothing") + case Some{n}: + do IO: + +s : Lint.Shape <- Lint.shape(n) + return kind_of(s) + +def top(m: Maybe<&2, Lint.Node>) -> IO(String): + match m: + case None{}: + IO.pure(String, "no body") + case Some{n}: + do IO: + +s : Lint.Shape <- Lint.shape(n) + c : String <- child_kind(first(s)) + return kind_of(s) ++ " > " ++ c + +def found(m: Maybe<&2, Lint.Fact>) -> String: + match m: + case None{}: + "lost" + case Some{f}: + "found" + +def about(+f: Lint.Fact) -> IO(String): + do IO: + +n : Lint.Node <- Lint.node(f) + +s : Lint.Shape <- Lint.shape(n) + g : Maybe<&2, Lint.Fact> <- Lint.fact(n) + c : String <- child_kind(first(s)) + return kind_of(s) ++ " > " ++ c ++ " " ++ found(g) + +def back(m: Maybe<&2, Lint.Fact>) -> IO(String): + match m: + case None{}: + IO.pure(String, "no fact") + case Some{f}: + about(f) + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + do IO>: + b : Maybe<&2, Lint.Node> <- Lint.body("id") + t : String <- top(b) + m : Maybe<&2, Lint.Fact> <- Lint.next_fact() + k : String <- back(m) + none : Maybe<&2, Lint.Node> <- Lint.body("nope") + t2 : String <- top(none) + return [Lint.Diag{Lint.Hint{}, t ++ "; " ++ k ++ "; " ++ t2, None{}, []}] + +def main() -> IO(Unit): + Lint.serve(run) +`; + +// A rule that reports its options. +const echo: LintRule = { + id: "test/echo", + options: { tabWidth: 2, breakLines: false }, + run: (cx) => [cx.diag({ message: JSON.stringify(cx.options), severity: "hint" })], +}; + +// Functions +// ========= + +// Whether `f` throws a drift error. +function drifts(f: () => unknown): boolean { + try { + f(); + } catch (e) { + return (e as { [DRIFT]?: boolean })[DRIFT] === true; + } + return false; +} + +function file2(p: string, ns: string): { str: string; ns: string; al: {}; path: string } { + return { str: "x\n", ns, al: {}, path: p }; +} + +function fixture(name: string, text: string): string { + const file = path.join(DIR, name); + fs.writeFileSync(file, text); + return file; +} + +// Every node under `node`, parents first. +function nodes(cx: RuleContext, node: Node): Node[] { + return [node, ...cx.shape(node).children.flatMap((child) => nodes(cx, child))]; +} + +// The facts of a def's checked body, parents first. +function facts(cx: RuleContext, name: string): Fact[] { + const body = cx.body(name); + return body === undefined ? [] : nodes(cx, body).flatMap((n) => cx.fact(n) ?? []); +} + +// A fact as bend2 gives it. +function loose(cx: RuleContext, fact: Fact): Loose { + return cx.unstable.raw(fact) as Loose; +} + +function bodies(book: Book): Record { + return Object.fromEntries( + book.order.flatMap((name) => { + const tld = book.tlds[name]; + return tld.$ === "Def" && tld.e !== undefined && !tld.b + ? [[name, Bend.term_show(tld.e)]] + : []; + }), + ); +} + +function root(sources: Source[]): Source { + return sources.find((s) => s.root)!; +} + +// The facts a rule with this filter gets: kind, def, name and file. +async function seen(file: string, want: LintRule["facts"]): Promise { + let got: Seen[] = []; + await lint(file, [ + { + id: "test/seen", + facts: want, + run: (cx) => { + got = cx.facts!.map((f) => { + const v = cx.view(f); + return { kind: v.kind, def: v.owner, name: v.name, path: v.span!.file.path }; + }); + return []; + }, + }, + ]); + return got; +} + +// A GitHub that lists `tags` and serves this checkout's bend2 at any +// release; it records the URLs it is asked for. +function github(tags: string[]): { get: (url: string) => Promise; asked: string[] } { + const asked: string[] = []; + const get = async (url: string): Promise => { + asked.push(url); + const file = url.match( + /^https:\/\/raw\.githubusercontent\.com\/bendlang\/bend\/v[\d.]+\/bend2\/(.+)$/, + )?.[1]; + return url.includes("/info/refs") + ? new Response( + [ + "0000 HEAD\0caps", + ...tags.flatMap((t) => ["0000 refs/tags/" + t, "0000 refs/tags/" + t + "^{}"]), + "0000 refs/heads/main", + ].join("\n") + "\n", + ) + : file !== undefined && fs.existsSync(path.join(BEND2, file)) + ? new Response(fs.readFileSync(path.join(BEND2, file), "utf8")) + : new Response("", { status: 404 }); + }; + return { get, asked }; +} + +function run(...args: string[]) { + return spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8" }); +} + +function module(name: string, body: string): string { + return fixture(name, "export const rules = " + body + ";\n"); +} + +// Tests +// ===== + +afterAll(() => fs.rmSync(DIR, { recursive: true, force: true })); + +describe("patch", () => { + const src = fs.readFileSync(path.join(BEND2, "bend.ts"), "utf8"); + const comp = fs.readFileSync(path.join(BEND2, "comp.ts"), "utf8"); + const main = fs.readFileSync(path.join(BEND2, "main.ts"), "utf8"); + + test("bend.ts: renames term_infer and term_check behind wrappers, and swaps fs and path", () => { + const out = patch("bend.ts", src); + expect(out).toContain("function unseen_term_infer("); + expect(out).toContain("function unseen_term_check("); + expect(out).toContain("export const term_infer = seeInfer(unseen_term_infer);"); + expect(out).toContain("export const term_check = seeCheck(unseen_term_check);"); + expect(out).not.toMatch(/^export function term_(infer|check)\(/m); + expect(out).toMatch(/^import \{ fs \} from "file:.*seam\.ts";/m); + expect(out).toMatch(/^import \{ path \} from "file:.*seam\.ts";/m); + }); + + test("comp.ts: exports RUNTIME_MAIN and js_sat", () => { + expect(patch("comp.ts", comp)).toContain("export { RUNTIME_MAIN, js_sat };"); + }); + + test("main.ts: exports how bend reads a book and words a failure, with the same fs and path", () => { + const out = patch("main.ts", main); + expect(out).toContain("export { book_read, book_err, Check_Fail };"); + expect(out).toMatch(/^import \{ fs \} from "file:.*seam\.ts";/m); + expect(out).toMatch(/^import \{ path \} from "file:.*seam\.ts";/m); + expect(() => + patch("main.ts", main.replace("async function book_read(", "async function book_load_file(")), + ).toThrow(/main\.ts: found 0 of a declaration of book_read/); + }); + + test("fails loudly when an edit or a needed name is missing or repeated", () => { + expect( + drifts(() => + patch( + "bend.ts", + src.replace("export function term_infer(", "export function term_infer2("), + ), + ), + ).toBe(true); + expect(() => + patch("bend.ts", src.replace('import * as fs from "node:fs";', 'import fs from "node:fs";')), + ).toThrow(/node:fs/); + expect(() => patch("bend.ts", src + "\nexport function term_check(\n")).toThrow( + /found 2 of "export function term_check\("/, + ); + expect(() => patch("comp.ts", comp.replace("function js_sat(", "function js_name("))).toThrow( + /comp\.ts: found 0 of a declaration of js_sat/, + ); + expect(() => + patch("comp.ts", comp.replace("function io_run(m) {", "function run(m) {")), + ).toThrow(/comp\.ts: found 0 of "function io_run\(m\) \{"/); + }); + + test("main.ts must give book_read, book_err and Check_Fail as bend-lint calls them", () => { + const Check_Fail = function (this: { why: unknown }, why: unknown) { + this.why = why; + } as unknown as new (why: unknown) => { why: unknown }; + const good = { + book_read: (_file: string, _base?: unknown) => Promise.reject(), + book_err: (_e: unknown) => "", + Check_Fail, + }; + expect(() => guardMain(good as never)).not.toThrow(); + expect(() => + guardMain({ ...good, book_read: (_file: string) => Promise.reject() } as never), + ).toThrow(/main\.ts no longer has book_read\(file, base, seen = \.\.\.\)/); + expect(() => guardMain({ ...good, Check_Fail: function () {} } as never)).toThrow( + /main\.ts no longer has new Check_Fail\(why\)\.why/, + ); + expect(drifts(() => guardMain({ ...good, Check_Fail: function () {} } as never))).toBe(true); + }); + + test("the loaded bend.ts and comp.ts are the patched ones", () => { + expect((Bend as unknown as Record).BEND_LINT_PATCH).toBe(1); + expect((Comp as unknown as Record).BEND_LINT_PATCH).toBe(1); + }); + + test("a check is bend's own: a PROOF.bend that skips ./LAWS.bend fails as bend says", async () => { + const dir = path.join(DIR, "proof"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "LAWS.bend"), "def one() -> Type:\n Type\n"); + const res = await lint(fixture("proof/PROOF.bend", "def two() -> Type:\n Type\n"), []); + expect([res.ok, res.diags[0].message]).toEqual([false, "PROOF.bend must import ./LAWS.bend"]); + }); + + test("the wrappers pass every argument through, and need the expected arity", () => { + const got: unknown[][] = []; + const fake = ((...args: unknown[]) => ( + got.push(args), + { tm: null, us: null } + )) as unknown as typeof Bend.term_check; + Object.defineProperty(fake, "length", { value: 7 }); + (seeCheck(fake) as unknown as (...a: unknown[]) => unknown)( + {}, + { def: "d" }, + {}, + 1, + 2, + 3, + 4, + "extra", + ); + expect(got).toEqual([[{}, { def: "d" }, {}, 1, 2, 3, 4, "extra"]]); + Object.defineProperty(fake, "length", { value: 5 }); + expect(() => seeInfer(fake as unknown as typeof Bend.term_infer)).toThrow( + /term_infer takes 5 parameters, not 6/, + ); + }); + + test("bend2 is found from a checkout, its bend2 folder, or the repo; a wrong dir fails", async () => { + const repo = path.dirname(BEND2); + expect([await bendDir(repo), await bendDir(BEND2), await bendDir(undefined)]).toEqual([ + BEND2, + BEND2, + BEND2, + ]); + await expect(bendDir(DIR)).rejects.toThrow(/no bend2 at/); + }); + + test("paths: a drive letter is a root; POSIX paths are unchanged", () => { + expect(resolve("C:/cwd", "C:/Users/m/", "./dep.bend")).toBe("C:/Users/m/dep.bend"); + expect(resolve("C:/cwd", "C:/Users/m/", "/shared/x.bend")).toBe("C:/shared/x.bend"); + expect(resolve("/home/x", "/a/b/", "../c.bend")).toBe("/a/c.bend"); + expect(resolve("/home/x", "y.bend")).toBe("/home/x/y.bend"); + expect(relative("C:/Users/m/", "C:/Users/m/sub/d.bend")).toBe("sub/d.bend"); + expect(relative("/a/b", "/a/c/d.bend")).toBe("../c/d.bend"); + }); +}); + +describe("downloading bend", () => { + const cache = () => fs.mkdtempSync(path.join(DIR, "cache-")); + const offline = () => Promise.reject(new Error("offline")); + + test("the installed bend's version comes first; else the newest release, asked once a day", async () => { + expect([ + installedTag(() => "bend 2.0.36\n"), + installedTag(() => undefined), + installedTag(() => "nope"), + ]).toEqual(["v2.0.36", undefined, undefined]); + const gh = github(["v2.0.9", "v2.0.36", "v2.0.10", "nightly"]); + const at = cache(); + expect([await latestTag(at, gh.get), await latestTag(at, gh.get)]).toEqual([ + "v2.0.36", + "v2.0.36", + ]); + expect(gh.asked.length).toBe(1); + fs.writeFileSync(path.join(at, "latest.json"), "{ damaged"); + expect(await latestTag(at, gh.get)).toBe("v2.0.36"); + expect(installedTag(() => "bend 2.0.36\nA newer bend is out.\n")).toBe("v2.0.36"); + }); + + test("offline, the newest release in the cache is used", async () => { + const at = cache(); + ["v2.0.3", "v2.0.12", "latest-ish"].forEach((d) => fs.mkdirSync(path.join(at, d))); + expect(await latestTag(at, offline)).toBe("v2.0.12"); + expect(fs.existsSync(path.join(at, "latest.json"))).toBe(false); + const gh = github(["v2.0.40"]); + expect(await latestTag(at, gh.get)).toBe("v2.0.40"); + expect(gh.asked.length).toBe(1); + await expect(latestTag(cache(), offline)).rejects.toThrow( + /cannot list bend's releases \(offline\)/, + ); + }); + + test("a release downloads once and whole, and bend-lint runs on it", async () => { + const gh = github([]); + const at = cache(); + const dir = await fetchBend("v2.0.36", at, gh.get); + const effs = fs.readdirSync(path.join(BEND2, "effs")); + expect(fs.readFileSync(path.join(dir, "bend.ts"), "utf8")).toBe( + fs.readFileSync(path.join(BEND2, "bend.ts"), "utf8"), + ); + expect(fs.readFileSync(path.join(dir, "main.ts"), "utf8")).toBe( + fs.readFileSync(path.join(BEND2, "main.ts"), "utf8"), + ); + expect(fs.readdirSync(path.join(dir, "effs")).sort()).toEqual(effs.sort()); + expect(gh.asked.length).toBe(5 + effs.length); + expect(await fetchBend("v2.0.36", at, gh.get)).toBe(dir); + expect(gh.asked.length).toBe(5 + effs.length); + fs.rmSync(path.join(dir, "main.ts")); // a cache from before main.ts was patched + expect(await fetchBend("v2.0.36", at, gh.get)).toBe(dir); + expect(gh.asked.length).toBe(2 * (5 + effs.length)); + expect(fs.existsSync(path.join(dir, "main.ts"))).toBe(true); + expect(fs.readdirSync(path.join(at, "v2.0.36"))).toEqual(["bend2"]); + const out = spawnSync(process.execPath, [CLI, fixture("downloaded.bend", USERLAND)], { + encoding: "utf8", + env: { ...process.env, BEND_DIR: dir }, + }); + expect([out.status, out.stderr]).toEqual([0, ""]); + await expect(fetchBend("../x", at, gh.get)).rejects.toThrow(/not a bend release/); + }); + + test("bendDir downloads only when no bend is given or around it", async () => { + const saved = process.env.BEND_DIR; + delete process.env.BEND_DIR; // a given bend would win over all below + try { + const gh = github(["v2.0.35"]); + const at = cache(); + const away = { get: gh.get, cache: at, repo: DIR }; + expect(await bendDir(undefined, { ...away, run: () => "bend 2.0.36\n" })).toBe( + fs.realpathSync(path.join(at, "v2.0.36", "bend2")).replaceAll("\\", "/"), + ); + expect(await bendDir(undefined, { ...away, run: () => undefined })).toBe( + fs.realpathSync(path.join(at, "v2.0.35", "bend2")).replaceAll("\\", "/"), + ); + await expect( + bendDir(undefined, { get: offline, cache: at, repo: DIR, run: () => "bend 2.0.40\n" }), + ).rejects.toThrow(/could not download bend v2\.0\.40 \(offline\)/); + expect( + await bendDir(undefined, { + get: offline, + cache: at, + repo: BEND2, + run: () => "bend 2.0.40\n", + }), + ).toBe(BEND2); + } finally { + if (saved !== undefined) { + process.env.BEND_DIR = saved; + } + } + }); +}); + +describe("spans", () => { + const source = (p: string, text: string) => ({ + str: text, + ns: "", + al: {} as Record, + path: p, + }); + + for (const eol of ["\n", "\r\n"]) { + const text = ["import Base", "import ./a.bend as A", "", "def f() -> N:", " x", ""].join(eol); + const lines = text.split("\n"); + for (const [kind, masked] of [ + ["empty", lines.map((l) => (l.startsWith("import") ? "" : l)).join("\n")], + ["spaces", lines.map((l) => (l.startsWith("import") ? " ".repeat(l.length) : l)).join("\n")], + ]) { + test("map past " + kind + " import lines (" + JSON.stringify(eol) + ")", () => { + const src = source("/p/m.bend", text); + const beg = masked.indexOf("x"); + const got = mapper([src])({ + file: { str: masked, ns: "", dir: "/p/", al: { A: "a" } }, + beg, + end: beg + 1, + } as BendSpan)!; + expect(got.file).toBe(src); + expect(text.slice(got.beg, got.end)).toBe("x"); + expect(src.al.A).toBe("a"); + }); + } + } + + test("a span on disk passes through; a copy that does not match fails", () => { + const src = source("/p/m.bend", "import Base\ndef f() -> N:\n x\n"); + const map = mapper([src]); + const spn = { file: src, beg: 2, end: 3 }; + expect(map(spn)).toBe(spn); + expect( + drifts(() => + map({ file: { str: "\ndef g() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 }), + ), + ).toBe(true); + expect( + drifts(() => map({ file: { str: "def f() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 })), + ).toBe(true); + }); + + test("the directory picks between equal files", () => { + const a = source("/a/m.bend", "import Base\nx\n"); + const b = source("/b/m.bend", "import Base\nx\n"); + const map = mapper([a, b]); + expect( + map({ file: { str: "\nx\n", ns: "", dir: "/b/", al: {} }, beg: 1, end: 2 } as BendSpan)!.file, + ).toBe(b); + }); +}); + +describe("lint", () => { + const userland = fixture("userland.bend", USERLAND); + + test("source and typed rules report in order, with fixes on the file on disk", async () => { + const res = await lint(userland, [commaSpace, identity]); + expect(res.ok).toBe(true); + expect(res.diags.map((d) => d.code)).toEqual([commaSpace.id, identity.id]); + expect(res.diags[0].fixes[0].edits[0].span.file.text).toBe(USERLAND); + expect(render(res.diags[0])).toStartWith("Warning [style/comma-space]:"); + expect(render(res.diags[0])).toContain("generic(~N, a)"); + expect(render(res.diags[1])).toContain("Context:"); + }); + + test("only rules with facts get facts", async () => { + const look: LintRule = { + id: "test/look", + run: (cx) => { + expect(cx.facts).toBeUndefined(); + return []; + }, + }; + expect((await lint(userland, [look, identity])).ok).toBe(true); + expect((await lint(userland, [commaSpace])).facts).toBeUndefined(); + }); + + test("the term view: bodies, nodes, shapes, and a node's fact only if the rule asked for it", async () => { + const tree: LintRule = { + id: "test/tree", + facts: { kinds: ["Var"], defs: ["id"] }, + run: (cx) => { + const all = nodes(cx, cx.body("id")!); + const x = cx.facts!.find((f) => cx.view(f).name === "x" && !cx.view(f).inst)!; + expect(all).toContain(cx.node(x)); + expect(cx.fact(cx.node(x))).toBe(x); + expect(cx.shape(cx.node(x)).kind).toBe("Ann"); + expect(cx.shape(cx.shape(cx.node(x)).children[0])).toMatchObject({ + kind: "Var", + name: "x", + }); + const theirs = all.map((n) => cx.fact(n)).filter((f) => f !== undefined); + expect(theirs.length).toBeGreaterThan(0); + expect(theirs.every((f) => cx.view(f).kind === "Var")).toBe(true); + expect(cx.body("nope")).toBeUndefined(); + return []; + }, + }; + const every: LintRule = { + id: "test/every", + facts: true, + run: (cx) => { + const kept = nodes(cx, cx.body("id")!).flatMap((n) => cx.fact(n) ?? []); + expect(kept.some((f) => cx.view(f).kind !== "Var")).toBe(true); + return []; + }, + }; + expect((await lint(userland, [tree, every])).ok).toBe(true); + }); + + test("facts cover templates, proofs, matches and fields", async () => { + const probe: LintRule = { + id: "test/probe", + facts: true, + run: (cx) => { + const named = (def: string, kind: string, name = "") => + facts(cx, def).find((f) => { + const x = loose(cx, f).tm.x; + return x?.$ === kind && (name === "" || x.k === name); + })!; + const generic = named("generic", "Var", "x"); + expect(loose(cx, generic).bok.tlds["generic~T"]).toBeDefined(); + expect(cx.view(generic).inst).toBe(false); + const inst = named("generic~0", "Var", "x"); + expect(cx.view(inst).inst).toBe(true); + expect(cx.view(inst).span).toEqual(cx.view(generic).span); + expect(["erased", "once", "many"]).toContain(cx.view(generic).quantity); + expect(loose(cx, generic).us.$).toBeDefined(); + const proof = named("proof", "Rfl"); + expect(loose(cx, proof).ty.$).toBe("Eql"); + expect(loose(cx, proof).dep).toBe(1); + const peel = nodes(cx, cx.body("peel")!).map((n) => ({ n, ...cx.shape(n) })); + expect(peel.some((s) => s.kind === "Mat")).toBe(true); + expect( + peel.some( + (s) => + s.kind === "Ann" && + cx.shape(s.children[0]).kind === "Efq" && + cx.fact(s.n) === undefined, + ), + ).toBe(true); + const field = loose(cx, named("peel", "Var", "p")); + expect(cx.unstable.Bend.pmap_get(field.ctx, field.tm.x!.i!)!.k).toBe("p"); + return []; + }, + }; + expect((await lint(userland, [probe])).ok).toBe(true); + }); + + test("walk reaches every kind in checked bodies; an unknown kind fails", async () => { + const res = await lint(userland, []); + const { book } = res.unstable; + const kinds = new Set( + book.order.flatMap((name) => { + const tld = book.tlds[name]; + return tld.$ === "Def" && tld.e !== undefined ? [...walk(tld.e)].map((tm) => tm.$) : []; + }), + ); + for (const k of ["Mat", "Lam", "App", "Var", "Ctr"]) + expect(kinds.has(k as LTerm["$"])).toBe(true); + expect(drifts(() => walk({ $: "Nope" } as unknown as LTerm).next())).toBe(true); + }); + + test("walk preserves constructor and type argument order across traversals", () => { + const children: LTerm[] = [ + { $: "Ref", k: "first" }, + { $: "Ref", k: "second" }, + ]; + for (const $ of ["Ctr", "ADT"] as const) { + const term = { $, k: "Pair", x: children } as LTerm; + const original = [...children]; + expect([...walk(term)]).toEqual([term, ...original]); + expect(children).toEqual(original); + expect([...walk(term)]).toEqual([term, ...original]); + expect(children).toEqual(original); + } + }); + + test("a failed check is one bend/check error, and no rule runs", async () => { + const parse = await lint(fixture("parse.bend", "type N is Data:\n Z{}\ndef broken(\n"), [ + neverRun, + ]); + expect(parse.ok).toBe(false); + expect(parse.diags.map((d) => d.code)).toEqual(["bend/check"]); + expect(render(parse.diags[0])).toStartWith("Error [bend/check]:"); + expect( + ( + await lint( + fixture("name.bend", "type N is Data:\n Z{}\ndef broken() -> N:\n missing\n"), + [neverRun], + ) + ).ok, + ).toBe(false); + const todo = await lint( + fixture("todo.bend", "type N is Data:\n Z{}\ndef broken() -> N:\n ?TODO\n"), + [neverRun], + ); + expect(todo.diags[0].message).toStartWith("1 TODO found"); + // The message is bend's, its location aside: expected and observed, with the names in scope. + const types = "type N is Data:\n Z{}\n\ntype M is Data:\n W{}\n\n"; + expect( + (await lint(fixture("mismatch.bend", types + "def bad(x: N) -> M:\n x\n"), [neverRun])) + .diags[0].message, + ).toBe("expected: M\nobserved: N"); + expect( + (await lint(fixture("bound.bend", types + "def bad(A: Type, x: N) -> A:\n x\n"), [neverRun])) + .diags[0].message, + ).toBe("expected: A\nobserved: N"); + expect((await lint(path.join(DIR, "missing.bend"), [neverRun])).ok).toBe(false); + }); + + test("an error finding stops later rules; the code is always the rule's id", async () => { + const stamp: LintRule = { + id: "test/stamp", + run: (cx) => [{ ...cx.diag({ message: "stamped", severity: "hint" }), code: "other/code" }], + }; + expect((await lint(userland, [stamp])).diags[0].code).toBe(stamp.id); + const stop: LintRule = { + id: "test/error", + run: (cx) => [cx.diag({ message: "failed", severity: "error" })], + }; + const res = await lint(userland, [stop, neverRun]); + expect(res.ok).toBe(false); + expect(res.diags.map((d) => d.code)).toEqual([stop.id]); + }); + + test("a bad rule, a rule that throws, and an abort reach the caller", async () => { + await expect(lint(userland, [{ id: "bad", run: () => [] }])).rejects.toThrow(/invalid rule/); + await expect(lint(userland, [undefined as unknown as LintRule])).rejects.toThrow( + /invalid rule at 0/, + ); + const broken: LintRule = { + id: "test/broken-fix", + run: (cx) => { + const span = { file: cx.root, beg: 0, end: 4 }; + return [ + cx.diag({ + message: "b", + fixes: [ + { + title: "two", + applicability: "safe", + edits: [ + { span, text: "a" }, + { span, text: "b" }, + ], + }, + ], + }), + ]; + }, + }; + await expect(lint(userland, [broken])).rejects.toThrow( + /fix "two" has an edit out of bounds, or two that clash/, + ); + await expect( + lint(userland, [ + { + id: "test/boom", + run: async () => { + throw new Error("rule failed"); + }, + }, + ]), + ).rejects.toThrow("rule failed"); + const controller = new AbortController(); + const abort: LintRule = { + id: "test/abort", + run: async () => { + controller.abort(); + return []; + }, + }; + await expect( + lint(userland, [abort, neverRun], { signal: controller.signal }), + ).rejects.toThrow(); + await expect(lint(userland, [], { signal: controller.signal })).rejects.toThrow(); + }); + + test("unsaved text is what bend and the rules see, for that run only", async () => { + const saved = "import Base\ndef main() -> U32:\n 1\n"; + const edited = "import Base\ndef main() -> U32:\n 2\n"; + const file = fixture("unsaved.bend", saved); + const seen: LintRule = { + id: "test/seen", + run: (cx) => [cx.diag({ message: cx.root.text, severity: "hint" })], + }; + const text = async (unsaved?: Map): Promise => + (await lint(file, [seen], { unsaved })).diags[0].message; + expect(await text(new Map([[file, edited]]))).toBe(edited); + expect(await text()).toBe(saved); + const broken = await lint(file, [seen], { + unsaved: new Map([[file, "import Base\ndef main() -> U32:\n nope\n"]]), + }); + expect([broken.ok, broken.diags[0].code]).toEqual([false, "bend/check"]); + // Runs at the same time wait for each other's check, and each sees its own text. + const other = "import Base\ndef main() -> U32:\n 3\n"; + expect( + await Promise.all([text(new Map([[file, edited]])), text(), text(new Map([[file, other]]))]), + ).toEqual([edited, saved, other]); + expect(await text()).toBe(saved); + }); + + test("spans after import lines point at the right text on disk", async () => { + fixture("dep.bend", "type N is Data:\n Z{}\ndef id(x: N) -> N:\n x\n"); + const main = fixture( + "main.bend", + "import ./dep.bend as D\n\ndef main() -> D.N:\n D.id(D.Z{})\n", + ); + const look: LintRule = { + id: "test/imports", + facts: true, + run: (cx) => { + expect(cx.root.path).toEndWith("/main.bend"); + const texts = cx + .facts!.map((f) => cx.view(f)) + .filter((v) => v.owner === "main" && v.span !== undefined) + .map(({ span }) => { + expect(span!.file).toBe(cx.root); + return cx.root.text.slice(span!.beg, span!.end); + }); + expect(texts.some((t) => t.startsWith("D.id") || t.startsWith("D.Z"))).toBe(true); + return []; + }, + }; + const res = await lint(main, [look]); + expect(res.diags.map(render)).toEqual([]); + expect(res.sources.length).toBe(2); + }); + + test("fix offsets must be integers within the source file", async () => { + for (const [beg, end] of [ + [NaN, 0], + [0, NaN], + [Infinity, Infinity], + [0.5, 1], + [0, 0.5], + [-1, 0], + [2, 1], + [0, 1000000], + ]) { + const rule: LintRule = { + id: "test/bad-offset", + run: (cx) => [ + cx.diag({ + message: "bad offset", + fixes: [ + { + title: "bad offset", + applicability: "safe", + edits: [{ span: { file: cx.root, beg, end }, text: "X" }], + }, + ], + }), + ], + }; + await expect(lint(userland, [rule])).rejects.toThrow("out of bounds"); + } + expect(fs.readFileSync(userland, "utf8")).toBe(USERLAND); + }); + + test("Base is reused, its facts are left out, and it stays out of files that do not import it", async () => { + const withBase = fixture("with_base.bend", "import Base\n\ndef main() -> Nat:\n 1n\n"); + const probe: LintRule = { + id: "test/base", + facts: true, + run: (cx) => { + expect(cx.root.path).toEndWith("/with_base.bend"); + expect(cx.sources.some((s) => s.base)).toBe(true); + const owners = cx.facts!.map((f) => cx.view(f).owner); + expect(owners.some((def) => def === "main")).toBe(true); + expect(owners.every((def) => cx.unstable.book.tlds[def]?.b !== true)).toBe(true); + return []; + }, + }; + const first = await lint(withBase, [probe]); + const second = await lint(withBase, [probe]); + expect([first.ok, second.ok]).toEqual([true, true]); + expect(second.unstable.book.order).toEqual(first.unstable.book.order); + expect((await lint(fixture("without_base.bend", "def main() -> Nat:\n 1n\n"), [])).ok).toBe( + false, + ); + }); + + test("lints in flight keep their own rules and facts", async () => { + const gate = Promise.withResolvers(); + const entered = Promise.withResolvers(); + const paused: LintRule = { + id: "test/paused", + run: async (cx) => { + expect(cx.facts).toBeUndefined(); + entered.resolve(); + await gate.promise; + return []; + }, + }; + const first = lint(userland, [paused]); + await entered.promise; + const second = await lint(userland, [identity]).finally(() => gate.resolve()); + expect(second.facts!.length).toBeGreaterThan(0); + expect(second.diags.map((d) => d.code)).toEqual([identity.id]); + expect((await first).diags).toEqual([]); + }); + + test("a rule may await I/O, and later rules see earlier findings", async () => { + const server = Bun.serve({ + port: 0, + fetch: async (req) => new Response("advice for " + (await req.json()).type), + }); + const remote: LintRule = { + id: "test/api", + facts: true, + run: async (cx, signal) => { + const fact = loose(cx, cx.fact(cx.body("id")!)!); + const type = cx.unstable.Bend.term_wnf(fact.bok, fact.ty) as { A: typeof fact.ty }; + const arg = cx.unstable.Bend.term_wnf(fact.bok, type.A) as { k?: string }; + const res = await fetch(server.url, { + method: "POST", + body: JSON.stringify({ type: arg.k }), + signal, + }); + return [cx.diag({ message: await res.text(), severity: "hint" })]; + }, + }; + const next: LintRule = { + id: "test/after-api", + run: (cx) => { + expect(cx.prior[0].message).toBe("advice for N"); + return []; + }, + }; + const res = await lint(userland, [remote, next]).finally(() => server.stop(true)); + expect(res.ok).toBe(true); + }); +}); + +describe("options", () => { + const userland = fixture("options_userland.bend", USERLAND); + const messages = async (config?: object, file = userland) => + (await lint(file, [echo], { config })).diags.map((d) => [d.severity, d.message]); + + test("a rule gets its defaults, with the config's values", async () => { + expect(await messages({})).toEqual([["hint", '{"tabWidth":2,"breakLines":false}']]); + expect(await messages({ rules: { "test/echo": { tabWidth: 4 } } })).toEqual([ + ["hint", '{"tabWidth":4,"breakLines":false}'], + ]); + }); + + test("the config turns a rule off, or sets its severity", async () => { + expect(await messages({ rules: { "test/echo": "off" } })).toEqual([]); + expect( + await messages({ rules: { "test/echo": { severity: "warning", breakLines: true } } }), + ).toEqual([["warning", '{"tabWidth":2,"breakLines":true}']]); + }); + + test("a wrong config fails loudly", async () => { + await expect(messages({ rules: { "test/echo": { tabSize: 4 } } })).rejects.toThrow( + /test\/echo has no option tabSize/, + ); + await expect(messages({ rules: { "test/echo": { tabWidth: "4" } } })).rejects.toThrow( + /tabWidth must be a number/, + ); + await expect(messages({ rules: { "test/echo": { severity: "loud" } } })).rejects.toThrow( + /severity must be one of/, + ); + await expect(messages({ rules: { "test/echo": 4 } })).rejects.toThrow( + /must be "off" or an object/, + ); + }); + + test("bend-lint.json is found in the file's folder or above it", async () => { + const top = path.join(DIR, "project"); + fs.mkdirSync(path.join(top, "deep", "er"), { recursive: true }); + fs.writeFileSync( + path.join(top, "bend-lint.json"), + JSON.stringify({ rules: { "test/echo": { tabWidth: 8 } } }), + ); + const file = path.join(top, "deep", "er", "file.bend"); + fs.writeFileSync(file, USERLAND); + expect(findConfig(file)).toEqual({ rules: { "test/echo": { tabWidth: 8 } } }); + expect(await messages(undefined, file)).toEqual([ + ["hint", '{"tabWidth":8,"breakLines":false}'], + ]); + expect(await messages({}, file)).toEqual([["hint", '{"tabWidth":2,"breakLines":false}']]); + }); + + for (const extension of ["js", "ts"]) { + test(`bend-lint.${extension} loads a named config export and relative imports`, async () => { + const top = path.join(DIR, "config_" + extension); + fs.mkdirSync(path.join(top, "nested"), { recursive: true }); + fs.writeFileSync(path.join(top, "width.ts"), "export const width: number = 8;"); + fs.writeFileSync( + path.join(top, "bend-lint." + extension), + 'import { width } from "./width.ts"; export const config = { rules: { "test/echo": { tabWidth: width, severity: "warning" } } };', + ); + const file = path.join(top, "nested", "file.bend"); + fs.writeFileSync(file, USERLAND); + expect(findConfig(file)).toEqual({ + rules: { "test/echo": { tabWidth: 8, severity: "warning" } }, + }); + expect(await messages(undefined, file)).toEqual([ + ["warning", '{"tabWidth":8,"breakLines":false}'], + ]); + expect(await messages({}, file)).toEqual([["hint", '{"tabWidth":2,"breakLines":false}']]); + }); + } + + test("nearest config wins; JSON precedes JS, then TS in the same directory", () => { + const configs = [ + [ + "bend-lint.ts", + 'export const config = { rules: { "test/echo": { tabWidth: 6 } } };', + { tabWidth: 6 }, + ], + [ + "bend-lint.js", + 'export const config = { rules: { "test/echo": { tabWidth: 8 } } };', + { tabWidth: 8 }, + ], + ["bend-lint.json", '{"rules":{"test/echo":"off"}}', "off"], + ] as const; + for (let count = 1; count <= configs.length; count++) { + const top = path.join(DIR, "config_precedence_" + count); + const child = path.join(top, "child"); + fs.mkdirSync(child, { recursive: true }); + fs.writeFileSync(path.join(top, "bend-lint.json"), '{"rules":{"test/echo":{"tabWidth":4}}}'); + for (const [name, text] of configs.slice(0, count)) { + fs.writeFileSync(path.join(child, name), text); + } + expect(findConfig(path.join(child, "file.bend"))).toEqual({ + rules: { "test/echo": configs[count - 1][2] }, + }); + } + }); + + test("module configs require a named object export and preserve load errors", () => { + for (const extension of ["js", "ts"]) { + for (const [name, text, error] of [ + ["default", "export default {};", /must export a named `config` object/], + ["missing", "export const rules = {};", /must export a named `config` object/], + ["null", "export const config = null;", /`config` must be an object/], + ["array", "export const config = [];", /`config` must be an object/], + ["function", "export const config = () => ({});", /`config` must be an object/], + ["throws", 'throw new Error("config exploded");', /config exploded/], + ] as const) { + const file = fixture(`config_${name}.${extension}`, text); + expect(() => readConfig(file)).toThrow(error); + expect(() => readConfig(file)).toThrow(file); + } + } + }); + + test("module configs use the same rule option validation as JSON", async () => { + const file = fixture( + "invalid_options.ts", + 'export const config = { rules: { "test/echo": { tabWidth: "4" } } };', + ); + await expect(messages(readConfig(file))).rejects.toThrow(/tabWidth must be a number/); + }); + + test("a Bend rule reads its options with defaults", async () => { + const rule = await bendRule(fixture("options_rule.bend", OPTIONS_BEND)); + const said = async (config: object) => + (await lint(userland, [rule], { config })).diags.map((d) => d.message); + const [plain] = await said({}); + expect(plain).toMatch(/^2 \S+ none$/); + const [given] = await said({ rules: { "test/options": { width: 4, wrap: true, name: "x" } } }); + expect(given).toMatch(/^4 \S+ x$/); + expect(given.split(" ")[1]).not.toBe(plain.split(" ")[1]); + await expect(said({ rules: { "test/options": { width: 1.5 } } })).rejects.toThrow( + /whole number/, + ); + }); +}); + +describe("review fixes", () => { + test("facts come only from the linted file: none from Base, with or without seeding", async () => { + for (const [name, head] of [ + ["base_comment.bend", "import Base # comment"], + ["base_plain.bend", "import Base"], + ]) { + const file = fixture( + name, + head + "\n\ntype P is Data:\n P{n: Nat}\n\ndef main() -> Nat:\n 1n\n", + ); + const where: LintRule = { + id: "test/where", + facts: true, + run: (cx) => { + expect(cx.facts!.length).toBeGreaterThan(0); + expect(cx.facts!.every((f) => cx.view(f).span?.file === cx.root)).toBe(true); + return []; + }, + }; + expect((await lint(file, [where])).ok).toBe(true); + } + }); + + test("checks at the same time keep their facts apart", async () => { + const files = ["one", "two"].map((name) => + fixture( + "apart_" + name + ".bend", + "type N is Data:\n Z{}\n\ndef " + name + "(x: N) -> N:\n x\n", + ), + ); + const where: LintRule = { + id: "test/where", + facts: true, + run: (cx) => [ + cx.diag({ + message: [...new Set(cx.facts!.map((f) => cx.view(f).owner))].join(), + severity: "hint", + }), + ], + }; + const got = await Promise.all( + [...files, ...files].map(async (file) => (await lint(file, [where])).diags[0].message), + ); + expect(got).toEqual(["N,one", "N,two", "N,one", "N,two"]); + }); + + test("identical files map by namespace, not only by text", async () => { + const dir = path.join(DIR, "twins"); + fs.mkdirSync(dir, { recursive: true }); + for (const f of ["a.bend", "b.bend"]) + fs.writeFileSync(path.join(dir, f), "def one() -> Type:\n Type\n"); + fs.writeFileSync( + path.join(dir, "m.bend"), + "import ./a.bend as A\nimport ./b.bend as B\n\ndef main() -> Type:\n A.one()\n", + ); + const all: LintRule = { + id: "test/all", + facts: true, + run: (cx) => cx.facts!.map((f) => cx.diag({ message: "f", span: cx.view(f).span })), + }; + const res = await lint(path.join(dir, "m.bend"), [all]); + expect(res.ok).toBe(true); + expect(res.diags.length).toBeGreaterThan(0); + const a = file2("/p/a.bend", "a"), + b = file2("/p/b.bend", "b"); + expect( + mapper([a, b])({ + file: { str: "x\n", ns: "b", dir: "/p/", al: {} }, + beg: 0, + end: 1, + } as BendSpan)!.file, + ).toBe(b); + }); + + test("walk is linear in a long list (it took 408 ms at 2,000 elements)", async () => { + const n = 2000; + const file = fixture( + "long_list.bend", + "import Base\n\ndef xs() -> List<&2, U32>:\n [" + + Array.from({ length: n }, (_, i) => i).join(", ") + + "]\n", + ); + const res = await lint(file, []); + expect(res.diags.map(render)).toEqual([]); + const t = performance.now(); + const count = [...walk((res.unstable.book.tlds.xs as { e: LTerm }).e)].length; + expect(count).toBeGreaterThan(n); + expect(performance.now() - t).toBeLessThan(100); + }); + + test("clashing fixes are skipped, not fatal; equal ones merge", () => { + const file: Source = { path: "", text: "abcdef", root: true, base: false }; + const fix = (beg: number, end: number, text: string): Diag => ({ + code: "t/f", + severity: "hint", + message: "", + fixes: [{ title: "f", applicability: "safe", edits: [{ span: { file, beg, end }, text }] }], + }); + expect( + applyFixes(file, [ + fix(1, 1, " "), + fix(1, 1, " "), + fix(3, 5, "X"), + fix(4, 6, "Y"), + fix(1, 1, "-"), + ]), + ).toEqual({ text: "a bcXf", skipped: 2, elsewhere: 0 }); + }); + + test("a fix that also edits another file is skipped whole", () => { + const file: Source = { path: "", text: "abc", root: true, base: false }; + const other: Source = { path: "", text: "xyz", root: false, base: false }; + const fix = (edits: Edit[]): Diag => ({ + code: "t/f", + severity: "hint", + message: "", + fixes: [{ title: "f", applicability: "safe", edits }], + }); + expect( + applyFixes(file, [ + fix([ + { span: { file, beg: 0, end: 1 }, text: "A" }, + { span: { file: other, beg: 0, end: 1 }, text: "X" }, + ]), + fix([{ span: { file, beg: 2, end: 3 }, text: "C" }]), + ]), + ).toEqual({ text: "abC", skipped: 0, elsewhere: 1 }); + }); + + test("comp.ts that already exports js_sat still loads", () => { + const comp = fs + .readFileSync(path.join(BEND2, "comp.ts"), "utf8") + .replace("\nfunction js_sat(", "\nexport function js_sat("); + const out = patch("comp.ts", comp); + expect(out).toContain("export { RUNTIME_MAIN };"); + expect(() => new Bun.Transpiler({ loader: "ts" }).transformSync(out)).not.toThrow(); + }); + + test("another checkout's bend.ts loads as its own code", () => { + const other = path.join(DIR, "other"); + fs.cpSync(BEND2, path.join(other, "bend2"), { recursive: true }); + fs.appendFileSync(path.join(other, "bend2", "bend.ts"), "\nexport const OTHER_CHECKOUT = 1;\n"); + const script = fixture( + "other.ts", + "import " + + JSON.stringify(CLI) + + ";\nconst o = await import(" + + JSON.stringify(path.join(other, "bend2", "bend.ts")) + + ");\n" + + "console.log(JSON.stringify([o.OTHER_CHECKOUT, o.BEND_LINT_PATCH ?? null]));\n", + ); + const out = spawnSync(process.execPath, [script], { encoding: "utf8" }); + expect(JSON.parse(out.stdout.trim().split("\n").at(-1)!)).toEqual([1, null]); + }); +}); + +describe("rules", () => { + test("erasure: four redundant annotations; the fix keeps every body", async () => { + const file = fixture("erasure.bend", ERASURE); + const res = await lint(file, [redundantAnnotation]); + expect(res.diags.map((d) => d.def).sort()).toEqual(["alias", "dependent", "direct", "generic"]); + const cleaned = applyFixes(root(res.sources), res.diags, ["suggested"]).text; + expect(cleaned).toContain("f : N -> N = y => y"); + expect(cleaned).toContain("value : N = Z{}"); + const after = await lint(fixture("erasure_fixed.bend", cleaned), [redundantAnnotation]); + expect(after.diags).toEqual([]); + expect(bodies(after.unstable.book)).toEqual(bodies(res.unstable.book)); + for (const [annotation, binding] of [ + ["f : N -> N =", "f ="], + ["value : N =", "value ="], + ]) { + const broken = await lint( + fixture("erasure_broken.bend", cleaned.replace(annotation, binding)), + [], + ); + expect(broken.diags.map((d) => d.code)).toEqual(["bend/check"]); + } + expect(fs.readFileSync(file, "utf8")).toBe(ERASURE); + }); +}); + +describe("drift: bend-lint agrees with bend's own tests", () => { + for (const name of BEND_TESTS) { + const file = path.join(BEND2, "..", "tests", name + ".bend"); + test.skipIf(!fs.existsSync(file))(name, async () => { + const first = fs + .readFileSync(file, "utf8") + .match(/^#\|(.*)$/m)![1] + .trim(); + expect((await lint(file, [])).ok).toBe(first !== "SOME PROOFS FAIL"); + }); + } +}); + +describe("rules written in Bend", () => { + const userland = fixture("bend_userland.bend", USERLAND); + + test("a source rule finds the comma, with a fix on the file on disk", async () => { + const rule = await bendRule(fixture("comma_rule.bend", COMMA_BEND)); + expect([rule.id, rule.facts]).toEqual(["style/comma-space", undefined]); + const res = await lint(userland, [rule]); + expect(res.diags.map((d) => [d.code, d.severity])).toEqual([["style/comma-space", "warning"]]); + expect(render(res.diags[0])).toContain("generic(~N, a)"); + expect(applyFixes(root(res.sources), res.diags).text).toContain("generic(~N, a)"); + }); + + test("a typed rule asks the checker through effects", async () => { + const rule = await bendRule(fixture("types_rule.bend", TYPES_BEND)); + expect(rule.facts).toEqual({ scope: "file", kinds: ["Var"], defs: ["id"], names: [] }); + const res = await lint(userland, [rule, rule]); + expect(res.diags.map((d) => d.message)).toEqual( + Array(2).fill("x: Alias = N (same as its binder), text x, demanded once, uses x once"), + ); + expect(res.diags.every((d) => d.span?.file === root(res.sources))).toBe(true); + }); + + test("a rule reads the term view through effects", async () => { + const rule = await bendRule(fixture("tree_rule.bend", TREE_BEND)); + const res = await lint(userland, [rule]); + expect(res.diags.map((d) => d.message)).toEqual(["Ann > Lam; Ann > Var found; no body"]); + }); + + test("Bend fixes reject out-of-bounds code-point offsets instead of clamping them", async () => { + for (const source of [USERLAND, "# 😀\n" + USERLAND]) { + const end = [...source].length + 1; + const code = COMMA_BEND.replace( + "Lint.Span{path, at, at}", + "Lint.Span{path, at, " + end + "}", + ); + const rule = await bendRule(fixture("bad_offset_rule.bend", code)); + const file = fixture("bad_offset_input.bend", source); + await expect(lint(file, [rule])).rejects.toThrow( + 'fix "Insert space" has an edit out of bounds', + ); + expect(fs.readFileSync(file, "utf8")).toBe(source); + const diagnosticOnly = code.replace( + '[Lint.Fix{"Insert space", Lint.Safe{}, [Lint.Edit{span, " "}]}]', + "[]", + ); + const diagnosticRule = await bendRule( + fixture("clamped_diagnostic_rule.bend", diagnosticOnly), + ); + const result = await lint(file, [diagnosticRule]); + expect(result.ok).toBe(true); + expect(result.diags[0].span!.end).toBe(source.length); + } + }); +}); + +describe("fact filters", () => { + const userland = fixture("filter_userland.bend", USERLAND); + + test("kinds, defs and names narrow the facts; an empty or absent list matches all", async () => { + const all = await seen(userland, true); + const only = async (want: LintRule["facts"], keep: (f: Seen) => boolean) => { + const got = await seen(userland, want); + expect(got.length).toBeGreaterThan(0); + expect(got).toEqual(all.filter(keep)); + }; + await only({ kinds: ["Var"] }, (f) => f.kind === "Var"); + await only({ defs: ["generic"] }, (f) => f.def === "generic" || f.def.startsWith("generic~")); + await only({ kinds: ["Ref"], names: ["id"] }, (f) => f.kind === "Ref" && f.name === "id"); + await only({ kinds: [], defs: [], names: [] }, () => true); + expect(all.some((f) => f.def.startsWith("generic~"))).toBe(true); + }); + + test("each rule gets its own facts; bend-lint keeps only what some rule asked for", async () => { + const got: Record = {}; + const rule = (id: string, kinds: string[]): LintRule => ({ + id, + facts: { kinds }, + run: (cx) => ((got[id] = cx.facts!.map((f) => cx.view(f).kind)), []), + }); + const res = await lint(userland, [rule("test/vars", ["Var"]), rule("test/refs", ["Ref"])]); + expect(new Set(got["test/vars"])).toEqual(new Set(["Var"])); + expect(new Set(got["test/refs"])).toEqual(new Set(["Ref"])); + expect(res.facts!.length).toBe(got["test/vars"].length + got["test/refs"].length); + expect(res.facts!.length).toBeLessThan( + (await lint(userland, [rule("test/all", [])])).facts!.length, + ); + }); + + test("scope program adds the imports' facts, never Base's", async () => { + const dir = path.join(DIR, "program"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "dep.bend"), "import Base\n\ndef one(x: Nat) -> Nat:\n x\n"); + for (const head of ["import Base", "import Base # comment"]) { + const main = path.join(dir, "main.bend"); + fs.writeFileSync( + main, + head + "\nimport ./dep.bend as D\n\ndef main() -> Nat:\n D.one(1n)\n", + ); + const where = async (scope: "file" | "program") => + new Set((await seen(main, { scope })).map((f) => path.basename(f.path))); + expect(await where("file")).toEqual(new Set(["main.bend"])); + expect(await where("program")).toEqual(new Set(["main.bend", "dep.bend"])); + } + }); + + test("a bad filter is an invalid rule", async () => { + for (const facts of [{ scope: "all" }, { kinds: "Var" }, { names: [1] }, 3]) { + await expect( + lint(userland, [{ id: "test/bad", facts, run: () => [] } as unknown as LintRule]), + ).rejects.toThrow(/invalid rule at 0/); + } + }); + + test("a Bend rule pulls its facts one at a time, any number of them", async () => { + const rule = await bendRule(fixture("count_rule.bend", COUNT_BEND)); + expect(rule.facts).toEqual({ scope: "program", kinds: ["Var"], defs: [], names: [] }); + const many = fixture( + "many_vars.bend", + "import Base\n\n" + + Array.from({ length: 5000 }, (_, i) => "def f" + i + "(x: U32) -> U32:\n x\n").join("\n"), + ); + expect((await lint(many, [rule])).diags.map((d) => d.message)).toEqual(["5000"]); + }); +}); + +describe("cli", () => { + const input = fixture("cli.bend", USERLAND); + const first = module( + "first.js", + `[{ id: "cli/first", facts: true, run(cx) { + if (!cx.facts?.length || !cx.sources.length) throw new Error("missing metadata"); + return [cx.diag({ message: "First rule", severity: "warning" })]; + } }]`, + ); + const second = module( + "second.ts", + `[{ id: "cli/second", run(cx) { + if (cx.prior[0]?.code !== "cli/first") throw new Error("wrong rule order"); + return [cx.diag({ message: "Second rule", severity: "hint" })]; + } }]`, + ); + + test("rules run in order and print bend's layout", () => { + const out = run(input, "--rules", first, "--rules", second); + expect(out.status).toBe(0); + expect(out.stdout).toMatch(/Warning \[cli\/first\]:[\s\S]*Hint \[cli\/second\]:/); + }); + + test("an error finding, or a failed check, exits 1", () => { + const blocking = module( + "error.js", + `[{ id: "cli/error", run: (cx) => [cx.diag({ message: "Blocked", severity: "error" })] }]`, + ); + expect(run(input, "--rules", blocking)).toMatchObject({ + status: 1, + stdout: expect.stringContaining("Error [cli/error]:"), + }); + expect(run(fixture("cli_bad.bend", "def broken(\n"))).toMatchObject({ + status: 1, + stdout: expect.stringContaining("Error [bend/check]:"), + }); + }); + + test("bad usage and bad modules exit 2", () => { + expect(run().status).toBe(2); + expect(run(input, "--rules").status).toBe(2); + expect(run(input, "--nope").status).toBe(2); + expect(run(input, "--rules", path.join(DIR, "missing.js")).status).toBe(2); + expect(run(input, "--rules", module("object.js", "{}")).stderr).toMatch(/must export `rules`/); + expect( + run(input, "--rules", module("bad.js", `[{ id: "bad", run() { return []; } }]`)).stderr, + ).toMatch(/invalid rule/); + expect( + run( + input, + "--rules", + fixture("no_id.bend", 'import Base\n\ndef main() -> IO(Unit):\n IO.print("x")\n'), + ).stderr, + ).toMatch(/must define id\(\) -> String/); + expect(run(input, "--rules", fixture("broken_rule.bend", "def broken(\n")).stderr).toMatch( + /does not check/, + ); + expect( + run( + input, + "--rules", + fixture( + "no_facts.bend", + COMMA_BEND.replace("def facts() -> Lint.Want:\n Lint.NoFacts{}\n", ""), + ), + ).stderr, + ).toMatch(/must define id\(\) -> String, facts\(\) -> Lint.Want/); + }); + + test("a rule written in Bend runs from the CLI", () => { + const out = run(input, "--rules", fixture("cli_comma_rule.bend", COMMA_BEND)); + expect(out.status).toBe(0); + expect(out.stdout).toContain("Warning [style/comma-space]:"); + }); + + test("--json prints findings with LSP ranges, and nothing else", () => { + const comma = module( + "json_comma.js", + `[{ id: "style/comma-space", run: (cx) => + [...cx.root.text.matchAll(/,(?=\\w)/g)].map((m) => { + const span = { file: cx.root, beg: m.index + 1, end: m.index + 1 }; + return cx.diag({ message: "space", severity: "hint", span, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ span, text: " " }] }] }); + }) }]`, + ); + const out = run(input, "--rules", comma, "--json"); + expect(out.status).toBe(0); + const json = JSON.parse(out.stdout); + const at = { line: 25, character: 13 }; + expect(json).toEqual({ + ok: true, + findings: [ + { + code: "style/comma-space", + severity: "hint", + message: "space", + path: fs.realpathSync(input).replaceAll("\\", "/"), + range: { start: at, end: at }, + fixes: [ + { + title: "Insert space", + applicability: "safe", + edits: [ + { + path: fs.realpathSync(input).replaceAll("\\", "/"), + range: { start: at, end: at }, + text: " ", + }, + ], + }, + ], + }, + ], + }); + expect(USERLAND.split("\n")[at.line].slice(0, at.character)).toBe(" generic(~N,"); + const bad = JSON.parse(run(fixture("json_bad.bend", "def broken(\n"), "--json").stdout); + expect([bad.ok, bad.findings[0].code]).toEqual([false, "bend/check"]); + }); + + test("--bend and BEND_DIR choose the bend to load; a wrong one exits 2", () => { + expect(run(input, "--bend", path.dirname(BEND2)).status).toBe(0); + const env = spawnSync(process.execPath, [CLI, input], { + encoding: "utf8", + env: { ...process.env, BEND_DIR: BEND2 }, + }); + expect(env.status).toBe(0); + expect(run(input, "--bend", DIR)).toMatchObject({ + status: 2, + stderr: expect.stringContaining("no bend2 at"), + }); + }); + + test("--config selects JSON, JS or TS explicitly", () => { + const echoes = module( + "echo.js", + `[{ id: "test/echo", options: { tabWidth: 2 }, run: (cx) => [cx.diag({ message: "w" + cx.options.tabWidth })] }]`, + ); + const settings = JSON.stringify({ rules: { "test/echo": { tabWidth: 6, severity: "hint" } } }); + for (const extension of ["json", "js", "ts"]) { + const config = fixture( + "cli_config." + extension, + extension === "json" ? settings : "export const config = " + settings + ";", + ); + const out = run(input, "--rules", echoes, "--config", config, "--json"); + expect(out.status).toBe(0); + expect( + JSON.parse(out.stdout).findings.map((f: { severity: string; message: string }) => [ + f.severity, + f.message, + ]), + ).toEqual([["hint", "w6"]]); + } + const invalid = fixture("cli_default_config.ts", "export default {};"); + expect(run(input, "--config", invalid)).toMatchObject({ + status: 2, + stderr: expect.stringContaining("must export a named `config` object"), + }); + }); + + test("--fix writes only the linted file, and prints findings when fixes clash", () => { + const dir = path.join(DIR, "fixroot"); + fs.mkdirSync(dir, { recursive: true }); + const dep = path.join(dir, "dep.bend"); + fs.writeFileSync(dep, "def one() -> Type:\n Type\n"); + const main = path.join(dir, "main.bend"); + fs.writeFileSync(main, "import ./dep.bend as D\n\ndef main() -> Type:\n D.one()\n"); + const everywhere = module( + "everywhere.js", + `[{ id: "test/everywhere", run: (cx) => cx.sources.filter((s) => !s.base).flatMap((s) => [0, 0, 1].map((n) => { + const span = { file: s, beg: n, end: n }; + return cx.diag({ message: "x", span, fixes: [{ title: "x", applicability: "safe", edits: [{ span, text: n === 0 ? "#" : "!" }] }] }); + })) }]`, + ); + const out = run(main, "--rules", everywhere, "--fix"); + expect(out.status).toBe(0); + expect(out.stdout).toContain("Warning [test/everywhere]:"); + expect(fs.readFileSync(dep, "utf8")).toBe("def one() -> Type:\n Type\n"); + expect(fs.readFileSync(main, "utf8").startsWith("#i!mport")).toBe(true); + expect(out.stderr).not.toContain("skipped"); + }); + + test("--fix applies fixes even when a finding is an error", () => { + const target = fixture("fix_error.bend", "#abc\n"); + const strict = module( + "strict.js", + `[{ id: "test/strict", run: (cx) => { + const span = { file: cx.root, beg: 0, end: 0 }; + return [cx.diag({ message: "e", severity: "error", span, fixes: [{ title: "e", applicability: "safe", edits: [{ span, text: "!" }] }] })]; + } }]`, + ); + const out = run(target, "--rules", strict, "--fix"); + expect(out.status).toBe(1); + expect(out.stdout).toContain("bend-lint: FAIL"); + expect(fs.readFileSync(target, "utf8")).toBe("!#abc\n"); + }); + + test("--fix-suggested and --fix-dangerously apply wider levels", () => { + const levels = module( + "levels.js", + `[{ id: "test/levels", run: (cx) => ["safe", "suggested", "dangerous"].map((applicability, n) => { + const span = { file: cx.root, beg: n, end: n }; + return cx.diag({ message: applicability, span, fixes: [{ title: applicability, applicability, edits: [{ span, text: String(n) }] }] }); + }) }]`, + ); + const fixed = (flag: string) => { + const target = fixture("levels" + flag + ".bend", "#abc\n"); + expect(run(target, "--rules", levels, flag).status).toBe(0); + return fs.readFileSync(target, "utf8"); + }; + expect(["--fix", "--fix-suggested", "--fix-dangerously"].map(fixed)).toEqual([ + "0#abc\n", + "0#1abc\n", + "0#1a2bc\n", + ]); + }); + + test("--fix applies safe fixes", () => { + const target = fixture("fix.bend", USERLAND); + const comma = module( + "comma.js", + `[{ id: "style/comma-space", run: (cx) => + [...cx.root.text.matchAll(/,(?=\\w)/g)].map((m) => { + const span = { file: cx.root, beg: m.index + 1, end: m.index + 1 }; + return cx.diag({ message: "space", severity: "hint", span, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ span, text: " " }] }] }); + }) }]`, + ); + expect(run(target, "--rules", comma, "--fix").status).toBe(0); + expect(fs.readFileSync(target, "utf8")).toContain("generic(~N, a)"); + }); +}); diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts new file mode 100644 index 0000000000..934f0af637 --- /dev/null +++ b/tools/bend-lint/src/lint.ts @@ -0,0 +1,819 @@ +#!/usr/bin/env bun +// bend-lint checks a Bend file with bend's checker, then runs rules over +// its source and the checker's results. Rules are TS modules, or Bend files +// built on ./bend/lint.bend. It reaches bend2 only through ./seam.ts; anything +// there it cannot follow stops it with a drift error. The types below are +// bend-lint's own, so a rule does not depend on bend2's internals. As a +// CLI, it exits 0 when ok, 1 when it found an error, 2 on bad usage or a +// tool failure. + +import * as url from "node:url"; +import * as util from "node:util"; + +import { + check, + compile, + fs, + layout, + line, + load, + operations, + path, + select, + starts, +} from "./seam.ts"; +import type { Unstable } from "./seam.ts"; + +// Types +// ===== + +export type Severity = "error" | "warning" | "information" | "hint"; +// safe keeps behavior; suggested may change it; dangerous may break code. +export type Applicability = "safe" | "suggested" | "dangerous"; + +// A file of the book, as it is on disk (or as the editor holds it unsaved). +export type Source = { path: string; text: string; root: boolean; base: boolean }; + +// A range of a source, in UTF-16 offsets. +export type Span = { file: Source; beg: number; end: number }; + +export type Edit = { span: Span; text: string }; +export type Fix = { title: string; applicability: Applicability; edits: Edit[] }; + +declare const OPAQUE: unique symbol; + +// A checked term, a type, and a node of a checked body, as handles: what +// they hold is bend2's, and a rule reaches it only through the RuleContext. +export type Fact = { readonly [OPAQUE]: "fact" }; +export type Type = { readonly [OPAQUE]: "type" }; +export type Node = { readonly [OPAQUE]: "node" }; + +// A node: its kind, annotations kept (Ann, Var, App, ...), the name a Var +// or Ref points to (else ""), its span, and its children, in bend's order. +export type Shape = { kind: string; name: string; span?: Span; children: Node[] }; + +// How many times a term is demanded, or a variable is used. +export type Quantity = "erased" | "once" | "many"; + +// A fact's term: its kind (annotations stripped: Var, Ref, App, ...), the +// name a Var or Ref points to, the def whose body holds it, and how many +// times it is demanded. A template body is checked as written, then again +// for each instance (generic~0) at the same spans; `inst` marks the facts +// of an instance. `inner` is the term's span without its annotations. +export type View = { + owner: string; + inst: boolean; + kind: string; + name: string; + quantity: Quantity; + span?: Span; + inner?: Span; +}; + +export type Diag = { + code: string; + severity: Severity; + message: string; + span?: Span; + def?: string; + fact?: Fact; // its scope shows as the finding's context + fixes: Fix[]; + core?: unknown; // bend's own failure, when the check failed +}; + +export type DiagInit = { + message: string; + severity?: Severity; + span?: Span; + fact?: Fact; + def?: string; + fixes?: Fix[]; +}; + +// The checker's facts a rule gets. A fact must match each list given; an +// absent or empty list matches all. In defs, a template's instances +// (generic~0) count as the template. +export type FactFilter = { + scope?: "file" | "program"; // file (the default): the linted file; program: its imports too, never Base + kinds?: string[]; // the term's kind, annotations stripped: Var, Ref, App, ... + defs?: string[]; // the def whose body holds the term + names?: string[]; // the name a Var or Ref points to +}; + +// `type`, `binder`, `same`, `show` and `normal` work in the fact's scope. +// `unstable` is bend2's own objects: code that uses it breaks when bend2 +// changes. +export type RuleContext = { + sources: Source[]; + root: Source; + options: Options; // the rule's defaults, with the config's values + facts?: Fact[]; // only for a rule with facts, and only those it asked for + prior: readonly Diag[]; // what earlier rules found + view(fact: Fact): View; + type(fact: Fact): Type; + binder(fact: Fact): Type | undefined; // the declared type of the variable a Var fact uses + same(fact: Fact, a: Type, b: Type): boolean; + show(fact: Fact, type: Type): string; + normal(fact: Fact, type: Type): Type; + uses(fact: Fact): Array<{ name: string; quantity: Quantity }>; + sameDeclarations(text: string): boolean; // whether `text` declares what the linted file declares + body(name: string): Node | undefined; // a def's checked body + node(fact: Fact): Node; // the node a fact is about + shape(node: Node): Shape; + fact(node: Node): Fact | undefined; // the node's fact, if this rule asked for it + diag(init: DiagInit): Diag; + unstable: Unstable; +}; + +// A rule option's value. A default's type is its option's type. +export type OptionValue = number | boolean | string; +export type Options = Record; + +// Config: per rule id, "off", or a severity and option values. +export type Config = { rules?: Record }; + +// `unsaved` maps file paths to text an editor holds but has not saved. bend +// and the rules read it in place of the file, for that run only. +export type LintOptions = { + signal?: AbortSignal; + config?: Config; + unsaved?: ReadonlyMap; +}; + +export type LintRule = { + id: string; // namespace/name; the code of its findings + facts?: true | FactFilter; // the checker's facts it needs; true: all of the linted file's + options?: Options; // defaults; without them (a Bend rule), any option is passed as given + run(cx: RuleContext, signal: AbortSignal): Diag[] | Promise; +}; + +export type LintResult = { + ok: boolean; + diags: Diag[]; + sources: Source[]; + facts?: Fact[]; + unstable: Unstable; +}; + +// An LSP position: 0-based line and character, in UTF-16 units. +export type Position = { line: number; character: number }; + +// A span as lint.bend has it: a file's path and two code-point offsets. +type Spot = { path: string; beg: number; end: number }; + +// What a Bend rule reports, as effects.js reads it. +type Reported = { + severity: Severity; + message: string; + span?: Spot; + fixes: Array<{ + title: string; + applicability: Applicability; + edits: Array<{ span: Spot; text: string }>; + }>; +}; + +// What effects.js asks of bend-lint while a Bend rule runs. Facts and types +// cross as indexes into the run's tables. +type Channel = { + input(): { sources: Array<{ path: string; text: string; root: boolean }>; options: Options }; + next(): number | undefined; + report(diags: Reported[]): void; + view(fact: number): Omit & { span?: Spot; inner?: Spot }; + type(fact: number): number; + binder(fact: number): number | undefined; + same(fact: number, a: number, b: number): boolean; + show(fact: number, t: number): string; + normal(fact: number, t: number): number; + uses(fact: number): Array<{ name: string; quantity: Quantity }>; + text(span: Spot): string; + body(name: string): number | undefined; + node(fact: number): number; + shape(node: number): Omit & { span?: Spot; children: number[] }; + fact(node: number): number | undefined; +}; + +// Constants +// ========= + +const RULE_ID = /^[^/\s]+\/[^/\s]+$/; +const SCOPES = ["file", "program"]; +const CONFIG_FILES = ["bend-lint.json", "bend-lint.js", "bend-lint.ts"]; +const LINE = /[^\n]*\n|[^\n]+$/g; +const HEAD: Record = { + error: "Error", + warning: "Warning", + information: "Information", + hint: "Hint", +}; + +const shared = globalThis as typeof globalThis & { BEND_LINT?: Channel }; + +const OPTIONS = { + rules: { type: "string", multiple: true }, + fix: { type: "boolean" }, + "fix-suggested": { type: "boolean" }, + "fix-dangerously": { type: "boolean" }, + json: { type: "boolean" }, + config: { type: "string" }, + bend: { type: "string" }, + help: { type: "boolean", short: "h" }, +} as const; + +const USAGE = + "usage: bun src/lint.ts [--rules ]... [--config ] [--fix | --fix-suggested | --fix-dangerously] [--json] [--bend ]"; + +// The fix levels each flag applies; the widest flag given wins. +const FIXES: ReadonlyArray< + readonly ["fix" | "fix-suggested" | "fix-dangerously", Applicability[]] +> = [ + ["fix-dangerously", ["safe", "suggested", "dangerous"]], + ["fix-suggested", ["safe", "suggested"]], + ["fix", ["safe"]], +]; + +// Functions +// ========= + +export function readConfig(file: string): Config { + try { + if ([".js", ".ts"].includes(path.extname(file))) { + const module = import.meta.require(url.pathToFileURL(path.resolve(file)).href); + if (!Object.hasOwn(module, "config")) { + throw new Error("must export a named `config` object"); + } + if ( + typeof module.config !== "object" || + module.config === null || + Array.isArray(module.config) + ) { + throw new Error("`config` must be an object"); + } + return module.config; + } + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch (e) { + throw new Error(file + ": " + (e instanceof Error ? e.message : String(e))); + } +} + +// The nearest config; JSON, JS, then TS within each directory. +export function findConfig(file: string): Config { + for (let dir = path.dirname(path.resolve(file)); ; dir = path.dirname(dir)) { + const config = CONFIG_FILES.map((name) => path.join(dir, name)).find((candidate) => + fs.existsSync(candidate), + ); + if (config !== undefined) { + return readConfig(config); + } + if (path.dirname(dir) === dir) { + return {}; + } + } +} + +// What the config says for a rule: off, a severity, and its options (the +// defaults, with the given values, which must be known and of their type). +function settings( + rule: LintRule, + config: Config, +): { off: boolean; severity?: Severity; options: Options } { + const given = config.rules?.[rule.id]; + const where = "bend-lint config: " + rule.id; + if (given === undefined || given === "off") { + return { off: given === "off", options: { ...rule.options } }; + } + if (typeof given !== "object" || given === null) { + throw new Error(where + ' must be "off" or an object'); + } + const { severity, ...options } = given; + if (severity !== undefined && !Object.hasOwn(HEAD, severity)) { + throw new Error(where + ": severity must be one of " + Object.keys(HEAD).join(", ")); + } + for (const [key, value] of Object.entries(options)) { + const want = rule.options === undefined ? typeof value : typeof rule.options[key]; + if (rule.options !== undefined && !Object.hasOwn(rule.options, key)) { + throw new Error(where + " has no option " + key); + } + if (typeof value !== want || !["number", "boolean", "string"].includes(want)) { + throw new Error( + where + + ": " + + key + + " must be a " + + (rule.options === undefined ? "number, boolean or string" : want), + ); + } + } + return { off: false, severity, options: { ...rule.options, ...options } }; +} + +// Rules run in order; the config may turn one off, set its severity, and +// give its options. A finding with severity error stops the run. A rule +// that throws, and an abort, reach the caller. +export async function lint( + file: string, + rules: LintRule[], + { + signal = new AbortController().signal, + config = findConfig(file), + unsaved: held, + }: LintOptions = {}, +): Promise { + const strings = (xs: unknown): boolean => + xs === undefined || (Array.isArray(xs) && xs.every((x) => typeof x === "string")); + const bad = rules.findIndex( + (r) => + !RULE_ID.test(String(r?.id)) || + typeof r?.run !== "function" || + !( + r.facts === undefined || + r.facts === true || + (typeof r.facts === "object" && + r.facts !== null && + (r.facts.scope === undefined || SCOPES.includes(r.facts.scope)) && + strings(r.facts.kinds) && + strings(r.facts.defs) && + strings(r.facts.names)) + ), + ); + if (bad >= 0) { + throw new TypeError( + "invalid rule at " + + bad + + " (" + + JSON.stringify(rules[bad]?.id) + + "): it needs an id like ns/name, a run function, and facts, if given, true or a FactFilter", + ); + } + const plans = rules + .map((rule) => ({ + rule, + ...settings(rule, config), + want: rule.facts === true ? {} : rule.facts, + })) + .filter((p) => !p.off); + const program = plans.some((p) => p.want?.scope === "program"); + const checked = await check( + loaded, + file, + plans.flatMap((p) => (p.want === undefined ? [] : [p.want])), + signal, + held, + ); + const { sources, facts, failure } = checked; + const ops = operations(loaded, checked); + if (failure !== undefined) { + return { ok: false, diags: [failure], sources, unstable: ops.unstable }; + } + const root = sources.find((s) => s.root)!; + let diags: Diag[] = []; + for (const { rule, severity, options, want } of plans) { + signal.throwIfAborted(); + const mine = want === undefined ? undefined : select(loaded, checked, want, program); + const asked = new Set(mine); + const out = await rule.run( + { + ...ops, + sources, + root, + options, + facts: mine, + fact: (node) => { + const fact = ops.fact(node); + return fact !== undefined && asked.has(fact) ? fact : undefined; + }, + prior: diags, + diag: (d) => ({ + code: rule.id, + severity: d.severity ?? "warning", + message: d.message, + span: d.span, + def: d.def ?? (d.fact && ops.view(d.fact).owner), + fact: d.fact, + fixes: d.fixes ?? [], + }), + }, + signal, + ); + signal.throwIfAborted(); + if (!Array.isArray(out)) { + throw new TypeError("rule " + rule.id + " must return an array of diagnostics"); + } + const settled = out.map((d): Diag => ({ + ...d, + code: rule.id, + severity: severity ?? d.severity, + })); + const broken = settled + .flatMap((d) => d.fixes) + .find((f) => + f.edits.some( + ({ span }, i) => + !validRange(span.beg, span.end, span.file.text.length) || + f.edits.some((o, j) => j !== i && clash(f.edits[i], o)), + ), + ); + if (broken !== undefined) { + throw new TypeError( + "rule " + + rule.id + + ': fix "' + + broken.title + + '" has an edit out of bounds, or two that clash', + ); + } + const stop = settled.findIndex((d) => d.severity === "error"); + diags = [...diags, ...(stop < 0 ? settled : settled.slice(0, stop + 1))]; + if (stop >= 0) { + return { ok: false, diags, sources, facts, unstable: ops.unstable }; + } + } + return { ok: true, diags, sources, facts, unstable: ops.unstable }; +} + +function validRange(beg: number, end: number, length: number): boolean { + return Number.isInteger(beg) && Number.isInteger(end) && beg >= 0 && beg <= end && end <= length; +} + +// Whether two edits to one file cannot both apply: their ranges overlap, +// or they insert at the same point. +function clash(a: Edit, b: Edit): boolean { + return ( + a.span.file === b.span.file && + ((a.span.beg < b.span.end && b.span.beg < a.span.end) || + (a.span.beg === b.span.beg && a.span.end === b.span.end)) + ); +} + +// `text` with edits applied; their spans are offsets into `text`, and they +// do not clash. +function apply(text: string, edits: Edit[]): string { + return [...edits] + .sort((a, b) => b.span.beg - a.span.beg || b.span.end - a.span.end) + .reduce((s, { span, text: t }) => s.slice(0, span.beg) + t + s.slice(span.end), text); +} + +// The text of `file` with the fixes of the given levels applied, in order. +// An edit equal to one already taken is merged; a fix with an edit that +// clashes with one already taken is skipped and counted. A fix for other +// files is left out; one that edits `file` and another file is skipped +// whole, never half applied, and counted apart. +export function applyFixes( + file: Source, + diags: Diag[], + levels: Applicability[] = ["safe"], +): { text: string; skipped: number; elsewhere: number } { + const kept: Edit[] = []; + let skipped = 0; + let elsewhere = 0; + for (const fix of diags.flatMap((d) => d.fixes).filter((f) => levels.includes(f.applicability))) { + if (fix.edits.every((e) => e.span.file !== file)) { + continue; + } + if (fix.edits.some((e) => e.span.file !== file)) { + elsewhere += 1; + continue; + } + const fresh = fix.edits.filter( + (e) => + !kept.some( + (k) => k.span.beg === e.span.beg && k.span.end === e.span.end && k.text === e.text, + ), + ); + if (fresh.some((e) => kept.some((k) => clash(k, e)))) { + skipped += 1; + } else { + kept.push(...fresh); + } + } + return { text: apply(file.text, kept), skipped, elsewhere }; +} + +// bend's own error layout; the head names the severity and the code, and +// each fix follows as a unified diff of the lines it touches. +export function render(d: Diag): string { + const fixes = d.fixes.map( + (fix) => + "\n\nFix: " + + fix.title + + " [" + + fix.applicability + + "]" + + [...new Set(fix.edits.map((e) => e.span.file))] + .map((file) => { + const mine = fix.edits.filter((e) => e.span.file === file); + const ls = starts(file, file.text); + const first = line(ls, Math.min(...mine.map((e) => e.span.beg))); + const from = ls[first]; + const to = ls[line(ls, Math.max(...mine.map((e) => e.span.end))) + 1] ?? file.text.length; + const old = file.text.slice(from, to); + const gone = old.match(LINE) ?? []; + const came = + apply( + old, + mine.map((e) => ({ + ...e, + span: { ...e.span, beg: e.span.beg - from, end: e.span.end - from }, + })), + ).match(LINE) ?? []; + const name = file.path; + const range = (n: number): string => (n === 0 ? first : first + 1) + "," + n; + const show = (xs: string[], sign: string): string[] => + xs.map( + (l) => + sign + + l.replace(/\r?\n$/, "") + + (l.endsWith("\n") ? "" : "\n\\ No newline at end of file"), + ); + return ( + "\n--- " + + name + + "\n+++ " + + name + + "\n@@ -" + + range(gone.length) + + " +" + + range(came.length) + + " @@\n" + + [...show(gone, "-"), ...show(came, "+")].join("\n") + ); + }) + .join(""), + ); + return layout(loaded, d, HEAD[d.severity] + " [" + d.code + "]:") + fixes.join(""); +} + +// The LSP range of a span in a file on disk. +export function position(span: Span): { start: Position; end: Position } { + const ss = starts(span.file, span.file.text); + const at = (off: number): Position => { + const i = line(ss, off); + return { line: i, character: off - ss[i] }; + }; + return { start: at(span.beg), end: at(span.end) }; +} + +// A rule written in Bend: a file built on ./bend/lint.bend (see there). It is +// checked and compiled once; each run calls its main, while effects.js +// reaches bend-lint through globalThis.BEND_LINT. Offsets cross as code points. +export async function bendRule(file: string): Promise { + const checked = await check(loaded, file, [], new AbortController().signal); + if (checked.failure !== undefined) { + throw new Error(file + " does not check:\n" + render(checked.failure)); + } + const { id, want, main } = compile(loaded, checked, file); + return { + id, + ...(want === null ? {} : { facts: want }), + run: (cx) => { + const tables = new Map(); + const table = (text: string): { points: number[]; units: number[] } => { + const known = tables.get(text); + if (known !== undefined) { + return known; + } + const points: number[] = []; + const units: number[] = []; + for (const ch of text) { + units.push(points.length); + points.push(...Array(ch.length).fill(units.length - 1)); + } + units.push(points.length); + points.push(units.length - 1); + tables.set(text, { points, units }); + return { points, units }; + }; + const spot = (span: Span | undefined): Spot | undefined => { + const t = span && table(span.file.text); + return ( + span && t && { path: span.file.path, beg: t.points[span.beg], end: t.points[span.end] } + ); + }; + const span = (s: Spot): Span => { + const src = cx.sources.find((x) => x.path === s.path); + if (src === undefined) { + throw new Error( + "rule " + id + " reported a span in " + s.path + ", which is not in the book", + ); + } + const { units } = table(src.text); + const at = (n: number): number => units[Math.min(n, units.length - 1)]; + return { file: src, beg: at(s.beg), end: at(s.end) }; + }; + const facts = cx.facts ?? []; + let given = 0; + const types: Type[] = []; + const nodes: Node[] = []; + const index = new Map(facts.map((f, i) => [f, i])); + const pick = (xs: T[], i: number, what: string): T => { + if (xs[i] === undefined) { + throw new Error( + "rule " + id + " asked about " + what + " " + i + ", which it was not given", + ); + } + return xs[i]; + }; + const keep = (t: Type): number => types.push(t) - 1; + const hold = (n: Node): number => nodes.push(n) - 1; + const found: Reported[][] = []; + shared.BEND_LINT = { + input: () => { + const odd = Object.entries(cx.options).find( + ([, v]) => typeof v === "number" && !(Number.isInteger(v) && v >= 0 && v <= 0xffffffff), + ); + if (odd !== undefined) { + throw new Error( + "rule " + + id + + ": option " + + odd[0] + + " must be a whole number from 0 to 4294967295 (a U32)", + ); + } + return { + sources: cx.sources.map((s) => ({ path: s.path, text: s.text, root: s.root })), + options: cx.options, + }; + }, + next: () => (given < facts.length ? given++ : undefined), + report: (diags) => void found.push(diags), + view: (i) => { + const { span, inner, ...rest } = cx.view(pick(facts, i, "fact")); + return { ...rest, span: spot(span), inner: spot(inner) }; + }, + type: (i) => keep(cx.type(pick(facts, i, "fact"))), + binder: (i) => { + const t = cx.binder(pick(facts, i, "fact")); + return t === undefined ? undefined : keep(t); + }, + same: (i, a, b) => + cx.same(pick(facts, i, "fact"), pick(types, a, "term"), pick(types, b, "term")), + show: (i, t) => cx.show(pick(facts, i, "fact"), pick(types, t, "term")), + normal: (i, t) => keep(cx.normal(pick(facts, i, "fact"), pick(types, t, "term"))), + uses: (i) => cx.uses(pick(facts, i, "fact")), + text: (s) => { + const { file, beg, end } = span(s); + return file.text.slice(beg, end); + }, + body: (name) => { + const n = cx.body(name); + return n === undefined ? undefined : hold(n); + }, + node: (i) => hold(cx.node(pick(facts, i, "fact"))), + shape: (i) => { + const { span, children, ...rest } = cx.shape(pick(nodes, i, "node")); + return { ...rest, span: spot(span), children: children.map(hold) }; + }, + fact: (i) => { + const f = cx.fact(pick(nodes, i, "node")); + return f === undefined ? undefined : index.get(f); + }, + }; + let code: number; + try { + code = main([file]); + } finally { + shared.BEND_LINT = undefined; + } + if (code !== 0 || found.length !== 1) { + throw new Error( + "rule " + + id + + " exited with " + + code + + " after " + + found.length + + " reports; it must report once", + ); + } + return found[0].map((d) => + cx.diag({ + message: d.message, + severity: d.severity, + span: d.span && span(d.span), + fixes: d.fixes.map((f) => ({ + ...f, + edits: f.edits.map((e) => { + const at = span(e.span); + if (!validRange(e.span.beg, e.span.end, table(at.file.text).units.length - 1)) { + throw new TypeError( + "rule " + id + ': fix "' + f.title + '" has an edit out of bounds', + ); + } + return { span: at, text: e.text }; + }), + })), + }), + ); + }, + }; +} + +async function cli(argv: string[]): Promise { + const { values, positionals } = util.parseArgs({ + args: argv, + options: OPTIONS, + allowPositionals: true, + }); + if (values.help) { + console.log(USAGE); + return 0; + } + if (positionals.length !== 1) { + throw new Error("give one .bend file\n" + USAGE); + } + const modules = await Promise.all( + (values.rules ?? []).map(async (file): Promise => { + if (file.endsWith(".bend")) { + return [await bendRule(file)]; + } + const { rules } = await import(url.pathToFileURL(path.resolve(file)).href); + if (!Array.isArray(rules)) { + throw new Error(file + " must export `rules`, an array of rules"); + } + return rules; + }), + ); + const res = await lint(positionals[0], modules.flat(), { + config: values.config === undefined ? undefined : readConfig(values.config), + }); + const root = res.sources.find((s) => s.root); + const levels = FIXES.find(([flag]) => values[flag])?.[1]; + const fixed = + levels !== undefined && root !== undefined ? applyFixes(root, res.diags, levels) : undefined; + const where = (span: Span) => ({ path: span.file.path, range: position(span) }); + console.log( + values.json + ? JSON.stringify( + { + ok: res.ok, + findings: res.diags.map((d) => ({ + code: d.code, + severity: d.severity, + message: d.message, + def: d.def, + ...(d.span && where(d.span)), + fixes: d.fixes.map((f) => ({ + ...f, + edits: f.edits.map((e) => ({ ...where(e.span), text: e.text })), + })), + })), + }, + null, + 2, + ) + : [ + ...res.diags.map(render), + res.ok ? "bend-lint: " + res.diags.length + " finding(s)" : "bend-lint: FAIL", + ].join("\n\n"), + ); + if (root !== undefined && fixed !== undefined && fixed.text !== root.text) { + fs.writeFileSync(root.path, fixed.text); + console.error("bend-lint: fixed " + root.path); + } + if (fixed !== undefined && fixed.skipped > 0) { + console.error( + "bend-lint: skipped " + + fixed.skipped + + " fix(es) that clash with earlier ones; run the fix again to apply them", + ); + } + if (fixed !== undefined && fixed.elsewhere > 0) { + console.error( + "bend-lint: skipped " + + fixed.elsewhere + + " fix(es) that also edit other files; --fix writes only " + + root!.path, + ); + } + return res.ok ? 0 : 1; +} + +function fail(e: unknown): never { + console.error("bend-lint: " + (e instanceof Error ? e.message : String(e))); + process.exit(2); +} + +// Side effects +// ============ + +// The CLI's --bend is read here, before bend loads; a library uses $BEND_DIR. +const given = import.meta.main + ? util.parseArgs({ + args: process.argv.slice(2), + options: OPTIONS, + allowPositionals: true, + strict: false, + }).values.bend + : undefined; + +const loaded = await load(typeof given === "string" ? given : undefined).catch((e: unknown) => + import.meta.main ? fail(e) : Promise.reject(e), +); + +export const { BEND2 } = loaded; + +if (import.meta.main) { + process.exit(await cli(process.argv.slice(2)).catch(fail)); +} diff --git a/tools/bend-lint/src/seam.ts b/tools/bend-lint/src/seam.ts new file mode 100644 index 0000000000..d321d7732d --- /dev/null +++ b/tools/bend-lint/src/seam.ts @@ -0,0 +1,1060 @@ +// Everything bend-lint knows about bend2's internals: finding (or +// downloading) bend2, patching it as Bun loads it, and every call into it. +// The docs say what the patch does and how drift stops it. Each text edit +// must match exactly once; the wrappers pass every argument through, so +// bend computes what it would without them. + +import * as nodeFs from "node:fs"; +import * as os from "node:os"; +import * as nodePath from "node:path"; +import * as url from "node:url"; + +import type { Book, Ctx, Err, HTerm, LTerm, Name, Quant, Uses } from "bend2/bend.ts"; +import type { Span as BendSpan } from "bend2/bend.ts"; +import type * as BendModule from "bend2/bend.ts"; +import type * as CompModule from "bend2/comp.ts"; +import type { + Diag, + Fact, + FactFilter, + Node, + Quantity, + RuleContext, + Shape, + Source, + Span, + Type, + View, +} from "./lint.ts"; + +// Types +// ===== + +// A file's patch: exact edits, text it must hold once unchanged (needs), a +// tail appended to the file, and names it must declare once and export (the +// tail exports those it does not). +type Patch = { edits: Array<[string, string]>; needs?: string[]; tail: string; exports: string[] }; + +export type Get = (url: string) => Promise; + +// How bendDir reaches the network, runs `bend`, where it caches, and where +// the bend repo would be; tests give their own. run gives a command's +// stdout, or undefined if it fails. +export type FindOptions = { + get?: Get; + run?: (cmd: string[]) => string | undefined; + cache?: string; + repo?: string; +}; + +// comp.ts as the patch exports it. +type Comp = typeof CompModule & { RUNTIME_MAIN: string; js_sat(k: Name): string }; + +// What bend-lint uses of main.ts, as the patch exports it: book_read throws +// a Check_Fail holding why the check failed. +type Main = { + book_read(file: string, base?: Book, seen?: Map): Promise; + book_err(e: unknown): string; + Check_Fail: new (why: unknown) => { why: unknown }; +}; + +// bend2 as load gives it: the patched modules and their folder. +export type Loaded = { Bend: typeof BendModule; Comp: Comp; Main: Main; BEND2: string }; + +// A source as bend.ts spans point at it. +type File = { str: string; ns: string; al: Record; path: string }; + +type Mapper = (s: BendSpan | undefined) => BendSpan | undefined; + +// A fact as the checker gives it: `tm` checked (or inferred) as `ty` at +// depth `dep` in `ctx`, in def `def`, demanded `qt` times, using the +// variables in `us`, in book `bok` (a template body has its own). `inst` +// marks an instance's fact. +export type Raw = { + tm: LTerm; + ty: HTerm; + bok: Book; + ctx: Ctx; + dep: number; + def: Name; + qt: Quant; + us: Uses; + inst: boolean; + spn?: BendSpan; +}; + +// What the wrappers report for each checked term. +type Report = Omit; + +// bend2's own objects, for code that accepts to break when bend2 changes. +export type Unstable = { Bend: typeof BendModule; book: Book; raw: (fact: Fact) => Raw }; + +// The rule context's operations that ask bend2. +export type Operations = Omit< + RuleContext, + "sources" | "root" | "options" | "facts" | "prior" | "diag" +>; + +export type Checked = { + book: Book; + sources: Source[]; + map: Mapper; + facts?: Fact[]; + failure?: Diag; +}; + +// A rule written in Bend, checked and compiled: what its id(), facts() and +// main() give. +type Compiled = { id: string; want: FactFilter | null; main: (args: string[]) => number }; + +// Constants +// ========= + +// Marks an error as drift: bend2 changed in a way bend-lint does not follow. +export const DRIFT = Symbol("DriftError"); + +const HERE = url.fileURLToPath(new URL(".", import.meta.url)); +const DRIVE = /^[A-Za-z]:(?=\/)/; +const SHIM = JSON.stringify(url.pathToFileURL(nodePath.join(HERE, "seam.ts")).href); +const SAMPLE = nodePath.join(HERE, "bend", "sample.bend"); + +export const MARK = "BEND_LINT_PATCH"; + +const GIT = "https://github.com/bendlang/bend.git/info/refs?service=git-upload-pack"; +const RAW = "https://raw.githubusercontent.com/bendlang/bend/"; +const RELEASE = /^v\d+\.\d+\.\d+$/; +const DAY = 24 * 60 * 60 * 1000; +const EFF = /^effs\/[\w.-]+$/; +const GIVE = "give a bend checkout with --bend or BEND_DIR"; + +// Downloaded bends, one folder per release. +const CACHE = nodePath.join( + process.env.XDG_CACHE_HOME ?? + (process.platform === "win32" + ? (process.env.LOCALAPPDATA ?? nodePath.join(os.homedir(), "AppData", "Local")) + : nodePath.join(os.homedir(), ".cache")), + "bend-lint", +); + +const SHIMMED: Array<[string, string]> = [ + ['import * as fs from "node:fs";', "import { fs } from " + SHIM + ";"], + ['import * as path from "node:path";', "import { path } from " + SHIM + ";"], +]; + +const PATCHES: Record = { + "bend.ts": { + edits: [ + ...SHIMMED, + ["export function term_infer(", "function unseen_term_infer("], + ["export function term_check(", "function unseen_term_check("], + ], + tail: `import { seeInfer, seeCheck } from ${SHIM};\nexport const term_infer = seeInfer(unseen_term_infer);\nexport const term_check = seeCheck(unseen_term_check);\n`, + exports: [], + }, + "comp.ts": { + edits: [], + needs: ["let cli_args = [];", "function io_run(m) {"], + tail: "", + exports: ["RUNTIME_MAIN", "js_sat"], + }, + "main.ts": { edits: SHIMMED, tail: "", exports: ["book_read", "book_err", "Check_Fail"] }, +}; + +// The bend2 files bend-lint patches and imports. +export const PATCHED = Object.keys(PATCHES); + +// Where the wrappers report, set for one check at a time. +export const hook: { see?: (report: Report) => void } = {}; + +// Text an editor holds unsaved, by real path, set for one check at a time. +// bend.ts reads it in place of the file on disk. +export const unsaved = new Map(); + +// The text bend.ts read in the check running now, by real path. Sources +// take their text from it, so a save during the check cannot set them +// apart from what bend parsed. +let reads: Map | undefined; + +// The checked base.bend book, with the text of base.bend it was checked +// from: Base is checked once per process, and again only if base.bend +// changes. Only the latest is kept. +let BASE: { key: string; book: Promise } | undefined; + +// The check running now, or done; the next check waits for it. +let queue: Promise = Promise.resolve(); + +const STARTS = new WeakMap(); + +// Each checked source's File, and back. +const FILES = new WeakMap(); +const SOURCES = new WeakMap(); + +const QUANTITY: Record = { None: "erased", Lone: "once", Many: "many" }; + +// Binders are explicit in checked terms, so Var.v cells are not followed. +// A new term kind is a type error here, and a drift error when walked. +const CHILDREN: { [K in LTerm["$"]]: (tm: Extract) => LTerm[] } = { + Let: (tm) => [...tm.v, tm.f], + Lam: (tm) => [tm.f], + Sub: (tm) => [tm.f], + App: (tm) => [tm.f, tm.x], + Ctr: (tm) => tm.x, + ADT: (tm) => tm.x, + Mat: (tm) => [tm.h, tm.m], + Rwt: (tm) => [tm.e, tm.p, tm.f], + Typ: (tm) => [tm.g], + Min: (tm) => [tm.a, tm.b], + All: (tm) => [tm.A, tm.B], + Eql: (tm) => [tm.a, tm.b, tm.T], + Ann: (tm) => [tm.x, tm.T], + Var: () => [], + Ref: () => [], + Qnt: () => [], + Qua: () => [], + Lit: () => [], + Efq: () => [], + Rfl: () => [], + Hol: () => [], +}; + +// Functions +// ========= + +const slash = (p: string): string => p.split(nodePath.sep).join("/"); + +const held = (p: nodeFs.PathOrFileDescriptor): string | undefined => + typeof p === "string" && unsaved.size > 0 && nodeFs.existsSync(p) + ? unsaved.get(slash(nodeFs.realpathSync(p))) + : undefined; + +// path.posix.resolve from `cwd`, where a drive letter is a root. +export const resolve = (cwd: string, ...ps: string[]): string => { + const all = [cwd, ...ps].map(slash); + const drive = + all + .filter((p) => DRIVE.test(p)) + .at(-1) + ?.slice(0, 2) ?? ""; + return drive + nodePath.posix.resolve(...all.map((p) => p.replace(DRIVE, ""))); +}; + +export const relative = (from: string, to: string): string => + nodePath.posix.relative(slash(from).replace(DRIVE, ""), slash(to).replace(DRIVE, "")); + +// fs and path for bend.ts and main.ts. +export const fs = { + ...nodeFs, + realpathSync: (p: nodeFs.PathLike): string => slash(nodeFs.realpathSync(p)), + readFileSync: ((p: nodeFs.PathOrFileDescriptor, ...rest: unknown[]) => { + const out = + held(p) ?? (nodeFs.readFileSync as (...a: unknown[]) => unknown)(p, ...rest); + if (reads !== undefined && typeof p === "string" && typeof out === "string") { + reads.set(slash(nodeFs.realpathSync(p)), out); + } + return out; + }) as typeof nodeFs.readFileSync, +}; + +export const path = { + ...nodePath, + join: (...ps: string[]): string => slash(nodePath.join(...ps)), + resolve: (...ps: string[]): string => slash(nodePath.resolve(...ps)), + posix: { + ...nodePath.posix, + resolve: (...ps: string[]): string => resolve(process.cwd(), ...ps), + relative, + }, +}; + +// f.length counts the parameters before the first default. +const arity = (f: (...args: never[]) => unknown, n: number, name: string): void => { + if (f.length !== n) { + throw drift( + `${name} takes ${f.length} parameters, not ${n}; update seeInfer and seeCheck in tools/bend-lint/src/seam.ts`, + ); + } +}; + +// term_infer and term_check as bend.ts calls them: every argument passes +// through, and each result is also reported to hook.see. tsc checks the +// names against bend.ts's signatures; a function that no longer takes the +// arguments read here stops loading. +export const seeInfer = (f: typeof BendModule.term_infer): typeof BendModule.term_infer => { + arity(f, 6, "term_infer"); + return (...args) => { + const r = f(...args); + const [bok, lhs, tm, qt, ctx, dep] = args; + hook.see?.({ tm: r.tm, ty: r.ty, bok, ctx, dep, def: lhs.def, qt, us: r.us, spn: tm.s }); + return r; + }; +}; + +export const seeCheck = (f: typeof BendModule.term_check): typeof BendModule.term_check => { + arity(f, 7, "term_check"); + return (...args) => { + const r = f(...args); + const [bok, lhs, tm, qt, ty, ctx, dep] = args; + hook.see?.({ tm: r.tm, ty, bok, ctx, dep, def: lhs.def, qt, us: r.us, spn: tm.s }); + return r; + }; +}; + +// `file` is one of PATCHED. +export const patch = (file: string, src: string): string => { + const { edits, needs = [], tail, exports } = PATCHES[file]; + const mismatch = (what: string, n: number): never => { + throw drift( + `cannot patch bend2/${file}: found ${n} of ${what}, expected 1. Update PATCHES in tools/bend-lint/src/seam.ts.`, + ); + }; + const edited = [...edits, ...needs.map((t): [string, string] => [t, t])].reduce( + (out, [at, to]) => { + const n = out.split(at).length - 1; + return n === 1 ? out.replace(at, () => to) : mismatch(JSON.stringify(at), n); + }, + src, + ); + const declared = (name: string, exported: string): RegExp => + new RegExp(`^${exported}(?:async function|function|class|const|let) ${name}\\b`, "gm"); + const missing = exports.filter((name) => { + const n = edited.match(declared(name, "(?:export )?"))?.length ?? 0; + return n === 1 + ? !declared(name, "export ").test(edited) + : mismatch("a declaration of " + name, n); + }); + const named = missing.length === 0 ? "" : `export { ${missing.join(", ")} };\n`; + return `${edited}\n${tail}${named}export const ${MARK} = 1;\n`; +}; + +export const drift = (message: string): Error => + Object.assign(new Error(message), { name: "DriftError", [DRIFT]: true }); + +const message = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +// A response's text, or a rejection naming its status. +const text = (res: Response, what: string): Promise => + res.ok ? res.text() : Promise.reject(new Error(`GitHub answered ${res.status}${what}`)); + +// fetch, given up after 30 seconds. +const download = (url: string): Promise => + fetch(url, { signal: AbortSignal.timeout(30_000) }); + +// A command's stdout, or undefined if it does not run or fails. +const spawn = (cmd: string[]): string | undefined => { + try { + const r = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "ignore" }); + return r.exitCode === 0 ? r.stdout.toString() : undefined; + } catch { + return undefined; + } +}; + +// The installed bend's release ("v2.0.36"), or undefined if `bend version` +// does not run. +export const installedTag = ( + run: (cmd: string[]) => string | undefined = spawn, +): string | undefined => { + const version = run(["bend", "version"])?.match(/^bend (\d+\.\d+\.\d+)\b/)?.[1]; + return version === undefined ? undefined : "v" + version; +}; + +// The newest release, from git's list of refs (GitHub's API limits calls). +// It asks at most once a day (a missing or damaged note asks again). +export const latestTag = async (cache: string = CACHE, get: Get = download): Promise => { + const note = nodePath.join(cache, "latest.json"); + const known = await Bun.file(note) + .json() + .catch(() => undefined); + if (RELEASE.test(String(known?.tag)) && Date.now() - Number(known?.at) < DAY) { + return String(known.tag); + } + const newest = (tags: string[]): string | undefined => + tags + .filter((t) => RELEASE.test(t)) + .map((t) => t.slice(1).split(".").map(Number)) + .sort((a, b) => b[0] - a[0] || b[1] - a[1] || b[2] - a[2]) + .map((v) => "v" + v.join("."))[0]; + const got = await get(GIT) + .then((res) => text(res, "")) + .then( + (refs) => ({ refs }), + (e: unknown) => ({ e }), + ); + if ("e" in got) { + // Offline: the newest cached release, left out of the note, so the + // next run asks again. + const cached = newest(nodeFs.existsSync(cache) ? nodeFs.readdirSync(cache) : []); + if (cached === undefined) { + throw new Error(`cannot list bend's releases (${message(got.e)}); ${GIVE}`); + } + return cached; + } + const listed = newest([...got.refs.matchAll(/refs\/tags\/(v[\d.]+)$/gm)].map((m) => m[1])); + if (listed === undefined) { + throw new Error(`bend has no release tags; ${GIVE}`); + } + nodeFs.mkdirSync(cache, { recursive: true }); + nodeFs.writeFileSync(note, JSON.stringify({ tag: listed, at: Date.now() })); + return listed; +}; + +// bend2 at a release (the PATCHED files, safe.ts, base.bend and the effs/ +// files Base imports), kept in //bend2. +// A download goes to a temporary folder first, so the cache never holds a +// partial one. +export const fetchBend = async ( + tag: string, + cache: string = CACHE, + get: Get = download, +): Promise => { + if (!RELEASE.test(tag)) { + throw new Error("not a bend release: " + tag); + } + const dir = nodePath.join(cache, tag, "bend2"); + const whole = (): boolean => + [...PATCHED, "safe.ts", "base.bend"].every((f) => nodeFs.existsSync(nodePath.join(dir, f))); + if (whole()) { + return dir; + } + const fetched = (file: string): Promise<[string, string]> => + get(`${RAW}${tag}/bend2/${file}`) + .then((res) => text(res, ` for bend2/${file}`)) + .then((body) => [file, body]); + const base = await fetched("base.bend"); + const effs = [ + ...new Set([...base[1].matchAll(/^\s*import "\.\/(effs\/[^"]+)"/gm)].map((m) => m[1])), + ]; + const odd = effs.find((f) => !EFF.test(f)); + if (odd !== undefined) { + throw new Error("base.bend imports " + odd + ", which is not a plain file in effs/"); + } + const files = [base, ...(await Promise.all([...PATCHED, "safe.ts", ...effs].map(fetched)))]; + nodeFs.mkdirSync(nodePath.join(cache, tag), { recursive: true }); + const part = nodeFs.mkdtempSync(nodePath.join(cache, tag, ".part-")); + files.forEach(([file, body]) => { + nodeFs.mkdirSync(nodePath.dirname(nodePath.join(part, file)), { recursive: true }); + nodeFs.writeFileSync(nodePath.join(part, file), body); + }); + try { + nodeFs.rmSync(dir, { recursive: true, force: true }); + nodeFs.renameSync(part, dir); + } catch (e) { + nodeFs.rmSync(part, { recursive: true, force: true }); + if (!whole()) { + throw e; + } + } + return dir; +}; + +// The bend2 folder to load, found in the order the docs give. +export const bendDir = async ( + given: string | undefined, + { get = download, run = spawn, cache = CACHE, repo }: FindOptions = {}, +): Promise => { + const chosen = given ?? process.env.BEND_DIR; + const dir = path.resolve(chosen ?? repo ?? path.join(HERE, "..", "..", "..", "bend2")); + const found = [path.join(dir, "bend2"), dir].find((d) => + nodeFs.existsSync(path.join(d, "bend.ts")), + ); + if (found !== undefined) { + return fs.realpathSync(found); + } + if (chosen !== undefined) { + throw new Error(`no bend2 at ${dir} (it needs bend.ts); ${GIVE}`); + } + const tag = installedTag(run) ?? (await latestTag(cache, get)); + const fresh = !nodeFs.existsSync(nodePath.join(cache, tag, "bend2")); + const got = await fetchBend(tag, cache, get).catch((e: unknown) => { + throw new Error(`could not download bend ${tag} (${message(e)}); ${GIVE}`); + }); + if (fresh) { + console.error("bend-lint: downloaded bend " + tag + " to " + got); + } + return fs.realpathSync(got); +}; + +// Every sub-term, parents first, with an explicit stack: linear in the +// size of the term, at any depth. +export function* walk(tm: LTerm): Generator { + const stack = [tm]; + for (let t = stack.pop(); t !== undefined; t = stack.pop()) { + const next = children(t); + yield t; + for (let i = next.length - 1; i >= 0; i--) { + stack.push(next[i]); + } + } +} + +// A term's sub-terms, in bend's order. +const children = (t: LTerm): LTerm[] => { + const of = (CHILDREN as Record LTerm[]) | undefined>)[t.$]; + if (of === undefined) { + throw drift(`unknown term kind ${t.$}; update CHILDREN in tools/bend-lint/src/seam.ts`); + } + return of(t); +}; + +// Where each line of `text` starts, computed once per owner of the text. +export const starts = (owner: object, text: string): number[] => { + const known = STARTS.get(owner); + if (known !== undefined) { + return known; + } + const out = [0, ...[...text.matchAll(/\n/g)].map((m) => m.index! + 1)]; + STARTS.set(owner, out); + return out; +}; + +// The index of the line that holds `off`, given each line's start. +export const line = (starts: number[], off: number): number => { + let [lo, hi] = [0, starts.length - 1]; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + [lo, hi] = starts[mid] <= off ? [mid, hi] : [lo, mid - 1]; + } + return lo; +}; + +// bend.ts parses a copy of each file with its import lines blanked, so a +// span moves to the file on disk by line and column. The copy's namespace, +// folder and lines pick the file; a copy that matches no file, or two, is +// a drift error. +export const mapper = (files: File[]): Mapper => { + const own = new Set(files); + const memo = new WeakMap(); + // Each file's lines, split once. + const split = new Map(); + const linesOf = (file: File): string[] => { + const known = split.get(file) ?? file.str.split("\n"); + split.set(file, known); + return known; + }; + const find = (f: BendSpan["file"] & { dir?: string }) => { + const lines = f.str.split("\n"); + const found = files.filter((file) => { + if ( + f.ns !== file.ns || + (f.dir !== undefined && f.dir !== file.path.slice(0, file.path.lastIndexOf("/") + 1)) + ) { + return false; + } + const theirs = linesOf(file); + return ( + lines.length === theirs.length && + lines.every( + (l, i) => l === theirs[i] || (l.trim() === "" && /^\s*import(\s|$)/.test(theirs[i])), + ) + ); + }); + if (found.length !== 1) { + throw drift( + `cannot map a bend.ts span to a file on disk (${found.length} candidates); bend.ts may mask imports another way now`, + ); + } + Object.assign(found[0].al, f.al); + const known = { file: found[0], from: starts(f, f.str), to: starts(found[0], found[0].str) }; + memo.set(f, known); + return known; + }; + return (s) => { + if (s === undefined || own.has(s.file)) { + return s; + } + const { file, from, to } = memo.get(s.file) ?? find(s.file); + const at = (off: number): number => { + const i = line(from, off); + return to[i] + off - from[i]; + }; + return { file, beg: at(s.beg), end: at(s.end) }; + }; +}; + +// A mapped bend.ts span as a span of a checked source, and back. +const toSpan = (s: BendSpan | undefined): Span | undefined => { + const file = s && SOURCES.get(s.file); + return s && file && { file, beg: s.beg, end: s.end }; +}; + +const fromSpan = (s: Span | undefined): BendSpan | undefined => + s && { file: FILES.get(s.file)!, beg: s.beg, end: s.end }; + +// A fact or a type handle as what it holds, and back. +const raw = (fact: Fact): Raw => fact as unknown as Raw; +const handle = (r: Raw): Fact => r as unknown as Fact; +const term = (t: Type): HTerm => t as unknown as HTerm; +const typed = (t: HTerm): Type => t as unknown as Type; +const tree = (n: Node): LTerm => n as unknown as LTerm; +const node = (t: LTerm): Node => t as unknown as Node; + +// Throws a drift error that names each check that failed. +const demand = (checks: Array<[string, boolean]>, say: (wrong: string) => string): void => { + const wrong = checks.flatMap(([what, ok]) => (ok ? [] : [what])); + if (wrong.length > 0) { + throw drift(say(wrong.join(", "))); + } +}; + +const isErr = (e: unknown): e is Err => + typeof e === "object" && e !== null && (e as { $?: unknown }).$ === "Err"; + +// A term's kind, and the name a Var or Ref points to. +const kindOf = (t: LTerm): { kind: string; name: Name } => ({ + kind: t.$, + name: t.$ === "Var" || t.$ === "Ref" ? t.k : "", +}); + +// Whether a fact passes a filter, its scope aside. +const matches = (f: FactFilter, kind: string, def: Name, name: Name): boolean => { + const all = (xs: string[] | undefined, x: string): boolean => + xs === undefined || xs.length === 0 || xs.includes(x); + return ( + all(f.kinds, kind) && + (all(f.defs, def) || all(f.defs, def.replace(/~\d+$/, ""))) && + all(f.names, name) + ); +}; + +// bend's words for a failed check, its location aside (a finding has its +// own): expected and observed, with the names in scope, and the note. +const failure = (m: Loaded, e: unknown): string => { + if (!isErr(e)) { + return m.Main.book_err(e).replace(/^Error: /, ""); + } + const show = (x: Err["exp"]): string => + m.Bend.expr_show(e.bok, x, m.Bend.ctx_scope(e.ctx), e.spn?.file); + return ( + (e.obs === undefined + ? show(e.exp) + : "expected: " + show(e.exp) + "\nobserved: " + show(e.obs)) + + (e.nte === undefined ? "" : "\n" + e.nte) + ); +}; + +// check's work, in its turn. A failure in a file bend did not finish +// loading may not map; it still reports, without a span. +const checked = async ( + m: Loaded, + file: string, + filters: FactFilter[], + signal: AbortSignal, +): Promise => { + const { Bend, Main } = m; + const seen = new Map(); + const found: Report[] = []; + const real = fs.existsSync(file) ? fs.realpathSync(file) : ""; + const home = real.slice(0, real.lastIndexOf("/") + 1); + const far = filters.filter((f) => f.scope === "program"); + const program = far.length > 0; + const texts = new Map(); + reads = texts; + const seeded = real !== "" && /^import Base$/m.test(fs.readFileSync(real, "utf8")); + const key = fs.readFileSync(Bend.BASE_BEND, "utf8"); + if (seeded && BASE?.key !== key) { + BASE = { key, book: Main.book_read(Bend.BASE_BEND) }; + } + const see = (report: Report): void => { + const at = report.spn?.file as { dir?: string; ns?: string } | undefined; + const near = at?.dir === home && at.ns === "" ? filters : far; + const { kind, name } = + near.length > 0 ? kindOf(Bend.term_strip(report.tm)) : { kind: "", name: "" }; + if (near.some((f) => matches(f, kind, report.def, name))) { + found.push(report); + } + }; + const read = await Promise.resolve(seeded ? BASE?.book : undefined) + .then((base) => { + hook.see = filters.length > 0 ? see : undefined; + return Main.book_read(file, base, seen); + }) + .then( + (book) => ({ book }), + (e: unknown) => ({ e: e instanceof Main.Check_Fail ? e.why : e }), + ); + reads = undefined; + signal.throwIfAborted(); + const root = [...seen.keys()].find((real) => real !== Bend.BASE_BEND || !seeded); + const sources = [...seen.keys()] + .filter((real) => texts.has(real) || fs.existsSync(real)) + .map((real): Source => { + const text = texts.get(real) ?? fs.readFileSync(real, "utf8"); + const source = { path: real, text, root: real === root, base: real === Bend.BASE_BEND }; + const file = { str: text, ns: seen.get(real) ?? "", al: {}, path: real }; + FILES.set(source, file); + SOURCES.set(file, source); + return source; + }); + const map = mapper(sources.map((s) => FILES.get(s)!)); + if ("book" in read) { + const { book } = read; + const insts = new Set(Object.values(book.tmps).flatMap((t) => [...t.values()])); + const own = new Set( + sources.filter((s) => (program ? !s.base : s.root)).map((s) => FILES.get(s)), + ); + const facts = found.flatMap((f): Array<[LTerm, Fact]> => { + const spn = map(f.spn); + return spn !== undefined && own.has(spn.file) + ? [[f.tm, handle({ ...f, inst: insts.has(f.def), spn })]] + : []; + }); + return { + book, + sources, + map, + facts: filters.length > 0 ? [...new Map(facts).values()] : undefined, + }; + } + const err = isErr(read.e) ? read.e : undefined; + let span: Span | undefined; + try { + span = toSpan(map(err?.spn)); + } catch { + span = undefined; + } + return { + book: Bend.book_nil(), + sources, + map, + failure: { + code: "bend/check", + severity: "error", + message: failure(m, read.e), + span, + def: err?.def, + fixes: [], + core: read.e, + }, + }; +}; + +// Checks a book as `bend ` does (not main.ts's verdict on @unsafe and +// foreign code), with `text` in place of the files on disk, one check at a +// time. A fact is kept only if a filter wants it, and only if its span maps +// to the linted file (or, with scope program, to any file but Base). +export const check = ( + m: Loaded, + file: string, + filters: FactFilter[], + signal: AbortSignal, + text?: ReadonlyMap, +): Promise => { + const turn = queue.then(async (): Promise => { + signal.throwIfAborted(); + [...(text ?? [])] + .filter(([at]) => fs.existsSync(at)) + .forEach(([at, str]) => unsaved.set(fs.realpathSync(at), str)); + try { + return await checked(m, file, filters, signal); + } finally { + hook.see = undefined; + reads = undefined; + unsaved.clear(); + } + }); + queue = turn.catch(() => undefined); + return turn; +}; + +// The facts one rule asked for, of those kept for all rules. A filter that +// matches all, in the scope of every kept fact, gets them as they are. +export const select = ( + m: Loaded, + { sources, facts }: Checked, + want: FactFilter, + program: boolean, +): Fact[] | undefined => { + const root = FILES.get(sources.find((s) => s.root)!); + const all = [want.kinds, want.defs, want.names].every((xs) => !xs?.length); + return facts === undefined || (all && (want.scope === "program" || !program)) + ? facts + : facts.filter((fact) => { + const r = raw(fact); + const { kind, name } = kindOf(m.Bend.term_strip(r.tm)); + return ( + (want.scope === "program" || r.spn?.file === root) && matches(want, kind, r.def, name) + ); + }); +}; + +// Whether `text` declares what the linted file declares: both are parsed +// with the same imported declarations, and compared as parsed terms, not +// checked ones, since checking unfolds definitions and would hide changes +// in meaning. No typecheck, disk writes or import fetches. A proof made +// only of {==} has no body span, so its aliases come from the sources. A +// proof of an imported law fills its declaration rather than creating one. +const sameDeclarations = (m: Loaded, { book, sources }: Checked, text: string): boolean => { + const { Bend } = m; + const root = sources.find((s) => s.root)!; + const ns = FILES.get(root)!.ns; + const dir = root.path.slice(0, root.path.lastIndexOf("/") + 1); + const body = (s: string) => s.replace(/^import[^\S\n].*$/gm, ""); + const spanned = (): Record => { + const original = body(root.text); + for (const tld of Object.values(book.tlds)) { + for (const t of [tld.T, ...(tld.$ === "Def" && tld.v ? [tld.v] : [])]) { + for (const tm of walk(Bend.term_lower(t))) { + if (tm.s?.file.str === original) { + return tm.s.file.al; + } + } + } + } + return {}; + }; + const aliases = [...root.text.matchAll(/^import\s+(\S+)\s+as\s+(\w+)/gm)].reduce< + Record + >( + (known, [, at, alias]) => { + const imported = sources.find((s) => s.path === resolve(root.path, "..", at)); + return known[alias] !== undefined || imported === undefined + ? known + : { ...known, [alias]: FILES.get(imported)!.ns }; + }, + /^import\s+\S+\s+as\s+/m.test(root.text) ? spanned() : {}, + ); + const qualify = (name: string) => { + const dot = name.indexOf("."); + return dot >= 0 && aliases[name.slice(0, dot)] !== undefined + ? aliases[name.slice(0, dot)] + ":" + name.slice(dot + 1) + : (ns ? ns + ":" : "") + name; + }; + const own = new Set( + [...root.text.matchAll(/^(?:@unsafe\s+)?(?:def|type|law)\s+([\w.]+)/gm)].map((match) => + qualify(match[1]), + ), + ); + const seed = (): Book => { + const fresh = Bend.book_nil(); + fresh.tlds = { ...book.tlds }; + fresh.ctrs = { ...book.ctrs }; + for (const k of own) { + const tld = fresh.tlds[k]; + if (k.includes(":") && !k.startsWith(ns + ":") && tld?.$ === "Def") { + fresh.tlds[k] = { ...tld, v: null, i: undefined, u: false }; + } else { + if (tld?.$ === "ADT") for (const c of tld.c) delete fresh.ctrs[c.k]; + delete fresh.tlds[k]; + } + } + return fresh; + }; + const snapshot = (s: string) => { + const parsed = seed(); + Bend.parse_book(parsed, dir, body(s), ns, aliases); + const lower = (t: HTerm | null) => (t === null ? null : Bend.term_lower(t)); + return JSON.stringify( + parsed.order.map((k) => { + const t = parsed.tlds[k]; + return t.$ === "ADT" + ? [k, t.n, t.g, lower(t.T), t.c.map((c) => [c.k, c.n, lower(c.T)])] + : [k, t.n, t.x, t.u ?? false, t.i, lower(t.T), lower(t.v)]; + }), + (k, v) => (k === "s" ? undefined : v), + ); + }; + try { + return snapshot(root.text) === snapshot(text); + } catch { + return false; + } +}; + +// What a rule asks bend2, over one check's book and facts. +export const operations = (m: Loaded, run: Checked): Operations => { + const { Bend } = m; + let byTerm: Map | undefined; + return { + view: (fact): View => { + const r = raw(fact); + return { + owner: r.def, + inst: r.inst, + ...kindOf(Bend.term_strip(r.tm)), + quantity: QUANTITY[r.qt.$], + span: toSpan(r.spn), + inner: toSpan(run.map(Bend.term_strip(r.tm).s)), + }; + }, + type: (fact) => typed(raw(fact).ty), + binder: (fact) => { + const r = raw(fact); + const v = Bend.term_strip(r.tm); + const ann = v.$ === "Var" ? Bend.pmap_get(r.ctx, v.i) : null; + return ann === null ? undefined : typed(ann.T); + }, + same: (fact, a, b) => Bend.term_compare("EQ", raw(fact).bok, term(a), term(b), raw(fact).dep), + show: (fact, t) => Bend.term_show(Bend.term_lower(term(t), raw(fact).dep)), + normal: (fact, t) => typed(Bend.term_snf(raw(fact).bok, term(t))), + uses: (fact) => { + const r = raw(fact); + return Bend.pmap_to_array(r.us).flatMap(([v, q]) => + q.$ === "None" ? [] : [{ name: Bend.pmap_get(r.ctx, v)?.k ?? "", quantity: QUANTITY[q.$] }], + ); + }, + sameDeclarations: (text) => sameDeclarations(m, run, text), + body: (name) => { + const tld = run.book.tlds[name]; + return tld?.$ === "Def" && tld.e !== undefined ? node(tld.e) : undefined; + }, + node: (fact) => node(raw(fact).tm), + shape: (n): Shape => { + const t = tree(n); + return { ...kindOf(t), span: toSpan(run.map(t.s)), children: children(t).map(node) }; + }, + fact: (n) => { + byTerm ??= new Map((run.facts ?? []).map((f) => [raw(f).tm, f])); + return byTerm.get(tree(n)); + }, + unstable: { Bend, book: run.book, raw }, + }; +}; + +// bend's own error layout for a finding, under `head`: its message, +// context and location. +export const layout = (m: Loaded, d: Diag, head: string): string => { + const r = d.fact && raw(d.fact); + return m.Bend.err_show( + isErr(d.core) + ? d.core + : m.Bend.Err( + r?.bok ?? m.Bend.book_nil(), + r?.ctx ?? m.Bend.ctx_nil(), + d.message, + undefined, + fromSpan(d.span), + d.def, + ), + ).replace(/^Error:/, head); +}; + +// A Bend rule's id(), facts() and main(), compiled as comp.ts io_run does. +// facts(), checked: NoFacts{} gives null, Want{...} a filter. +export const compile = (m: Loaded, { book }: Checked, file: string): Compiled => { + const { Bend, Comp } = m; + const value = (k: Name): LTerm | undefined => { + const tld = book.tlds[k]; + return tld?.$ === "Def" && tld.n === 0 && tld.v !== null + ? Bend.term_lower(Bend.term_snf(book, tld.v)) + : undefined; + }; + const shown = value("id"); + const id = shown === undefined ? undefined : Bend.term_show(shown).match(/^"([^"\\]*)"$/)?.[1]; + const args = (t: LTerm | undefined, ctr: string): LTerm[] | undefined => + t?.$ === "Ctr" && (t.k === ctr || t.k.endsWith(":" + ctr)) ? t.x : undefined; + const texts = (t: LTerm | undefined): string[] | undefined => { + const [head, tail] = args(t, "Con") ?? []; + const rest = tail && texts(tail); + return args(t, "Nil") !== undefined + ? [] + : head?.$ === "Lit" && typeof head.v === "string" && rest + ? [head.v, ...rest] + : undefined; + }; + const asked = value("facts"); + const [scope, kinds, defs, names] = args(asked, "Want") ?? []; + const want = + args(asked, "NoFacts") !== undefined + ? null + : { + scope: (["file", "program"] as const).find( + (s) => args(scope, s === "file" ? "File" : "Program") !== undefined, + ), + kinds: texts(kinds), + defs: texts(defs), + names: texts(names), + }; + if ( + id === undefined || + Comp.io_type(book) === null || + (want !== null && [want.scope, want.kinds, want.defs, want.names].includes(undefined)) + ) { + throw new Error( + file + " must define id() -> String, facts() -> Lint.Want and main() -> IO(Unit)", + ); + } + const main = new Function( + "require", + `${Comp.js_lib(book)}\n${Comp.RUNTIME_MAIN}\nreturn (args) => { cli_args = args; return io_run(${Comp.js_sat("main")}); };`, + )(import.meta.require) as (args: string[]) => number; + return { id, want: want as FactFilter | null, main }; +}; + +// What main.ts must give, as bend-lint calls it; a mismatch is a drift +// error. +export const guardMain = (Main: Main): void => + demand( + [ + ["book_read(file, base, seen = ...)", Main.book_read?.length === 2], + ["book_err(e)", Main.book_err?.length === 1], + [ + "new Check_Fail(why).why", + typeof Main.Check_Fail === "function" && new Main.Check_Fail(MARK).why === MARK, + ], + ], + (wrong) => + `bend2/main.ts no longer has ${wrong}; update PATCHES in tools/bend-lint/src/seam.ts`, + ); + +// Checks src/bend/sample.bend as a rule sees it. `x` in `def id(x: N) -> N: x` +// must be a Var typed N, bound as an N, used once, at its own span; `id` in +// main is reported only by term_infer, and the Lam only by term_check. +const selfCheck = async (m: Loaded): Promise => { + const run = await check(m, SAMPLE, [{}], new AbortController().signal); + const ops = operations(m, run); + const views = (run.facts ?? []).map((fact) => ({ fact, ...ops.view(fact) })); + const x = views.find((v) => v.kind === "Var" && v.name === "x"); + const bound = x && ops.binder(x.fact); + demand( + [ + ["check", run.failure === undefined], + ["Ref id (term_infer)", views.some((v) => v.kind === "Ref" && v.name === "id")], + ["Lam (term_check)", views.some((v) => v.kind === "Lam")], + ["type", x !== undefined && ops.show(x.fact, ops.type(x.fact)) === "N"], + ["scope", x !== undefined && bound !== undefined && ops.show(x.fact, bound) === "N"], + ["quantity", x?.quantity === "once"], + ["uses", JSON.stringify(x && ops.uses(x.fact)) === '[{"name":"x","quantity":"once"}]'], + ["span", x?.span?.file.text.slice(x.span.beg, x.span.end) === "x"], + ], + (wrong) => + `self-check failed: the patched bend2 gave the wrong ${wrong} for src/bend/sample.bend; update tools/bend-lint/src/seam.ts`, + ); +}; + +// Finds bend2, patches its PATCHED files as Bun loads them, imports them, +// and checks them (guardMain, selfCheck). +export const load = async (given: string | undefined): Promise => { + const dir = await bendDir(given); + const patched = new Map( + PATCHED.map((f) => [path.join(dir, f), patch(f, fs.readFileSync(path.join(dir, f), "utf8"))]), + ); + const exact = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll("/", "[\\\\/]"); + const names = PATCHED.map((f) => f.replace(/\.ts$/, "")).join("|"); + const filter = new RegExp( + `^${exact}[\\\\/](${names})\\.ts$`, + process.platform === "win32" ? "i" : "", + ); + Bun.plugin({ + name: "bend-lint", + setup: (build) => { + build.onLoad({ filter }, (args) => { + const contents = patched.get(fs.realpathSync(args.path)); + if (contents === undefined) { + throw drift(`bend-lint matched ${args.path} but did not patch it`); + } + return { contents, loader: "ts" }; + }); + }, + }); + const modules = await Promise.all( + PATCHED.map((f) => import(url.pathToFileURL(path.join(dir, f)).href)), + ); + if (modules.some((module) => module[MARK] !== 1)) { + throw drift("bend2 was loaded before bend-lint could patch it; import bend-lint first"); + } + const [B, C, M] = modules as [typeof BendModule, Comp, Main]; + guardMain(M); + const loaded = { Bend: B, Comp: C, Main: M, BEND2: dir }; + await selfCheck(loaded); + return loaded; +}; diff --git a/tools/bend-lint/tsconfig.json b/tools/bend-lint/tsconfig.json new file mode 100644 index 0000000000..81d67c09a9 --- /dev/null +++ b/tools/bend-lint/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["bun"], + "typeRoots": ["./node_modules/@types", "../../bend2/pack/node_modules/@types"], + "paths": { "bend2/*": ["../../bend2/*"] } + }, + "include": ["src", "rules"] +}