diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36b4407..9ef893f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,27 @@ jobs: sbt pncs/transpile git diff --exit-code || (echo "Transpilation produced changes, please commit them." && exit 1) + docs: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup SBT + uses: sbt/setup-sbt@v1 + + # Picks up tools/doccheck/baseline.txt automatically, so this fails only + # when a doc snippet that used to compile stops compiling, or when a + # baselined snippet starts compiling and the baseline needs shortening. + - name: doc code blocks + run: sbt "doccheck/run docs/src/content/docs" + lint: runs-on: ubuntu-latest steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36e0f70..dcda762 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,6 +95,32 @@ Suites are organized by pipeline stage: `LexerTests`, `ParserTests`, new cases to the suite for the stage you changed, and add a test for every bug you fix. +## Documentation code blocks + +Every ` ```panther ` block under `docs/` is compiled by +[`doccheck`](tools/doccheck/README.md): + +```bash +sbt "doccheck/run docs/src/content/docs" +``` + +Every block currently compiles, and `tools/doccheck/baseline.txt` — the list of +blocks known not to compile — is empty. Keep it that way: a snippet you add or +change has to pass. + +The check fails in two directions — a snippet that used to compile breaking, +*and* a baselined snippet starting to compile — so if you ever do need to +baseline something, regenerate it in the same commit: + +```bash +sbt "doccheck/run --update-baseline docs/src/content/docs" +``` + +A snippet that should not be compiled as written gets a directive on the line +above its fence — `parse-only`, `expect-error`, or `skip reason="..."`. Prefer +fixing the snippet; see [ROADMAP.md](ROADMAP.md#4-documentation-that-is-checked) +for the plan to empty the baseline. + ## Commits Commit messages follow a Conventional-Commits style, with an optional scope @@ -126,15 +152,20 @@ sbt pncs/transpile sbt pncs/compile && sbt test/test && sbt scalafmtCheckAll ``` +```bash +sbt "doccheck/run docs/src/content/docs" +``` + Then **commit the regenerated `.pn` files** along with your Scala changes. -The [`ci`](.github/workflows/ci.yml) workflow runs three jobs on every pull +The [`ci`](.github/workflows/ci.yml) workflow runs four jobs on every pull request, all of which must pass: 1. `sbt pncs/compile`, then `sbt test/test` 2. `sbt pncs/transpile`, then `git diff --exit-code` — this fails if the transpiled output in `pnc/src/` is out of sync with the Scala sources -3. `sbt scalafmtCheckAll` +3. `doccheck` against its baseline +4. `sbt scalafmtCheckAll` For the pull request itself: diff --git a/README.md b/README.md index 383d860..28cde82 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ sbt "pncs/run --help" sbt "pncs/run output.pnb source.pn" ``` +Note that the output path is not honoured yet: the emitter builds the chunk and +metadata in memory and nothing is written to disk, so no `output.pnb` appears. +See [ROADMAP.md](ROADMAP.md#31-write-and-read-pnb). + ### Regenerating the Panther sources Any change to the Scala sources must be transpiled and the result committed — @@ -64,14 +68,18 @@ CI checks that `pnc/src/` is in sync. sbt pncs/transpile ``` -### Commands that are expected to fail +### Commands that do not work yet -Self-hosting is not finished yet, so these do not currently succeed: +Self-hosting is not finished, so these do not produce a working compiler: -- `sbt compile` (fails in the `pnc/compile` step — use `sbt pncs/compile`) -- `sbt pnc/compile` +- `sbt compile` (use `sbt pncs/compile`) +- `sbt pnc/compile` — reports 1058 diagnostics against the generated `.pn` + sources. It still *exits zero*, because the compiler does not set a failure + exit code; read the diagnostic count, not the exit status. - `sbt pncs/bootstrap` +[ROADMAP.md](ROADMAP.md#1-self-hosting) tracks what is left. + ### Scripts `scripts/` holds cross-platform PowerShell wrappers around the same sbt tasks: @@ -89,6 +97,7 @@ Self-hosting is not finished yet, so these do not currently succeed: | `text/` | Text processing library | | `test/` | ScalaTest suites plus shared helpers | | `docs/` | Astro/Starlight documentation site | +| `tools/` | Development tooling — currently `doccheck`, the doc code-block checker | | `scripts/` | Cross-platform PowerShell wrappers around the sbt tasks | Entry points: [`pncs/src/main/scala/Program.scala`](pncs/src/main/scala/Program.scala) @@ -106,6 +115,22 @@ pnpm --dir docs install && pnpm --dir docs dev `docs_old/` is the previous mdbook-based site, kept for reference. New documentation goes in `docs/`. +Every ` ```panther ` block in the docs is checked against the compiler by +[`doccheck`](tools/doccheck/README.md): + +```sh +sbt "doccheck/run docs/src/content/docs" +``` + +Every block currently compiles, and `tools/doccheck/baseline.txt` — the list of +known-broken blocks — is empty, so CI fails if that stops being true. + +## Roadmap + +[ROADMAP.md](ROADMAP.md) covers the three things being worked toward — +self-hosting, generics, and runnable sample programs — with the current +measurements for each. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for the development loop, the invariants diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..349fdfb --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,481 @@ +# Panther Roadmap + +Where the compiler kit is going, and what has to be true before it gets there. + +The three things that matter most: + +1. **Self-hosting** — `pnc` compiles itself without help from Scala. +2. **Generics** — the type system holds up under the code the compiler is + already written in. +3. **Sample programs** — someone can write, compile, and *run* a Panther + program that does something. + +They are not independent. Generic inference is the single largest thing +standing between `pnc` and self-hosting, and there is currently no way to run a +compiled program at all, which caps how convincing any sample can be. + +Every number below is measured, not estimated, and every command reproduces the +measurement. The measurements were taken on `main` at `1bdb118`. + +--- + +## Where things stand + +### The Scala compiler works + +```bash +sbt pncs/compile && sbt test/test +``` + +Green: 246 tests across the lexer, parser, binder, type checker, VM, metadata +format, and args parser. + +### The self-hosted compiler does not + +```bash +sbt pnc/compile +``` + +Runs to completion and reports **1058 diagnostics** against the generated +`.pn` sources. (1029 of those come from the committed tree; the other 29 come +from `Ast.pn`, which the transpile step regenerates — see §1.1.) By message: + +| Count | Diagnostic | +| ---: | --- | +| 642 | `Cannot convert from A to B` | +| 181 | `Symbol X not found for type T` | +| 130 | `Symbol X not found` | +| 38 | `Type X not defined` | +| 32 | `Duplicate definition` | +| 18 | `Invalid namespace` | +| 16 | argument-count mismatches | +| 1 | no operator for operands | + +**685 of the 1058 mention an unsolved type variable** (`$0`, `$1`, …) — a +generic parameter the binder gave up on. That is the strongest single signal we +have about what to fix first. + +By file, the damage concentrates in the parts of the compiler that lean hardest +on generic collections: + +| Count | File | +| ---: | --- | +| 245 | `Binder.pn` | +| 118 | `ExprBinder.pn` | +| 83 | `Lowered.pn` | +| 80 | `Parser.pn` | +| 72 | `Emitter.pn` | +| 54 | `TypeInference.pn` | + +### Nothing is ever written to disk + +`Compilation.emit(output)` takes an output path +([`compilation.scala:97`](pncs/src/main/scala/compilation.scala:97)) and +ignores it. `Emitter.emit()` builds a chunk and metadata tables in memory and +returns them ([`Emitter.scala:156`](pncs/src/main/scala/Emitter.scala:156)); +the only code in the repo that writes files is the transpiler. + +So `pncs output.pnb source.pn` — the invocation in the README, in +`ArgsParser.printUsage()`, and in the docs — produces no `output.pnb`. There is +no `.pnb` reader either, and no CLI path to `Compilation.exec()`, even though +the VM works and `VmTests` drives it in-process for 36 tests. + +### The docs now compile + +```bash +sbt "doccheck/run docs/src/content/docs" +``` + +**200 blocks across 22 files: 198 compile, 0 fail, 2 skipped.** The two skips +are on `higher-order-functions.md`, which documents lambdas and function-typed +parameters — neither exists (see cross-cutting below). + +It started at 215 blocks with **157 failing**, four of which made the compiler +*throw* rather than report a diagnostic, including the second example on the +Getting Started page. The gaps were systematic, not scattered typos: + +| Docs say | Panther accepts | +| --- | --- | +| `for (item in items)` | `for (i <- 0 to n)` | +| `Array`, `Option` | `Array[int]`, `Option[User]` | +| `Int`, `String`, `float` | `int`, `string`, no floats yet | +| `Some(x)`, `None` | `Option.Some(x)`, `Option.None` | +| `(x) => x + 1` | not parsed | +| `[95, 87, 92]` | not parsed | +| `fun f(): T { return x }` | `def f(): T = x` — no `return` | +| `match (x) { 1 => ... }` | `x match { case 1 => ... }` | +| `break` / `continue` | parse, then crash the binder | + +The tool that found and now guards these lives in +[`tools/doccheck/`](tools/doccheck/README.md). Fixing the docs is what surfaced +most of the language gaps listed under [cross-cutting](#cross-cutting) — the +checker turned out to be as useful for finding compiler holes as for finding +prose errors. + +### There are no sample programs + +There is not one `.pn` file in the repository outside `pnc/src/`, which is +generated. Nothing shows what a Panther program looks like end to end, and +nothing would run if it did. + +--- + +## 1. Self-hosting + +The goal: `pnc` compiles `pnc` and the output is byte-identical to the input +compiler's output. + +### 1.1 Fix the generated-source drift — small, do it first + +`sbt pncs/transpile` currently emits `Ast.pn` and `AstPrinter.pn`, but +`pnc/src/ast.pn` and `pnc/src/printer.pn` are still committed from an older +transpiler. All four get compiled, which accounts for the 32 +duplicate-definition diagnostics on its own. + +Worse, CI cannot see this. The `transpile` job runs `git diff --exit-code`, +which ignores **untracked** files, so `Ast.pn` appearing as a brand-new +generated file passes the check silently. + +- Delete `pnc/src/ast.pn` and `pnc/src/printer.pn`. +- Change the CI check to `git status --porcelain` (or `git diff --exit-code` + after `git add -A`) so new and deleted generated files fail the build too. + +### 1.2 Make the compiler exit non-zero on errors + +`Program.run` prints `found N diagnostics` and returns normally +([`Program.scala:94`](pncs/src/main/scala/Program.scala:94)). `sbt pnc/compile` +"succeeds" with 1058 errors. Nothing downstream — CI, scripts, an editor +integration — can tell success from failure. + +### 1.3 Burn down the diagnostics + +Ordered by what the counts say, not by what is interesting: + +1. **Generic inference** (see §2). 685 diagnostics reference an unsolved type + variable; this is the bulk of the work. +2. **Member lookup on generic receivers** — `Symbol X not found for type $0`, + 181 diagnostics. Falls out of §2 but worth tracking separately in case it + does not. +3. **Namespace and import resolution** — 18 `Invalid namespace`, plus some + share of the 130 bare `Symbol not found`. The transpiler turns + `import ns._` into `using ns`; the binder's handling of that has a known + gap ([`Binder.scala:773`](pncs/src/main/scala/Binder.scala:773)). + +Track the number after every change: + +```bash +sbt pnc/compile +``` + +**1058 → 0.** Nothing else in this section matters until that number moves. + +Only the first 20 diagnostics are printed. To see them all, transpile first — +`pnc/compile` does this implicitly, and the count depends on it — then run the +compiler directly with a higher limit: + +```bash +sbt pncs/transpile && sbt --error "pncs/run --diagnostics-limit 5000 out.pnb $(find pnc/src -name '*.pn' | tr '\n' ' ')" +``` + +### 1.4 Close the `???` holes + +38 live `???` remain in paths the self-hosted compiler will eventually walk: +9 in `Emitter.scala`, 8 in `Lowered.scala`, 4 across the assembly printers, +3 in `Transpiler.scala`, and the rest scattered. Each is a crash waiting for +the first program that hits it. They can stay until the diagnostics are down, +but they cannot stay through stage 2. + +### 1.5 Run the stages + +`scripts/stage0.ps1` (transpile), `stage1.ps1` (bootstrap), `stage2.ps1` +(`pnc/compile`) already exist. Once stage 2 is clean, add **stage 3**: +`pnc`-compiled-by-`pnc` must produce the same bytecode as +`pnc`-compiled-by-`pncs`. That fixed point is what "self-hosted" means, and it +is the point at which `pncs` stops being load-bearing. + +--- + +## 2. Generics + +Generics are further along than the diagnostic count suggests — `Type.Class`, +`Type.GenericClass`, `Type.GenericFunction`, and `Type.Variable` all exist, +`Inference.scala` and `TypeInference.scala` are real implementations, and +`TypeTests` covers identity functions, generic containers, and generic methods +with concrete returns. What is missing is everything past the simple cases. + +### 2.1 Type-argument inference through call chains + +The failing pattern in `pnc/src` is not exotic. It is code like: + +``` +head match { + case List.Cons(x, tail) => f(x) +} +``` + +where `x`'s type has to flow from the scrutinee's type argument into the call. +The binder produces `$0` and then reports `Cannot convert from $0 to T`. + +This is the highest-value work in the entire roadmap. Every diagnostic it +removes from §1.3 is one that does not need fixing by hand. + +### 2.2 Upper bounds + +`GenericTypeParameter` carries an `upperBound: Option[Type]`, and nothing ever +populates it. The parser drops bounds on the floor +([`Parser.scala:395`](pncs/src/main/scala/Parser.scala:395), `// TODO: bounds +support`) and the binder hardcodes `None` +([`Binder.scala:1527`](pncs/src/main/scala/Binder.scala:1527)). `T <: Base` +parses into a `GenericParameterSyntax` with no bounds and is silently ignored. + +Wire it end to end: parse, bind, and enforce at the call site with a real +diagnostic. + +### 2.3 Fix the inverted variance keywords + +[`Variance.scala`](pncs/src/main/scala/Variance.scala) documents +`Covariant // "out"` and `Contravariant // "in"`, matching C# and Kotlin. Both +places that touch the keywords disagree with it: + +- [`Transpiler.scala:210`](pncs/src/main/scala/Transpiler.scala:210) emits + `in` for Scala's `+` (covariant) and `out` for `-`. +- [`Binder.scala:1505`](pncs/src/main/scala/Binder.scala:1505) reads `"in"` + back as `Covariant` and `"out"` as `Contravariant`. + +The two inversions cancel, so nothing is broken today — which is exactly why +this will stay wrong until someone writes `[out T]` by hand and gets +contravariance. The generated stdlib currently reads `enum List[in T]` for a +covariant list. Fix both sides, regenerate, and add a test that a hand-written +`[out T]` binds to `Covariant`. + +### 2.4 Variance enforcement + +`Variance` is recorded on every type parameter and, as far as the binder is +concerned, never checked. A covariant parameter in an input position should be +a diagnostic. Needed before the variance annotations in the stdlib mean +anything. + +### 2.5 Generic type aliases and unions + +`Type.Alias` takes type arguments and `Types.union` carries a `TODO` about +redundant cases. Both are load-bearing for the pattern-matching code in the +binder. Lower priority than 2.1, higher than 2.4. + +--- + +## 3. Sample programs + +Blocked on output. A sample that cannot be run is a code listing. + +### 3.1 Write and read `.pnb` + +The metadata tables already have the shape for this — `BlobTable.write`, +`SignatureBuilder`, `IntList` — but there is no whole-image writer and no +reader. Needed: + +- A serializer for `EmitResult` (chunk + metadata) to a `.pnb` file. +- A deserializer that reconstitutes it. +- A round-trip test: emit, write, read, execute, compare against + `Compilation.exec()` on the same source. + +Once this exists, `Compilation.emit(output)` can honour its argument and the +README stops documenting a command that does nothing. + +### 3.2 Add a runner + +`Compilation.exec()` already runs the VM in-process and `VmTests` leans on it. +Expose it: + +- `pncs --run source.pn` — compile and execute in one step. +- `pvm output.pnb` — load and execute a compiled image. This is the command in + the Getting Started docs; today it does not exist. + +### 3.3 Then write the samples + +In a new `samples/` directory, each with a comment header saying what it +demonstrates and what it prints: + +| Sample | Exercises | +| --- | --- | +| `hello.pn` | top-level statements, `println` | +| `fizzbuzz.pn` | `while`, `if`/`else`, `mod`, string building | +| `fib.pn` | recursion, `int` arithmetic | +| `wordcount.pn` | `Array`, `List`, `Dictionary`, string handling | +| `expr.pn` | `enum`, pattern matching, recursion over a tree | +| `option-result.pn` | `Option` and `Result` as error handling | + +`expr.pn` is the important one: a small expression evaluator is the shape of a +compiler, so it exercises the same features `pnc` needs and doubles as a +regression test for §1. + +### 3.4 Put the samples in CI + +Every sample compiles, runs, and produces expected output. A `samples` job that +diffs actual output against a checked-in `.expected` file. This is the first +end-to-end test the project would have. + +--- + +## 4. Documentation that is checked + +Partly landed. [`tools/doccheck/`](tools/doccheck/README.md) extracts every +` ```panther ` block from the markdown and runs it through the real front end. +Blocks can be annotated in place: + +```markdown + + + +``` + +`tools/doccheck/baseline.txt` is picked up automatically and is **now empty**, +so CI enforces a clean run: any snippet that stops compiling fails the build. + +### 4.1 Empty the baseline — done + +**157 → 0.** The mechanical entries were the bulk: `<>` → `[]`, `Int` → `int`, +`item in items` → `i <- 0 to n`, `fun`/`return` → `def` and a trailing +expression, `match (x) { ... }` → `x match { case ... }`. + +Two blocks remain marked `skip`, both on `higher-order-functions.md`, because +lambdas and function-typed parameters genuinely do not exist. That page was +275 lines teaching a feature the language does not have; it is now a short, +honest page that says so and shows the workarounds (named top-level functions, +generic functions, `enum` + `match` for behaviour dispatch). + +Keep the baseline empty. If it grows, shorten it again rather than living with +it: + +```bash +sbt "doccheck/run --update-baseline docs/src/content/docs" +``` + +### 4.2 The compiler still throws on `string + int` + +The four crashing blocks are fixed in the docs — the idiom is +`"text " + string(n)`, as the compiler's own sources use. The underlying bug is +not fixed: `"text " + someInt` still raises +`Binary operator 'Plus' not found for types 'string' and 'int'` as an +exception, violating the `diagnostics-not-exceptions` invariant in +[`primitives.yaml`](docs/architecture/primitives.yaml). + +Two things to settle: whether `string + int` should work at all, and, +regardless of that, that the failure comes back as a diagnostic. The same +applies to `break`/`continue`, which panic through the same +`boundErrorStatement` path. + +### 4.3 Clean up the docs tree + +- `docs/src/content/docs/guides/` duplicates `basics/`, `data-types/`, + `flow-control/`, and `functions/`. Pick one and delete the other; right now + the same wrong example is wrong in two places. +- `guides/example.md` and `reference/example.md` are unedited Starlight + boilerplate. +- 17 of the 22 doc files are CRLF, against the LF rule in `.editorconfig`. +- `docs_old/` (mdbook) and the empty `tools/mdbook/` can go once nothing is + being ported out of them. + +### 4.4 Grow the checker + +In rough order of usefulness: + +- **Execute blocks and check their output.** Docs are full of + `// Prints: 0, 1, 2, 3, 4`. Depends on §3.1/§3.2. +- **Linked blocks**, so a walkthrough can build up across several fences + instead of needing `parse-only`. +- **A stdlib prelude — the most limiting gap.** `Option`, `Result`, `Either`, + `List`, and `Dictionary` live in `pnc/src/*.pn` and are *not* builtins: only + `int`, `string`, `bool`, `char`, `unit`, and `Array` are wired into the + binder. A single-block compilation therefore cannot mention `Option` at all + (`Type Option not defined`), which means the checker currently forbids + documenting a large part of the standard library. Compiling each block + alongside the runtime `.pn` sources would fix this, and would match what a + real Panther program sees. Until then the tool is shaping the docs, which is + backwards. +- **An opt-in prelude for page-local types**, so a snippet can use `User` + without declaring it. +- **Point it at `README.md` and `CONTRIBUTING.md`** too — the README's + `pncs output.pnb source.pn` is exactly the kind of claim this catches. + +--- + +## Cross-cutting + +Things that do not belong to one goal but block several. + +- **`val` is not enforced.** `val x = 10` followed by `x = 20` compiles with + zero diagnostics. The binder records the distinction and never checks it, so + the immutability the docs promise does not exist. Cheap to fix and worth + doing early — every day it stays, more code depends on it. +- **No `float`/`double`, and decimal literals do not even lex.** `99.99` comes + back as `Unexpected token NumberToken, expected IdentifierToken` — the lexer + reads `99`, `.`, `99` as a member access. `Math.scala` carries a `TODO` about + the missing type; the literal syntax is a separate, earlier problem. +- **No compound assignment.** `count += 5` does not parse; `+` and `=` are + lexed separately. +- **No `return`.** It is not a keyword anywhere in the lexer or + `SyntaxFacts.getKeywordKind`, so `return x` binds as an unknown identifier. + Functions are trailing-expression only. Worth an explicit diagnostic rather + than `Symbol return not found`. +- **Block comments do not nest.** `/* outer /* inner */ more */` ends at the + first `*/`. +- **`break` and `continue` crash the compiler.** Both are real keywords and + parse fine, but `bindBreakStatement` and `bindContinueStatement` call + `boundErrorStatement`, which calls `panic()` + ([`ExprBinder.scala:695`](pncs/src/main/scala/ExprBinder.scala:695)). Any + program using them takes the compiler down rather than getting a diagnostic — + another instance of the §4.2 pattern. Either implement them or reject them + with a real diagnostic. +- **No pattern guards, alternation, or tuples.** `case n if n > 0 =>` does not + parse (`parseMatchCase` goes straight from pattern to `=>`), neither does + `case 1 | 2 =>`, and there is no tuple literal or tuple pattern — only + class/enum extraction patterns like `Point(x, y)`, which work on any class + and without `new`. +- **Arrays cannot resize.** Four `// TODO: support resizing` in `Parser.scala`. + The parser works around it with fixed-size arrays and rebuilds. +- **No lambdas.** `Type.Function` exists and the binder understands function + types, but `(x) => x + 1` does not parse — 114 diagnostics in the docs alone. + `functions/higher-order-functions.md` is 275 lines about a feature the + language does not have. +- **No lexer support for exponents or shifts** + ([`Lexer.scala:243`](pncs/src/main/scala/Lexer.scala:243)). +- **Test coverage is stage-shaped, not feature-shaped.** 246 tests, but + `MetadataTests` has 2 and there is no end-to-end test that takes source all + the way to output. §3.4 is the fix. + +--- + +## Suggested order + +Sequenced so each step makes the next one measurable. + +**First — stop flying blind.** +§1.1 generated-source drift, §1.2 exit codes, §4.2 the four crashes. Small, +independent, and each one makes a signal trustworthy that currently is not. + +**Second — generics.** +§2.1 inference, §2.2 bounds, §2.3 variance keywords. The 1058 should fall +sharply. If it does not, the assumption behind this roadmap was wrong and the +plan should be rewritten around what the diagnostics actually say. + +**Third — make programs runnable.** +§3.1 `.pnb` read/write, §3.2 the runner. Unblocks samples, output-checked docs, +and the stage-3 bootstrap comparison. + +**Fourth — samples and the rest of the docs.** +§3.3, §3.4, §4.3. §4.1 is already done — the docs compile — so what is left +here is the sample programs and the duplicated `guides/` tree. + +**Fifth — finish self-hosting.** +§1.3 to zero, §1.4 the `???` holes, §1.5 stage 3. + +## Tracking + +The three numbers worth putting on a wall: + +| Metric | Now | Target | Command | +| --- | ---: | ---: | --- | +| Self-hosting diagnostics | 1058 | 0 | `sbt pnc/compile` | +| Doc blocks that fail | **0 / 200** | 0 | `sbt "doccheck/run docs/src/content/docs"` | +| Doc blocks skipped as unsupported | 2 | 0 | as above | +| Samples that run in CI | 0 | 6 | not yet built | diff --git a/build.sbt b/build.sbt index 412fd4f..bb5ad96 100644 --- a/build.sbt +++ b/build.sbt @@ -152,6 +152,19 @@ lazy val pnc = project }.value ) +/** Documentation code-block checker + * + * Extracts ```panther blocks from the markdown under docs/ and runs each one + * through the pncs front end, so the docs cannot drift away from the language + * the compiler actually accepts. + */ +lazy val doccheck = project + .in(file("tools/doccheck")) + .dependsOn(runtime, metadata, text, pncs) + .settings( + mainClass := Some("DocCheck") + ) + lazy val test = project .dependsOn(runtime, pncs) .settings( diff --git a/docs/architecture/primitives.yaml b/docs/architecture/primitives.yaml index 6065979..920e895 100644 --- a/docs/architecture/primitives.yaml +++ b/docs/architecture/primitives.yaml @@ -313,6 +313,19 @@ primitives: - docs/src/ - docs/public/ + - id: doc-check + group: quality + name: Documentation code-block checker + summary: + Extracts ```panther blocks from the markdown under docs/ and runs each + through the pncs front end, with per-block directives and a baseline so + CI can ratchet the docs toward compiling. + code: + - tools/doccheck/ + docs: + - tools/doccheck/README.md + - ROADMAP.md + - id: visual-recap-tooling group: quality name: Visual-recap skill & taxonomy diff --git a/docs/src/content/docs/basics/comments.md b/docs/src/content/docs/basics/comments.md index 6242210..9ae2e92 100644 --- a/docs/src/content/docs/basics/comments.md +++ b/docs/src/content/docs/basics/comments.md @@ -18,6 +18,7 @@ val x = 42 // Comment after code Use `/* */` for comments spanning multiple lines: + ```panther /* This is a multi-line comment that spans several lines @@ -26,16 +27,18 @@ Use `/* */` for comments spanning multiple lines: val result = calculateValue() ``` -## Nested Comments +## Block Comments Do Not Nest -Multi-line comments can be nested: +Unlike some languages, Panther block comments do **not** nest. The first `*/` closes the comment, even if it looks like it should close an inner one: ```panther -/* Outer comment - /* Inner comment */ - More outer comment */ +/* This block comment + ends at the first closing marker */ +val x = 1 ``` +Avoid writing a `/* */` comment inside another `/* */` comment — the outer comment will end early, and whatever follows will be parsed as code. + ## Documentation Comments While Panther doesn't have special doc comments yet, use a consistent style: @@ -46,7 +49,7 @@ While Panther doesn't have special doc comments yet, use a consistent style: // width - the width of the rectangle // height - the height of the rectangle // Returns: the calculated area -def calculateArea(width: float, height: float): float = { +def calculateArea(width: int, height: int): int = { width * height } ``` @@ -56,29 +59,34 @@ def calculateArea(width: float, height: float): float = { ### Do Comment **Why, not what:** + ```panther // Use binary search because the list is sorted val index = binarySearch(sortedList, target) ``` **Complex algorithms:** + ```panther // Implements Dijkstra's shortest path algorithm -def findShortestPath(graph: Graph, start: Node, end: Node): Path { +def findShortestPath(graph: Graph, start: Node, end: Node): Path = { // Implementation... } ``` **Non-obvious decisions:** ```panther -// Multiply by 0.621371 to convert kilometers to miles -val miles = kilometers * 0.621371 +// Multiply by 1000 to convert kilometers to meters +val kilometers = 5 +val meters = kilometers * 1000 ``` ### Don't Comment **Obvious code:** ```panther +var counter = 0 + // Bad: comment just repeats the code // Increment counter by 1 counter = counter + 1 @@ -89,6 +97,8 @@ counter = counter + 1 **Outdated information:** ```panther +val value = 10 + // Bad: comment doesn't match code // Divide by 2 val result = value * 3 // Comment is wrong! @@ -115,15 +125,22 @@ def experimentalFeature() { Mark areas that need work: + ```panther // TODO: Add error handling -def processFile(filename: string): Data { - return readFile(filename) +def processFile(filename: string): string = { + readFile(filename) } +``` +```panther // FIXME: This crashes with negative values -def calculateSquareRoot(n: float): float { - return Math.sqrt(n) +def calculateSquareRoot(n: int): int = { + var result = 0 + while (result * result < n) { + result = result + 1 + } + result } ``` @@ -139,8 +156,9 @@ Consider adding headers to files: * Date: 2025-12-27 */ -def reverseString(s: string): string { +def reverseString(s: string): string = { // Implementation... + s } ``` diff --git a/docs/src/content/docs/basics/getting-started.md b/docs/src/content/docs/basics/getting-started.md index 6749dcc..9117143 100644 --- a/docs/src/content/docs/basics/getting-started.md +++ b/docs/src/content/docs/basics/getting-started.md @@ -22,7 +22,7 @@ val version = 1 // Function calls println("Welcome to " + name) -println("Version: " + version) +println("Version: " + string(version)) ``` ## Compiling Your Code diff --git a/docs/src/content/docs/basics/operators.md b/docs/src/content/docs/basics/operators.md index b5ecdf0..e122419 100644 --- a/docs/src/content/docs/basics/operators.md +++ b/docs/src/content/docs/basics/operators.md @@ -65,6 +65,7 @@ val notResult = !true // false Logical operators use short-circuit evaluation: + ```panther // If first condition is false, second is not evaluated val result = false && expensiveOperation() @@ -78,8 +79,8 @@ val result2 = true || expensiveOperation() ### Concatenation ```panther -val greeting = "Hello, " + "World!" // "Hello, World!" -val message = "Count: " + 42 // "Count: 42" +val greeting = "Hello, " + "World!" // "Hello, World!" +val message = "Count: " + string(42) // "Count: 42" ``` ## Assignment Operators @@ -91,16 +92,18 @@ var x = 10 x = 20 // Assigns 20 to x ``` -### Compound Assignment +### No Compound Assignment Operators + +Panther does not have compound assignment operators like `+=`, `-=`, `*=`, `/=`, or `%=`. Write the full expression instead: ```panther var count = 10 -count += 5 // count = count + 5 (15) -count -= 3 // count = count - 3 (12) -count *= 2 // count = count * 2 (24) -count /= 4 // count = count / 4 (6) -count %= 4 // count = count % 4 (2) +count = count + 5 // 15 +count = count - 3 // 12 +count = count * 2 // 24 +count = count / 4 // 6 +count = count % 4 // 2 ``` ## Operator Precedence @@ -129,9 +132,11 @@ val bool2 = (true || false) && false // false ### Numeric Types +Panther does not currently have a floating-point type — `int` is the only numeric type, and division truncates: + ```panther -val intResult = 10 / 3 // Integer division: 3 -val floatResult = 10.0 / 3.0 // Float division: 3.333... +val intResult = 10 / 3 // Integer division truncates: 3 +val remainder = 10 % 3 // Use modulo to get what division drops: 1 ``` ### Boolean Logic diff --git a/docs/src/content/docs/basics/variables.md b/docs/src/content/docs/basics/variables.md index 8dc7ccf..ffa1c8f 100644 --- a/docs/src/content/docs/basics/variables.md +++ b/docs/src/content/docs/basics/variables.md @@ -12,14 +12,14 @@ Use `val` to declare immutable values: ```panther val message = "Hello" val count = 42 -val price = 99.99 +val price = 9999 // price in cents ``` -Once assigned, `val` variables cannot be changed: +A `val` is meant to be assigned once. Use `var` (below) for anything you intend to reassign: ```panther val x = 10 -x = 20 // Error: Cannot reassign immutable value +println(x) ``` ## Mutable Variables with var @@ -39,7 +39,7 @@ Panther has type inference, but you can explicitly specify types: ```panther val name: string = "Panther" val age: int = 42 -val price: float = 99.99 +val letter: char = 'P' val isActive: bool = true ``` @@ -50,7 +50,7 @@ The compiler automatically infers types: ```panther val text = "hello" // Inferred as string val number = 42 // Inferred as int -val decimal = 3.14 // Inferred as float +val letter = 'h' // Inferred as char val flag = true // Inferred as bool ``` @@ -74,6 +74,7 @@ val x = 5 Variables are scoped to the block they're declared in: + ```panther { val outer = "outside" diff --git a/docs/src/content/docs/data-types/index.md b/docs/src/content/docs/data-types/index.md index e18c928..c794d5f 100644 --- a/docs/src/content/docs/data-types/index.md +++ b/docs/src/content/docs/data-types/index.md @@ -17,8 +17,10 @@ Explore Panther's type system: ## Quick Example ```panther -val name: String = "Panther" -val age: Int = 42 -val scores: Array = [95, 87, 92] -val user: Option = Some(currentUser) +val name: string = "Panther" +val age: int = 42 +val scores: Array[int] = new Array[int](3) +scores(0) = 95 +scores(1) = 87 +scores(2) = 92 ``` diff --git a/docs/src/content/docs/flow-control/conditional-statements.md b/docs/src/content/docs/flow-control/conditional-statements.md index a331ef0..f78e473 100644 --- a/docs/src/content/docs/flow-control/conditional-statements.md +++ b/docs/src/content/docs/flow-control/conditional-statements.md @@ -56,8 +56,11 @@ if (score >= 90) { In Panther, `if` is an expression that returns a value: ```panther +val a = 10 +val b = 20 val max = if (a > b) a else b +val isActive = true val status = if (isActive) { "active" } else { @@ -91,6 +94,11 @@ if (x > 0) { Each branch can contain multiple expressions: ```panther +def computeValue(): int = 21 +def getDefault(): int = 0 + +val condition = true + val result = if (condition) { val temp = computeValue() val adjusted = temp * 2 @@ -149,14 +157,20 @@ if (!hasLicense) { Logical operators use short-circuit evaluation: ```panther +val numerator = 10 +val denominator = 2 + // If first is false, second is not evaluated if (denominator != 0 && numerator / denominator > 1) { println("Safe division") } +val useDefault = true +val value = "" + // If first is true, second is not evaluated -if (value == null || value.length() > 0) { - println("Valid or null") +if (useDefault || value.length > 0) { + println("Using default or value is non-empty") } ``` @@ -195,7 +209,7 @@ def processValue(value: int): string = { } else if (value > 100) { "Invalid: too large" } else { - "Valid: " + value + "Valid: " + string(value) } } ``` @@ -220,11 +234,16 @@ if (isValid && isComplete) { Since if is an expression, you can use it anywhere a value is expected: ```panther +val isDebug = true + // As a function argument println(if (isDebug) "Debug mode" else "Production mode") -// In calculations -val discount = basePrice * if (isMember) 0.1 else 0 +val basePrice = 100 +val isMember = true + +// In calculations (a flat member discount, since there is no float type) +val discount = basePrice * if (isMember) 10 else 0 ``` ## Exhaustive Conditions @@ -232,6 +251,8 @@ val discount = basePrice * if (isMember) 0.1 else 0 Ensure all cases are covered: ```panther +val age = 30 + // Good: all cases covered val category = if (age < 13) { "child" @@ -243,6 +264,8 @@ val category = if (age < 13) { "senior" } +val condition = true + // Each branch must return the same type val result: int = if (condition) { 42 diff --git a/docs/src/content/docs/flow-control/loops.md b/docs/src/content/docs/flow-control/loops.md index 9aa17e1..bac4670 100644 --- a/docs/src/content/docs/flow-control/loops.md +++ b/docs/src/content/docs/flow-control/loops.md @@ -26,7 +26,7 @@ The condition is checked before each iteration: ```panther var i = 0 while (i < 3) { - println("Iteration: " + i) + println("Iteration: " + string(i)) i = i + 1 } ``` @@ -49,98 +49,64 @@ var running = true var attempts = 0 while (running) { - val input = readLine() - - if (input == "quit") { - running = false - } - + // ... do some work here ... + attempts = attempts + 1 - + + // Always include a way out, in case the expected condition never arrives if (attempts > 100) { running = false } } + +println("Stopped after " + string(attempts) + " attempts") ``` ## For Loops -Iterate over ranges and collections. +Panther's `for` loop counts a variable across an integer range. There is no `for (x in xs)` collection syntax — to visit every element of an array, loop over its indices instead. ### Range-Based For Loops -Iterate from a start value to an end value: +The loop variable counts from a start value up to (and including) an end value: ```panther -// Inclusive range (0 to 5) -for (i in 0..5) { - println(i) +for (i <- 0 to 5) { + println(string(i)) } // Prints: 0, 1, 2, 3, 4, 5 - -// Exclusive range (0 to 4) -for (i in 0..<5) { - println(i) -} -// Prints: 0, 1, 2, 3, 4 ``` -### Step Ranges - -Iterate with a custom step: - -```panther -// Count by twos -for (i in 0..10 step 2) { - println(i) -} -// Prints: 0, 2, 4, 6, 8, 10 - -// Count by fives -for (i in 0..50 step 5) { - println(i) -} -// Prints: 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50 -``` - -### Descending Ranges - -Iterate backwards: - -```panther -for (i in 10 downTo 0) { - println(i) -} -// Prints: 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 - -for (i in 5 downTo 1 step 2) { - println(i) -} -// Prints: 5, 3, 1 -``` +The range is always inclusive on both ends, and always counts upward by one — there is no `step`, `downTo`, or exclusive-range syntax. To count downward or skip values, use a `while` loop instead (see below). ## Collection Iteration -Iterate over arrays and lists: +Arrays don't support a `for (x in xs)` form. Iterate by index instead, using `.length` for the bound: ```panther -val fruits = ["apple", "banana", "cherry"] +val fruits = new Array[string](3) +fruits(0) = "apple" +fruits(1) = "banana" +fruits(2) = "cherry" -for (fruit in fruits) { - println(fruit) +for (i <- 0 to (fruits.length - 1)) { + println(fruits(i)) } // Prints: apple, banana, cherry ``` ### With Index -Access both the element and its index: +Since you're already looping by index, the index is available for free — no separate `withIndex()` call is needed: ```panther -val colors = ["red", "green", "blue"] +val colors = new Array[string](3) +colors(0) = "red" +colors(1) = "green" +colors(2) = "blue" -for ((index, color) in colors.withIndex()) { - println(index + ": " + color) +for (index <- 0 to (colors.length - 1)) { + println(string(index) + ": " + colors(index)) } // Prints: 0: red, 1: green, 2: blue ``` @@ -150,9 +116,9 @@ for ((index, color) in colors.withIndex()) { Loops can be nested inside other loops: ```panther -for (i in 1..3) { - for (j in 1..3) { - println("i=" + i + ", j=" + j) +for (i <- 1 to 3) { + for (j <- 1 to 3) { + println("i=" + string(i) + ", j=" + string(j)) } } ``` @@ -160,10 +126,10 @@ for (i in 1..3) { Create multiplication tables: ```panther -for (i in 1..10) { - for (j in 1..10) { +for (i <- 1 to 10) { + for (j <- 1 to 10) { val product = i * j - print(product + "\t") + print(string(product) + "\t") } println("") } @@ -177,24 +143,31 @@ Sum values in a loop: ```panther var sum = 0 -for (i in 1..10) { +for (i <- 1 to 10) { sum = sum + i } -println("Sum: " + sum) // Sum: 55 +println("Sum: " + string(sum)) // Sum: 55 ``` ### Finding Maximum ```panther -val numbers = [3, 7, 2, 9, 1, 5] -var max = numbers[0] - -for (num in numbers) { - if (num > max) { - max = num +val numbers = new Array[int](6) +numbers(0) = 3 +numbers(1) = 7 +numbers(2) = 2 +numbers(3) = 9 +numbers(4) = 1 +numbers(5) = 5 + +var max = numbers(0) + +for (i <- 0 to (numbers.length - 1)) { + if (numbers(i) > max) { + max = numbers(i) } } -println("Maximum: " + max) // Maximum: 9 +println("Maximum: " + string(max)) // Maximum: 9 ``` ### Counting @@ -202,42 +175,60 @@ println("Maximum: " + max) // Maximum: 9 Count elements that match a condition: ```panther -val values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] -var evenCount = 0 +val values = new Array[int](10) +for (i <- 0 to (values.length - 1)) { + values(i) = i + 1 +} -for (value in values) { - if (value % 2 == 0) { +var evenCount = 0 +for (i <- 0 to (values.length - 1)) { + if (values(i) % 2 == 0) { evenCount = evenCount + 1 } } -println("Even numbers: " + evenCount) // Even numbers: 5 +println("Even numbers: " + string(evenCount)) // Even numbers: 5 ``` ### Building Collections -Create a new collection based on existing data: +Arrays are fixed-size, so "building" one means allocating a same-sized array up front and filling it in a loop: ```panther -val numbers = [1, 2, 3, 4, 5] -val doubled = [] +val numbers = new Array[int](5) +for (i <- 0 to (numbers.length - 1)) { + numbers(i) = i + 1 +} -for (num in numbers) { - doubled.add(num * 2) +val doubled = new Array[int](numbers.length) +for (i <- 0 to (numbers.length - 1)) { + doubled(i) = numbers(i) * 2 } // doubled is [2, 4, 6, 8, 10] ``` ### Filtering -Select elements that match criteria: +To select only matching elements, count the matches first, then allocate a result array of exactly that size: ```panther -val values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] -val evens = [] +val values = new Array[int](10) +for (i <- 0 to (values.length - 1)) { + values(i) = i + 1 +} + +var evenCount = 0 +for (i <- 0 to (values.length - 1)) { + if (values(i) % 2 == 0) { + evenCount = evenCount + 1 + } +} -for (value in values) { - if (value % 2 == 0) { - evens.add(value) +val evens = new Array[int](evenCount) +var evenIndex = 0 +for (i <- 0 to (values.length - 1)) { + if (values(i) % 2 == 0) { + evens(evenIndex) = values(i) + evenIndex = evenIndex + 1 } } // evens is [2, 4, 6, 8, 10] @@ -248,23 +239,26 @@ for (value in values) { **Use while when:** - The number of iterations is unknown - Looping until a condition changes -- Reading input until a sentinel value +- Counting downward or by a custom step (`for` only counts up by one) ```panther -var input = "" -while (input != "quit") { - input = readLine() - processInput(input) +var count = 10 +while (count > 0) { + println(string(count)) + count = count - 1 } +println("Liftoff!") ``` **Use for when:** - Iterating a specific number of times -- Processing collections -- Working with ranges +- Processing an array by index +- Working with a simple ascending range ```panther -for (i in 0..10) { +def processValue(value: int): unit = println(string(value)) + +for (i <- 0 to 10) { processValue(i) } ``` @@ -273,9 +267,13 @@ for (i in 0..10) { ### Countdown +`for` only counts upward, so a countdown uses `while`: + ```panther -for (i in 10 downTo 1) { - println(i) +var i = 10 +while (i >= 1) { + println(string(i)) + i = i - 1 } println("Liftoff!") ``` @@ -283,23 +281,33 @@ println("Liftoff!") ### Processing Pairs ```panther -val names = ["Alice", "Bob", "Charlie"] -val scores = [95, 87, 92] - -for (i in 0.. " + next) +for (i <- 0 to (numbers.length - 2)) { + val current = numbers(i) + val next = numbers(i + 1) + println(string(current) + " -> " + string(next)) } ``` @@ -309,10 +317,10 @@ for (i in 0.. println("Weekend") - "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" => println("Weekday") + case "Saturday" => println("Weekend") + case "Sunday" => println("Weekend") + case "Monday" => println("Weekday") + case "Tuesday" => println("Weekday") + case "Wednesday" => println("Weekday") + case "Thursday" => println("Weekday") + case "Friday" => println("Weekday") } ``` @@ -22,11 +27,12 @@ day match { Basic structure of a match expression: + ```panther value match { - pattern1 => expression1 - pattern2 => expression2 - pattern3 => expression3 + case pattern1 => expression1 + case pattern2 => expression2 + case pattern3 => expression3 } ``` @@ -38,25 +44,33 @@ Match against specific values: val number = 42 number match { - 0 => println("Zero") - 1 => println("One") - 42 => println("The answer!") - 100 => println("Century") + case 0 => println("Zero") + case 1 => println("One") + case 42 => println("The answer!") + case 100 => println("Century") } ``` ## Multiple Patterns -Match multiple values with a single case: +Panther's `match` has no `|` or comma syntax for matching several values with one case body. Write a separate `case` for each value instead: ```panther val month = "June" month match { - "December", "January", "February" => println("Winter") - "March", "April", "May" => println("Spring") - "June", "July", "August" => println("Summer") - "September", "October", "November" => println("Fall") + case "December" => println("Winter") + case "January" => println("Winter") + case "February" => println("Winter") + case "March" => println("Spring") + case "April" => println("Spring") + case "May" => println("Spring") + case "June" => println("Summer") + case "July" => println("Summer") + case "August" => println("Summer") + case "September" => println("Fall") + case "October" => println("Fall") + case "November" => println("Fall") } ``` @@ -68,9 +82,9 @@ The `_` wildcard matches any value: val value = 99 value match { - 0 => println("Zero") - 1 => println("One") - _ => println("Some other number") + case 0 => println("Zero") + case 1 => println("One") + case _ => println("Some other number") } ``` @@ -81,10 +95,17 @@ The wildcard is typically used as the last case to handle all remaining values. Match expressions return values: ```panther +val day = "Monday" + val dayType = day match { - "Saturday", "Sunday" => "Weekend" - "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" => "Weekday" - _ => "Unknown" + case "Saturday" => "Weekend" + case "Sunday" => "Weekend" + case "Monday" => "Weekday" + case "Tuesday" => "Weekday" + case "Wednesday" => "Weekday" + case "Thursday" => "Weekday" + case "Friday" => "Weekday" + case _ => "Unknown" } println(dayType) @@ -93,13 +114,15 @@ println(dayType) Use in calculations: ```panther +val grade = "B" + val points = grade match { - "A" => 4 - "B" => 3 - "C" => 2 - "D" => 1 - "F" => 0 - _ => 0 + case "A" => 4 + case "B" => 3 + case "C" => 2 + case "D" => 1 + case "F" => 0 + case _ => 0 } ``` @@ -108,20 +131,23 @@ val points = grade match { Match cases can contain multiple statements: ```panther +def logSuccess(message: string): unit = println("LOG: " + message) +def logError(message: string): unit = println("ERR: " + message) + val status = "error" status match { - "success" => { + case "success" => { val message = "Operation completed" logSuccess(message) println(message) } - "error" => { + case "error" => { val message = "Operation failed" logError(message) println(message) } - _ => { + case _ => { println("Unknown status") } } @@ -135,27 +161,34 @@ Match against numeric values: val age = 25 val category = age match { - 0 => "newborn" - 1, 2 => "toddler" - 3, 4, 5 => "preschool" - _ => "other" + case 0 => "newborn" + case 1 => "toddler" + case 2 => "toddler" + case 3 => "preschool" + case 4 => "preschool" + case 5 => "preschool" + case _ => "other" } ``` ## Matching Strings ```panther +def startProcess(): unit = println("starting") +def stopProcess(): unit = println("stopping") +def printStatus(): unit = println("status: ok") + val command = "start" command match { - "start" => startProcess() - "stop" => stopProcess() - "restart" => { + case "start" => startProcess() + case "stop" => stopProcess() + case "restart" => { stopProcess() startProcess() } - "status" => printStatus() - _ => println("Unknown command") + case "status" => printStatus() + case _ => println("Unknown command") } ``` @@ -164,76 +197,98 @@ command match { Match based on type: ```panther -val value: Any = 42 +val value: any = 42 value match { - v: int => println("Integer: " + v) - v: string => println("String: " + v) - v: bool => println("Boolean: " + v) - _ => println("Other type") + case v: int => println("Integer: " + string(v)) + case v: string => println("String: " + v) + case v: bool => println("Boolean: " + string(v)) + case _ => println("Other type") } ``` -## Destructuring Tuples +## Destructuring Classes -Extract values from tuples: +Panther has no tuple literal syntax (`(x, y)`), but any class can be destructured the same way — a pattern names the class and binds its fields: ```panther -val point = (10, 20) +class Point(x: int, y: int) + +val point = Point(10, 20) point match { - (0, 0) => println("Origin") - (x, 0) => println("On X-axis at " + x) - (0, y) => println("On Y-axis at " + y) - (x, y) => println("Point at (" + x + ", " + y + ")") + case Point(0, 0) => println("Origin") + case Point(x, 0) => println("On X-axis at " + string(x)) + case Point(0, y) => println("On Y-axis at " + string(y)) + case Point(x, y) => println("Point at (" + string(x) + ", " + string(y) + ")") } ``` ## Matching Options -Handle optional values: +`Option` is not a built-in type, but you can define one and match on it like any other enum: ```panther -val maybeValue: Option = Some(42) +enum Option[T] { + case Some(value: T) + case None +} + +val maybeValue: Option[int] = Option.Some(42) maybeValue match { - Some(value) => println("Found: " + value) - None => println("No value") + case Option.Some(value) => println("Found: " + string(value)) + case Option.None => println("No value") } ``` Use in functions: ```panther -def processOption(opt: Option): string = { +enum Option[T] { + case Some(value: T) + case None +} + +def processOption(opt: Option[string]): string = { opt match { - Some(s) => "Value: " + s - None => "No value provided" + case Option.Some(s) => "Value: " + string(s) + case Option.None => "No value provided" } } ``` ## Matching Results -Handle success or failure: +The same approach works for a `Result`-shaped enum: ```panther -val result: Result = Ok(42) +enum Result { + case Ok(value: int) + case Err(error: string) +} + +val result: Result = Result.Ok(42) result match { - Ok(value) => println("Success: " + value) - Err(error) => println("Error: " + error) + case Result.Ok(value) => println("Success: " + string(value)) + case Result.Err(error) => println("Error: " + error) } ``` With error handling: ```panther -def processResult(r: Result): int = { +enum Result { + case Ok(value: int) + case Err(error: string) +} + +def processResult(r: Result): int = { r match { - Ok(n) => n * 2 - Err(msg) => { - logError(msg) + case Result.Ok(n) => n * 2 + case Result.Err(msg) => { + println(msg) 0 } } @@ -242,73 +297,88 @@ def processResult(r: Result): int = { ## Guard Conditions -Add conditions to patterns: +Panther's `match` does not support pattern guards (`case n if n < 0 => ...`) — a `case` is followed directly by `=>`, with no conditional clause in between. For value ranges like this, use an `if`/`else if` chain instead: ```panther val number = 15 -number match { - n if n < 0 => println("Negative") - n if n == 0 => println("Zero") - n if n < 10 => println("Small positive") - n if n < 100 => println("Medium positive") - _ => println("Large positive") +val description = if (number < 0) { + "Negative" +} else if (number == 0) { + "Zero" +} else if (number < 10) { + "Small positive" +} else if (number < 100) { + "Medium positive" +} else { + "Large positive" } + +println(description) ``` ## Nested Matching -Match expressions can be nested: +Patterns can nest, including inside a class's fields: ```panther -val pair = (Some(10), Some(20)) +enum Option[T] { + case Some(value: T) + case None +} + +class Pair(first: Option[int], second: Option[int]) + +val pair = Pair(Option.Some(10), Option.Some(20)) pair match { - (Some(x), Some(y)) => println("Both values: " + x + ", " + y) - (Some(x), None) => println("Only first: " + x) - (None, Some(y)) => println("Only second: " + y) - (None, None) => println("No values") + case Pair(Option.Some(x), Option.Some(y)) => println("Both values: " + string(x) + ", " + string(y)) + case Pair(Option.Some(x), Option.None) => println("Only first: " + string(x)) + case Pair(Option.None, Option.Some(y)) => println("Only second: " + string(y)) + case Pair(Option.None, Option.None) => println("No values") } ``` ## Matching Enums -Match enum variants: +Match enum variants. Enum cases are declared one per `case` line (no trailing commas), and `match` is always written after the scrutinee — there is no `match (value) { ... }` prefix form: ```panther enum Color { - Red, - Green, - Blue + case Red + case Green + case Blue } val color = Color.Red -match (color) { - Color.Red => println("Red color") - Color.Green => println("Green color") - Color.Blue => println("Blue color") +color match { + case Color.Red => println("Red color") + case Color.Green => println("Green color") + case Color.Blue => println("Blue color") } ``` ## Matching Discriminated Unions -Match on union variants: +Panther has no `union` keyword — algebraic sum types with per-variant data are written as an `enum` whose cases carry parameters, just like `Color` above but with fields: ```panther -union Shape { - Circle(radius: float), - Rectangle(width: float, height: float), - Triangle(base: float, height: float) +enum Shape { + case Circle(radius: int) + case Rectangle(width: int, height: int) + case Triangle(base: int, height: int) } -val shape = Shape.Circle(5.0) +val shape = Shape.Circle(5) -val area = match (shape) { - Circle(r) => 3.14159 * r * r - Rectangle(w, h) => w * h - Triangle(b, h) => 0.5 * b * h +val description = shape match { + case Shape.Circle(r) => "circle with radius " + string(r) + case Shape.Rectangle(w, h) => "rectangle " + string(w) + "x" + string(h) + case Shape.Triangle(b, h) => "triangle with base " + string(b) + " and height " + string(h) } + +println(description) ``` ## Exhaustiveness @@ -316,17 +386,26 @@ val area = match (shape) { Match expressions should handle all possible cases: ```panther +enum Option[T] { + case Some(value: T) + case None +} + +val option: Option[int] = Option.Some(5) + // Good: all cases covered val result = option match { - Some(x) => x - None => 0 + case Option.Some(x) => x + case Option.None => 0 } +val value = 1 + // Good: wildcard catches all remaining cases val category = value match { - 0 => "zero" - 1 => "one" - _ => "other" + case 0 => "zero" + case 1 => "one" + case _ => "other" } ``` @@ -339,19 +418,19 @@ var state = "idle" while (state != "done") { state = state match { - "idle" => { + case "idle" => { println("Starting") "processing" } - "processing" => { + case "processing" => { println("Working") "complete" } - "complete" => { + case "complete" => { println("Finishing") "done" } - _ => "done" + case _ => "done" } } ``` @@ -359,13 +438,18 @@ while (state != "done") { ### Command Processing ```panther -def executeCommand(cmd: string, args: Array): string = { +def addItem(item: string): string = "added: " + item +def removeItem(item: string): string = "removed: " + item +def listItems(): string = "listing items" +def clearAll(): string = "cleared" + +def executeCommand(cmd: string, args: Array[string]): string = { cmd match { - "add" => addItem(args[0]) - "remove" => removeItem(args[0]) - "list" => listItems() - "clear" => clearAll() - _ => "Unknown command: " + cmd + case "add" => addItem(args(0)) + case "remove" => removeItem(args(0)) + case "list" => listItems() + case "clear" => clearAll() + case _ => "Unknown command: " + cmd } } ``` @@ -373,25 +457,27 @@ def executeCommand(cmd: string, args: Array): string = { ### Error Code Translation ```panther +val errorCode = 2 + val errorMessage = errorCode match { - 0 => "Success" - 1 => "File not found" - 2 => "Permission denied" - 3 => "Invalid input" - 4 => "Network error" - _ => "Unknown error: " + errorCode + case 0 => "Success" + case 1 => "File not found" + case 2 => "Permission denied" + case 3 => "Invalid input" + case 4 => "Network error" + case _ => "Unknown error: " + string(errorCode) } ``` ### Type-Based Dispatch ```panther -def processValue(value: Any): string = { +def processValue(value: any): string = { value match { - n: int => "Processing integer: " + n - s: string => "Processing string: " + s - b: bool => "Processing boolean: " + b - _ => "Unknown type" + case n: int => "Processing integer: " + string(n) + case s: string => "Processing string: " + s + case b: bool => "Processing boolean: " + string(b) + case _ => "Unknown type" } } ``` diff --git a/docs/src/content/docs/functions/defining-functions.md b/docs/src/content/docs/functions/defining-functions.md index 9362a39..e5a8d19 100644 --- a/docs/src/content/docs/functions/defining-functions.md +++ b/docs/src/content/docs/functions/defining-functions.md @@ -10,9 +10,9 @@ Functions encapsulate reusable blocks of code. Define a function using the `def` keyword: ```panther -def functionName(param1: Type1, param2: Type2): ReturnType = { +def functionName(param1: int, param2: string): bool = { // function body - value // Last expression is returned + param1 > 0 // Last expression is returned } ``` @@ -88,35 +88,27 @@ def printNumber(n: int) = { } ``` -## Local Functions (future) +## Local Functions (not supported) -Define functions inside other functions: +Panther does not currently support defining a function inside another function's body — `def` +is only valid at the top level or as a class/object member. To split logic the way a nested +helper would in other languages, define a second top-level function instead: ```panther +def double(y: int): int = { + y * 2 +} + def outer(x: int): int = { - def inner(y: int): int = { - y * 2 - } - - inner(x) + 1 + double(x) + 1 } val result = outer(5) // 11 ``` -Local functions can access variables from the enclosing scope: - -```panther -def makeAdder(n: int): (int) => int = { - def add(x: int): int = { - x + n // Accesses n from outer scope - } - add -} - -val add5 = makeAdder(5) -val result = add5(10) // 15 -``` +Because Panther has no function types or lambdas (see [Higher-Order Functions](higher-order-functions)), +functions also cannot close over a variable and be handed back as a value. Every function's +behavior is fixed by its top-level or class-member definition. ## Recursive Functions @@ -143,19 +135,21 @@ Follow these conventions: - Be descriptive: `validateEmailAddress` over `validate` ```panther +class User(id: int) + // Good -def calculateArea(width: float, height: float): float +def calculateArea(width: int, height: int): int = width * height -def isValidEmail(email: string): bool +def isValidEmail(email: string): bool = email.length > 0 -def getUserById(id: int): User +def getUserById(id: int): User = new User(id) // Avoid -def calc(w: float, h: float): float +def calc(w: int, h: int): int = w * h -def check(s: string): bool +def check(s: string): bool = s.length > 0 -def get(x: int): User +def get(x: int): User = new User(x) ``` ## Pure Functions diff --git a/docs/src/content/docs/functions/higher-order-functions.md b/docs/src/content/docs/functions/higher-order-functions.md index 1995ee1..5a0f4c1 100644 --- a/docs/src/content/docs/functions/higher-order-functions.md +++ b/docs/src/content/docs/functions/higher-order-functions.md @@ -1,275 +1,166 @@ --- title: Higher-Order Functions -description: Functions as first-class values in Panther +description: Where Panther stands on functions as values, and what to use instead --- -Higher-order functions are functions that take other functions as parameters or return functions as results. This enables powerful abstraction and code reuse. +A higher-order function is one that takes another function as an argument or +returns one. **Panther does not support this yet.** There is no way to write a +parameter whose type is a function, and there are no anonymous functions. -## Functions as Parameters +This page records the intended design and, more usefully, shows what to write +today instead. -Pass functions to other functions: +## What is not implemented -```panther -def applyOperation(x: int, y: int, op: (int, int) => int): int = { - op(x, y) -} - -def add(a: int, b: int): int = a + b -def multiply(a: int, b: int): int = a * b - -val sum = applyOperation(5, 3, add) // 8 -val product = applyOperation(5, 3, multiply) // 15 -``` - -## Function Type Syntax - -Function types are written as `(ParamTypes) => ReturnType`: +Neither of these parses: + ```panther -// Takes int, returns int -val f: (int) => int - -// Takes two ints, returns int -val g: (int, int) => int - -// Takes string, returns bool -val h: (string) => bool - -// Takes no parameters, returns unit -val i: () => unit +def applyTwice(f: (int) -> int, x: int): int = f(f(x)) ``` -## Lambda Expressions - -Create anonymous functions inline: - + ```panther val double = (x: int) => x * 2 - -val sum = (a: int, b: int) => a + b - -val greet = (name: string) => "Hello, " + name ``` -Use lambdas with higher-order functions: +The parser rejects the first at the parameter's opening parenthesis, and the +second at `=>`. Because function values do not exist, none of the usual +building blocks — `map`, `filter`, `reduce`, function composition, currying, +partial application, closures — can be expressed. Nested functions are not +supported either: a `def` cannot appear inside another function's body. -```panther -val result = applyOperation(5, 3, (a: int, b: int) => a * a + b * b) -``` +## What to write instead -## Type Inference in Lambdas +### Named functions at the top level -Parameter types can often be inferred: +Give the operation a name and call it directly. This covers most cases where a +lambda would otherwise be passed inline: ```panther -def applyTwice(f: (int) => int, x: int): int = { - f(f(x)) -} - -// Type inferred from context -val result = applyTwice((x) => x + 1, 5) // 7 -``` - -## Returning Functions +def double(x: int): int = x * 2 -Functions can return other functions: - -```panther -def makeMultiplier(factor: int): (int) => int = { - (x: int) => x * factor +def applyToRange(limit: int): unit = { + for (i <- 0 to limit) { + println(string(double(i))) + } } -val double = makeMultiplier(2) -val triple = makeMultiplier(3) - -println(double(5)) // 10 -println(triple(5)) // 15 +applyToRange(4) ``` -## Closures - -Returned functions can capture variables from their enclosing scope: - -```panther -def makeCounter(): () => int = { - var count = 0 - - () => { - count = count + 1 - count - } -} - -val counter = makeCounter() -println(counter()) // 1 -println(counter()) // 2 -println(counter()) // 3 -``` - -## Common Higher-Order Functions - -### Map +### Generic functions -Transform each element of a collection: +Generics do work, so a function can be written once and used at several types: ```panther -def map(list: List, f: (T) => R): List = { - val result = [] - for (item in list) { - result.add(f(item)) - } - result -} +def identity[T](x: T): T = x -val numbers = [1, 2, 3, 4, 5] -val doubled = map(numbers, (x) => x * 2) // [2, 4, 6, 8, 10] +println(string(identity(42))) +println(identity("hello")) ``` -### Filter +### Transforming an array -Select elements that match a predicate: +Where another language would write `map`, walk the array by index and build a +new one: ```panther -def filter(list: List, predicate: (T) => bool): List = { - val result = [] - for (item in list) { - if (predicate(item)) { - result.add(item) - } - } - result -} - -val numbers = [1, 2, 3, 4, 5, 6] -val evens = filter(numbers, (x) => x % 2 == 0) // [2, 4, 6] -``` +val source = new Array[int](4) +source(0) = 1 +source(1) = 2 +source(2) = 3 +source(3) = 4 -### Reduce - -Combine all elements into a single value: - -```panther -def reduce(list: List, initial: T, f: (T, T) => T): T = { - var result = initial - for (item in list) { - result = f(result, item) - } - result +val doubled = new Array[int](source.length) +for (i <- 0 to (source.length - 1)) { + doubled(i) = source(i) * 2 } -val numbers = [1, 2, 3, 4, 5] -val sum = reduce(numbers, 0, (acc, x) => acc + x) // 15 -``` - -## Function Composition - -Combine functions to create new functions: - -```panther -def compose(f: (B) => C, g: (A) => B): (A) => C = { - (x: A) => f(g(x)) +for (i <- 0 to (doubled.length - 1)) { + println(string(doubled(i))) } - -val addOne = (x: int) => x + 1 -val double = (x: int) => x * 2 - -val addOneThenDouble = compose(double, addOne) -println(addOneThenDouble(5)) // 12 (5 + 1 = 6, 6 * 2 = 12) ``` -## Currying +### Filtering -Transform a multi-parameter function into a series of single-parameter functions: +Without function values, filtering is a two-pass job: count the matches, then +allocate and fill. ```panther -def add(a: int): (int) => int = { - (b: int) => a + b -} +val values = new Array[int](5) +values(0) = 1 +values(1) = 2 +values(2) = 3 +values(3) = 4 +values(4) = 5 -val add5 = add(5) -println(add5(3)) // 8 -println(add5(10)) // 15 -``` - -## Partial Application - -Create new functions by fixing some arguments: - -```panther -def makeAdder(x: int): (int) => int = { - (y: int) => x + y +var matches = 0 +for (i <- 0 to (values.length - 1)) { + if (values(i) % 2 == 0) { + matches = matches + 1 + } } -def makeMultiplier(factor: int): (int) => int = { - (n: int) => n * factor +val evens = new Array[int](matches) +var next = 0 +for (i <- 0 to (values.length - 1)) { + if (values(i) % 2 == 0) { + evens(next) = values(i) + next = next + 1 + } } -val add10 = makeAdder(10) -val triple = makeMultiplier(3) - -println(add10(5)) // 15 -println(triple(4)) // 12 +println("found " + string(matches) + " even numbers") ``` -## Predicates +### Reducing -Functions that return boolean values: +An accumulator in a loop replaces `reduce`: ```panther -def any(list: List, predicate: (T) => bool): bool = { - for (item in list) { - if (predicate(item)) { - true // Found a match - } - } - false // No matches found -} +val numbers = new Array[int](4) +numbers(0) = 10 +numbers(1) = 20 +numbers(2) = 30 +numbers(3) = 40 -def all(list: List, predicate: (T) => bool): bool = { - for (item in list) { - if (!predicate(item)) { - false // Found non-match - } - } - true // All match +var total = 0 +for (i <- 0 to (numbers.length - 1)) { + total = total + numbers(i) } -val numbers = [1, 2, 3, 4, 5] -val hasEven = any(numbers, (x) => x % 2 == 0) // true -val allPositive = all(numbers, (x) => x > 0) // true +println("sum: " + string(total)) ``` -## Common Patterns +### Choosing behaviour at runtime -### Chaining Operations +Where another language would pass a strategy function, use an `enum` and +`match`. This is the closest thing Panther has to dispatching on behaviour: ```panther -val result = numbers - .map((x) => x * 2) - .filter((x) => x > 10) - .reduce(0, (acc, x) => acc + x) -``` - -### Event Handlers +enum Operation { + case Add + case Multiply + case Max +} -```panther -def onClick(handler: () => unit): unit = { - // Register the handler +def apply(op: Operation, a: int, b: int): int = op match { + case Operation.Add => a + b + case Operation.Multiply => a * b + case Operation.Max => if (a > b) a else b } -onClick(() => println("Button clicked!")) +println(string(apply(Operation.Add, 3, 4))) +println(string(apply(Operation.Multiply, 3, 4))) +println(string(apply(Operation.Max, 3, 4))) ``` -### Strategy Pattern +The enum is closed, so unlike a function parameter it cannot be extended by a +caller — but it is checked exhaustively and it works today. -```panther -def processData(data: Data, strategy: (Data) => Result): Result = { - strategy(data) -} - -val fastStrategy = (data: Data) => quickProcess(data) -val thoroughStrategy = (data: Data) => deepProcess(data) +## Next Steps -val result1 = processData(data, fastStrategy) -val result2 = processData(data, thoroughStrategy) -``` +- [Defining Functions](defining-functions) - Function declaration syntax +- [Parameters](parameters) - Passing values into functions +- [Return Values](return-values) - What a function produces diff --git a/docs/src/content/docs/functions/parameters.md b/docs/src/content/docs/functions/parameters.md index 543bc64..140fcc5 100644 --- a/docs/src/content/docs/functions/parameters.md +++ b/docs/src/content/docs/functions/parameters.md @@ -22,12 +22,12 @@ greet("Alice") Functions can have multiple parameters: ```panther -def add(a: int, b: int): int { - return a + b +def add(a: int, b: int): int = { + a + b } -def introduce(firstName: string, lastName: string, age: int): string { - return firstName + " " + lastName + " is " + age + " years old" +def introduce(firstName: string, lastName: string, age: int): string = { + firstName + " " + lastName + " is " + string(age) + " years old" } val result = introduce("John", "Doe", 30) @@ -38,8 +38,8 @@ val result = introduce("John", "Doe", 30) Arguments must be passed in the order parameters are defined: ```panther -def divide(numerator: int, denominator: int): int { - return numerator / denominator +def divide(numerator: int, denominator: int): int = { + numerator / denominator } val result = divide(10, 2) // 5 @@ -52,28 +52,34 @@ All parameters must have explicit type annotations: ```panther // Correct -def square(x: int): int { - return x * x +def square(x: int): int = { + x * x } +``` + +```panther // Error: parameter type required -def square(x) { // Error! - return x * x +def square(x) = { // Error! + x * x } ``` -## Immutable Parameters +## Parameter Reassignment -Parameters are immutable by default: +Parameters are ordinary local bindings, so they can be reassigned inside the function body: ```panther def increment(x: int): int = { - x = x + 1 // Error: Cannot reassign parameter + x = x + 1 // Allowed - x behaves like a local variable x } +``` -// Instead, create a new variable -def increment(x: int): int = { +Many style guides still prefer introducing a new value instead of mutating a parameter, since it keeps the original argument visible for the rest of the function: + +```panther +def incrementPure(x: int): int = { val result = x + 1 result } @@ -97,41 +103,45 @@ def example(x: int): int = { While Panther doesn't have built-in varargs, you can use arrays: ```panther -def sum(numbers: Array): int = { +def sum(numbers: Array[int]): int = { var total = 0 - for (num in numbers) { - total = total + num + for (i <- 0 to numbers.length - 1) { + total = total + numbers(i) } total } -val result = sum([1, 2, 3, 4, 5]) // 15 +val numbers = new Array[int](5) +numbers(0) = 1 +numbers(1) = 2 +numbers(2) = 3 +numbers(3) = 4 +numbers(4) = 5 + +val result = sum(numbers) // 15 ``` ## Function Parameters -Functions can take other functions as parameters (see [Higher-Order Functions](higher-order-functions)): - -```panther -def applyOperation(x: int, y: int, op: (int, int) => int): int = { - op(x, y) -} - -def add(a: int, b: int): int = a + b - -val result = applyOperation(5, 3, add) // 8 -``` +Panther does not yet have function types or lambdas, so a function cannot be declared to take +another function as a parameter (see [Higher-Order Functions](higher-order-functions) for what +is and isn't possible today). ## Common Patterns ### Validation ```panther -def divide(numerator: int, denominator: int): Result = { +enum DivideResult { + case Ok(value: int) + case Err(message: string) +} + +def divide(numerator: int, denominator: int): DivideResult = { if (denominator == 0) { - Err("Division by zero") + DivideResult.Err("Division by zero") } else { - Ok(numerator / denominator) + DivideResult.Ok(numerator / denominator) } } ``` @@ -139,11 +149,9 @@ def divide(numerator: int, denominator: int): Result = { ### Transformation ```panther -def toUpperCase(text: string): string { - // Convert text to uppercase -} - def double(x: int): int = x * 2 + +def negate(x: int): int = -x ``` ### Predicate Functions @@ -153,5 +161,5 @@ def isPositive(n: int): bool = n > 0 def isEven(n: int): bool = n % 2 == 0 -def isEmpty(text: string): bool = text.length() == 0 +def isEmpty(text: string): bool = text.length == 0 ``` diff --git a/docs/src/content/docs/functions/return-values.md b/docs/src/content/docs/functions/return-values.md index dcc1c0e..ebb81ec 100644 --- a/docs/src/content/docs/functions/return-values.md +++ b/docs/src/content/docs/functions/return-values.md @@ -109,96 +109,126 @@ def logError(error: string) = { ## Returning Complex Types -### Tuples +### Multiple Values via a Class -Return multiple values using tuples: +Panther has no tuple type, so return multiple values by defining a small class: ```panther -def divideWithRemainder(a: int, b: int): (int, int) = { +class DivisionResult(quotient: int, remainder: int) + +def divideWithRemainder(a: int, b: int): DivisionResult = { val quotient = a / b val remainder = a % b - (quotient, remainder) + new DivisionResult(quotient, remainder) } val result = divideWithRemainder(10, 3) -val quotient = result._1 // 3 -val remainder = result._2 // 1 +val quotient = result.quotient // 3 +val remainder = result.remainder // 1 ``` ### Custom Types -Return custom data structures: +Return custom data structures. Note that Panther has no `float`/`double` type — use `int` for numeric fields: ```panther -record Point(x: float, y: float) +class Point(x: int, y: int) -def createPoint(x: float, y: float): Point = { - Point(x, y) +def createPoint(x: int, y: int): Point = { + new Point(x, y) } -val origin = createPoint(0.0, 0.0) +val origin = createPoint(0, 0) ``` ### Collections -Return arrays or lists: +Return arrays: ```panther -def getFirstThree(): Array = { - [1, 2, 3] +def getFirstThree(): Array[int] = { + val result = new Array[int](3) + result(0) = 1 + result(1) = 2 + result(2) = 3 + result } -def getNames(): List = { - List.of("Alice", "Bob", "Charlie") +def getNames(): Array[string] = { + val result = new Array[string](3) + result(0) = "Alice" + result(1) = "Bob" + result(2) = "Charlie" + result } ``` -## Option Type +## Modeling Optional Values -Use `Option` for values that might not exist: +Panther doesn't ship a built-in `Option` type. Define an enum to represent a value that might not exist: ```panther -def findUser(id: int): Option = { +class User(name: string) + +enum UserLookup { + case Found(user: User) + case NotFound +} + +def userExists(id: int): bool = id > 0 + +def getUser(id: int): User = new User("Alice") + +def findUser(id: int): UserLookup = { if (userExists(id)) { - Some(getUser(id)) + UserLookup.Found(getUser(id)) } else { - None + UserLookup.NotFound } } val user = findUser(123) -match (user) { - Some(u) => println("Found: " + u.name) - None => println("User not found") +user match { + case UserLookup.Found(u) => println("Found: " + u.name) + case UserLookup.NotFound => println("User not found") } ``` -## Result Type +## Modeling Operations That Can Fail -Use `Result` for operations that can fail: +Panther doesn't ship a built-in `Result` type either. The same enum pattern works for success/failure outcomes: ```panther -def parseNumber(text: string): Result = { +enum ParseResult { + case Ok(value: int) + case Err(message: string) +} + +def isValidNumber(text: string): bool = text.length > 0 + +def toInt(text: string): int = text.length + +def parseNumber(text: string): ParseResult = { if (isValidNumber(text)) { - Ok(toInt(text)) + ParseResult.Ok(toInt(text)) } else { - Err("Invalid number format") + ParseResult.Err("Invalid number format") } } val result = parseNumber("42") -match (result) { - Ok(num) => println("Number: " + num) - Err(msg) => println("Error: " + msg) +result match { + case ParseResult.Ok(num) => println("Number: " + string(num)) + case ParseResult.Err(msg) => println("Error: " + msg) } ``` ## Multiple Expressions -When a function has multiple statements, the last expression is returned: +When a function has multiple statements, the last expression is returned. This example works in whole-number percentages since Panther has no `float`: ```panther -def calculateDiscount(price: float, percentage: float): float = { +def calculateDiscount(price: int, percentage: int): int = { // Validation if (price <= 0) { 0 @@ -206,7 +236,7 @@ def calculateDiscount(price: float, percentage: float): float = { price } else { // Main logic - last expression is returned - price * (1 - percentage / 100) + price - (price * percentage / 100) } } ``` @@ -216,15 +246,22 @@ def calculateDiscount(price: float, percentage: float): float = { ### Validation with Early Return ```panther -def validateUser(user: User): Result = { - if (user.name.isEmpty()) { - Err("Name is required") +class Applicant(name: string, age: int, email: string) + +enum ValidationResult { + case Valid(user: Applicant) + case Invalid(reason: string) +} + +def validateUser(user: Applicant): ValidationResult = { + if (user.name.length == 0) { + ValidationResult.Invalid("Name is required") } else if (user.age < 0) { - Err("Age must be positive") - } else if (user.email.isEmpty()) { - Err("Email is required") + ValidationResult.Invalid("Age must be positive") + } else if (user.email.length == 0) { + ValidationResult.Invalid("Email is required") } else { - Ok(user) + ValidationResult.Valid(user) } } ``` @@ -232,23 +269,22 @@ def validateUser(user: User): Result = { ### Transformation ```panther -def transformData(input: string): string = { - val trimmed = input.trim() - val upper = trimmed.toUpperCase() - upper +def transformData(input: int): int = { + val doubled = input * 2 + val incremented = doubled + 1 + incremented } // Or as expression -def transformData(input: string): string = - input.trim().toUpperCase() +def transformDataExpr(input: int): int = input * 2 + 1 ``` ### Computation with Multiple Steps ```panther -def calculateFinalPrice(basePrice: float, tax: float, discount: float): float = { - val priceWithTax = basePrice * (1 + tax) - val discountAmount = priceWithTax * discount +def calculateFinalPrice(basePrice: int, taxPercent: int, discountPercent: int): int = { + val priceWithTax = basePrice + (basePrice * taxPercent / 100) + val discountAmount = priceWithTax * discountPercent / 100 val finalPrice = priceWithTax - discountAmount finalPrice } diff --git a/docs/src/content/docs/guides/basics.md b/docs/src/content/docs/guides/basics.md index 4a1e059..c67d63a 100644 --- a/docs/src/content/docs/guides/basics.md +++ b/docs/src/content/docs/guides/basics.md @@ -31,10 +31,9 @@ count = count + 1 While Panther has type inference, you can explicitly specify types: ```panther -val name: String = "Panther" -val age: Int = 42 -val price: Float = 99.99 -val isActive: Bool = true +val name: string = "Panther" +val age: int = 42 +val isActive: bool = true ``` ## Comments @@ -85,7 +84,7 @@ Panther provides built-in string manipulation: ```panther val greeting = "Hello, " + "World!" // String concatenation -val length = greeting.length() // Get string length +val len = greeting.length // Get string length ``` ## Output diff --git a/docs/src/content/docs/guides/data-types.md b/docs/src/content/docs/guides/data-types.md index ead6664..31e8ffa 100644 --- a/docs/src/content/docs/guides/data-types.md +++ b/docs/src/content/docs/guides/data-types.md @@ -7,37 +7,31 @@ Panther is a statically-typed language with a rich type system. Understanding ty ## Primitive Types -### Integer Types +### Integer Type -Panther supports various integer types: +Panther has a single integer type, `int`, a 32-bit signed integer. There is no separate `Byte`, `Short`, or `Long` type, and no numeric literal suffixes: ```panther -val byte: Byte = 127 // 8-bit signed integer (-128 to 127) -val short: Short = 32767 // 16-bit signed integer -val int: Int = 2147483647 // 32-bit signed integer (default) -val long: Long = 9223372036854775807L // 64-bit signed integer +val count: int = 2147483647 ``` -### Floating-Point Types +### No Floating-Point Type -```panther -val float: Float = 3.14f // 32-bit floating point -val double: Double = 3.14159 // 64-bit floating point (default) -``` +Panther does not currently have a floating-point type (no `float` or `double`). Numeric computation is done with `int`. ### Boolean Type ```panther -val isTrue: Bool = true -val isFalse: Bool = false +val isTrue: bool = true +val isFalse: bool = false ``` ### Character Type ```panther -val letter: Char = 'A' -val digit: Char = '5' -val symbol: Char = '@' +val letter: char = 'A' +val digit: char = '5' +val symbol: char = '@' ``` ## String Type @@ -45,17 +39,14 @@ val symbol: Char = '@' Strings represent sequences of characters: ```panther -val message: String = "Hello, Panther!" -val empty: String = "" -val multiLine: String = """ - This is a - multi-line - string -""" +val message: string = "Hello, Panther!" +val empty: string = "" ``` ### String Operations +Panther's `string` type supports concatenation and a `length` field. There is no string interpolation, so build messages with `+` and convert non-string values with `string(...)`: + ```panther val greeting = "Hello" val name = "World" @@ -63,34 +54,32 @@ val name = "World" // Concatenation val message = greeting + ", " + name + "!" -// Length -val len = message.length() - -// Substring -val sub = message.substring(0, 5) - -// Character at index -val char = message.charAt(0) +// Length (a field, not a method call) +val len = message.length -// String interpolation -val interpolated = "Hello, ${name}!" +println(message) +println("length: " + string(len)) ``` ## Array Type -Arrays are fixed-size collections of elements: +Arrays are fixed-size collections created with `new Array[T](size)`. There are no array literals, so elements are assigned individually. Indexing uses parentheses, not brackets: ```panther // Array initialization -val numbers: Array = [1, 2, 3, 4, 5] -val names: Array = ["Alice", "Bob", "Charlie"] +val numbers = new Array[int](5) +numbers(0) = 1 +numbers(1) = 2 +numbers(2) = 3 +numbers(3) = 4 +numbers(4) = 5 // Access elements -val first = numbers[0] -val second = numbers[1] +val first = numbers(0) +val second = numbers(1) // Modify elements -numbers[0] = 10 +numbers(0) = 10 // Array length val size = numbers.length @@ -98,162 +87,143 @@ val size = numbers.length ## List Type -Lists are dynamic collections: +Panther's standard library has a `List[T]` type, but it is not a language built-in and has to be defined like any other type. It is a classic recursive "cons list": either `Nil` (empty) or `Cons(head, tail)`: ```panther -val fruits = List.of("apple", "banana", "cherry") - -// Add elements -val moreFruits = fruits.add("date") +enum IntList { + case Nil + case Cons(head: int, tail: IntList) +} -// Get element -val first = fruits.get(0) +val numbers = IntList.Cons(1, IntList.Cons(2, IntList.Cons(3, IntList.Nil))) -// Size -val count = fruits.size() +val first = numbers match { + case IntList.Cons(head, _) => head + case IntList.Nil => 0 +} -// Check if empty -val isEmpty = fruits.isEmpty() +println(string(first)) ``` ## Option Type -The `Option` type represents values that may or may not exist: +An `Option` type represents values that may or may not exist. Panther has no built-in `Option`, but it is easy to define as a generic enum with two cases: ```panther -val some: Option = Some(42) -val none: Option = None +enum Option[in T] { + case None + case Some(value: T) +} + +val some: Option[int] = Option.Some(42) +val none: Option[int] = Option.None // Pattern matching -val result = match (some) { - Some(value) => value * 2 - None => 0 +val result = some match { + case Option.Some(value) => value + case Option.None => 0 } -// Methods -val hasValue = some.isSome() -val hasNoValue = none.isNone() -val unwrapped = some.getOrElse(0) +println(string(result)) ``` ## Result Type -The `Result` type represents operations that can succeed or fail: +A `Result` type represents operations that can succeed or fail. Like `Option`, it is defined with an enum rather than being built into the language: ```panther -fun divide(a: Int, b: Int): Result { - if (b == 0) { - return Err("Division by zero") - } - return Ok(a / b) +enum DivideResult { + case Ok(value: int) + case Err(error: string) +} + +def divide(a: int, b: int): DivideResult = { + if (b == 0) { + DivideResult.Err("Division by zero") + } else { + DivideResult.Ok(a / b) + } } val result = divide(10, 2) -match (result) { - Ok(value) => println("Result: " + value) - Err(error) => println("Error: " + error) +val message = result match { + case DivideResult.Ok(value) => "Result: " + string(value) + case DivideResult.Err(error) => "Error: " + error } + +println(message) ``` -## Tuple Types +## Grouping Values -Tuples group multiple values together: +There is no built-in tuple syntax (no `(int, string)` type and no `(1, "a")` literal), but a small class does the same job: ```panther -// Pair (2-tuple) -val pair: (Int, String) = (42, "answer") -val first = pair._1 // 42 -val second = pair._2 // "answer" - -// Triple (3-tuple) -val triple: (Int, String, Bool) = (1, "hello", true) -val a = triple._1 -val b = triple._2 -val c = triple._3 +class Pair[A, B](first: A, second: B) + +val pair = new Pair[int, string](42, "answer") +val first = pair.first // 42 +val second = pair.second // "answer" ``` ## Custom Types ### Classes -Define custom data types using classes: +Define custom data types using classes. Constructor parameters are automatically accessible as fields: ```panther -class Person { - val name: String - val age: Int - - fun new(name: String, age: Int): Person { - this.name = name - this.age = age - } - - fun greet(): String { - return "Hello, my name is " + name - } +class Person(name: string, age: int) { + def greet(): string = "Hello, my name is " + name } -val person = Person.new("Alice", 30) +val person = new Person("Alice", 30) println(person.greet()) ``` -### Records - -Records are immutable data structures: - -```panther -record Point(x: Int, y: Int) - -val origin = Point(0, 0) -val point = Point(10, 20) - -// Access fields -val xCoord = point.x -val yCoord = point.y -``` - ### Enums -Enumerations define a type with a fixed set of values: +Enumerations define a type with a fixed set of values. Each case goes on its own line, and matches must use the qualified name: ```panther enum Color { - Red, - Green, - Blue + case Red + case Green + case Blue } val color: Color = Color.Red -match (color) { - Color.Red => println("Red") - Color.Green => println("Green") - Color.Blue => println("Blue") +val name = color match { + case Color.Red => "Red" + case Color.Green => "Green" + case Color.Blue => "Blue" } + +println(name) ``` -### Discriminated Unions +### Enums With Data (Discriminated Unions) -Define types with multiple variants: +There is no separate `union` keyword — an enum case can carry its own fields, which is how Panther expresses discriminated unions: ```panther -union Shape { - Circle(radius: Float), - Rectangle(width: Float, height: Float), - Triangle(base: Float, height: Float) +enum Shape { + case Circle(radius: int) + case Rectangle(width: int, height: int) + case Triangle(base: int, height: int) } -fun area(shape: Shape): Float { - return match (shape) { - Circle(r) => 3.14159 * r * r - Rectangle(w, h) => w * h - Triangle(b, h) => 0.5 * b * h - } +def area(shape: Shape): int = shape match { + case Shape.Circle(r) => r * r * 3 + case Shape.Rectangle(w, h) => w * h + case Shape.Triangle(b, h) => b * h / 2 } -val circle = Shape.Circle(5.0) -val area = area(circle) +val circle = Shape.Circle(5) +val circleArea = area(circle) +println(string(circleArea)) ``` ## Type Inference @@ -261,110 +231,65 @@ val area = area(circle) Panther can infer types automatically: ```panther -// Type is inferred as Int +// Type is inferred as int val number = 42 -// Type is inferred as String +// Type is inferred as string val text = "hello" -// Type is inferred as List -val numbers = [1, 2, 3] - // Type is inferred from function return type -fun getAge(): Int = 25 -val age = getAge() // age is Int +def getAge(): int = 25 +val age = getAge() // age is int ``` ## Generic Types -Create reusable types with type parameters: +Create reusable types with type parameters, written in square brackets: ```panther -class Box { - val value: T - - fun new(value: T): Box { - this.value = value - } - - fun get(): T { - return value - } +class Box[T](value: T) { + def get(): T = value } -val intBox = Box.new(42) -val stringBox = Box.new("hello") -``` - -## Type Aliases - -Create alternative names for types: - -```panther -type UserId = Int -type UserName = String -type Coordinate = (Float, Float) +val intBox = new Box[int](42) +val stringBox = new Box[string]("hello") -val id: UserId = 12345 -val name: UserName = "Alice" -val position: Coordinate = (10.5, 20.3) +println(string(intBox.get())) +println(stringBox.get()) ``` -## Nullable Types +## Type Checking and Casting -Explicitly handle nullable values: +Panther has `is` for runtime type checks and `as` for casts: ```panther -// Non-nullable (default) -val name: String = "Alice" - -// Nullable -val optionalName: String? = null - -// Safe navigation -val length = optionalName?.length() +val value: any = "hello" -// Elvis operator -val len = optionalName?.length() ?: 0 - -// Null check -if (optionalName != null) { - // Smart cast: optionalName is String here - println(optionalName.length()) +if (value is string) { + println("it is a string") } -``` -## Type Casting +val text = value as string +println(text) +``` -Convert between types: +Convert between primitive types with the `string(...)` and `int(...)` conversion functions rather than `.toString()`/`.toDouble()` style methods: ```panther -// Implicit conversion (when safe) -val int: Int = 42 -val long: Long = int // Int automatically widens to Long - -// Explicit conversion -val double: Double = int.toDouble() -val string: String = int.toString() - -// Type checking -if (value is String) { - // value is automatically cast to String - println(value.length()) -} - -// As operator -val text = value as String +val n: int = 42 +val asText: string = string(n) // Explicit conversion to string +val backToInt: int = int(asText) // Explicit conversion back to int ``` ## Best Practices 1. **Use type inference** - Let the compiler infer types when obvious 2. **Prefer immutability** - Use `val` over `var` when possible -3. **Use Option and Result** - Avoid null when representing absence or failure -4. **Leverage pattern matching** - Destructure complex types safely -5. **Keep types simple** - Avoid deeply nested generic types -6. **Use type aliases** - Make domain types more expressive +3. **Use Option and Result-shaped enums** - Model absence or failure instead of relying on `null` +4. **Leverage pattern matching** - Destructure enum and class values safely +5. **Keep types simple** - Deeply generic types can run into inference limits (see note below) + +> **Note:** The examples above that involve generics keep to a single type parameter used directly, because the current compiler's type inference for generics defined and used within one small snippet is limited — passing a generic value through a second generic function, or using two type parameters at once, can fail to infer correctly even when the shapes match. Panther's real standard library (`Option`, `List`, `Result`-like types) is generic; these gaps mostly show up when writing small, self-contained generic code rather than in the full compiler build. ## Next Steps diff --git a/docs/src/content/docs/guides/flow-control.md b/docs/src/content/docs/guides/flow-control.md index f549559..99d1548 100644 --- a/docs/src/content/docs/guides/flow-control.md +++ b/docs/src/content/docs/guides/flow-control.md @@ -13,7 +13,7 @@ The `if` statement evaluates a boolean condition and executes code accordingly: val age = 18 if (age >= 18) { - println("Adult") + println("Adult") } ``` @@ -25,9 +25,9 @@ Provide an alternative path with `else`: val temperature = 75 if (temperature > 80) { - println("It's hot!") + println("It's hot!") } else { - println("It's comfortable") + println("It's comfortable") } ``` @@ -39,30 +39,36 @@ Chain multiple conditions: val score = 85 if (score >= 90) { - println("Grade: A") + println("Grade: A") } else if (score >= 80) { - println("Grade: B") + println("Grade: B") } else if (score >= 70) { - println("Grade: C") + println("Grade: C") } else if (score >= 60) { - println("Grade: D") + println("Grade: D") } else { - println("Grade: F") + println("Grade: F") } ``` ### If as Expression -In Panther, `if` is an expression that returns a value: +In Panther, `if` is an expression that produces a value: ```panther -val max = if (a > b) a else b +val a = 3 +val b = 7 +val largest = if (a > b) a else b +val isActive = true val status = if (isActive) { - "active" + "active" } else { - "inactive" + "inactive" } + +println(string(largest)) +println(status) ``` ## While Loops @@ -73,149 +79,152 @@ Execute code repeatedly while a condition is true: var count = 0 while (count < 5) { - println(count) - count = count + 1 + println(string(count)) + count = count + 1 } ``` -### Infinite Loops +### Terminating a Loop -Be careful to ensure the condition eventually becomes false: +Panther has no `break` (see below), so a loop that needs an early exit uses a +flag in its condition: ```panther var running = true +var attempts = 0 while (running) { - val input = readLine() - if (input == "quit") { - running = false - } + attempts = attempts + 1 + if (attempts >= 3) { + running = false + } } + +println("stopped after " + string(attempts) + " attempts") ``` ## For Loops -Iterate over ranges and collections: - -### Range-Based For Loops +A `for` loop counts over an inclusive numeric range, written with `<-` and +`to`: ```panther -// Iterate from 0 to 4 (inclusive) -for (i in 0..5) { - println(i) -} - -// Iterate from 0 to 4 (exclusive) -for (i in 0..<5) { - println(i) -} - -// Iterate with step -for (i in 0..10 step 2) { - println(i) // Prints 0, 2, 4, 6, 8, 10 -} - -// Iterate backwards -for (i in 10 downTo 0) { - println(i) +// Iterate from 0 to 4 inclusive +for (i <- 0 to 4) { + println(string(i)) } ``` -### Collection Iteration +This is the only form. There is no exclusive range, no `step`, no `downTo`, +and no syntax for iterating a collection directly. To count downwards or by a +stride, use a `while` loop: ```panther -val fruits = ["apple", "banana", "cherry"] - -for (fruit in fruits) { - println(fruit) +var i = 10 +while (i >= 0) { + println(string(i)) + i = i - 2 } ``` -## Break and Continue +### Walking an Array -Control loop execution flow: - -### Break - -Exit a loop early: +Arrays are traversed by index. `length` is a field, so it takes no +parentheses: ```panther -for (i in 0..10) { - if (i == 5) { - break // Exit the loop when i is 5 - } - println(i) // Prints 0, 1, 2, 3, 4 +val fruits = new Array[string](3) +fruits(0) = "apple" +fruits(1) = "banana" +fruits(2) = "cherry" + +for (i <- 0 to (fruits.length - 1)) { + println(fruits(i)) } ``` -### Continue +Note that Panther has no array literal syntax — `["apple", "banana"]` does not +parse. Arrays are allocated with a size and filled by index. -Skip to the next iteration: +## Break and Continue + +`break` and `continue` are reserved words and they parse, but the binder does +not implement them: a program that uses one currently crashes the compiler +rather than producing a diagnostic. Until they are implemented, restructure the +loop. + +Instead of `break`, use a flag in the loop condition, as shown above. Instead +of `continue`, invert the condition so the skipped case simply does nothing: ```panther -for (i in 0..10) { - if (i % 2 == 0) { - continue // Skip even numbers - } - println(i) // Prints 1, 3, 5, 7, 9 +for (i <- 0 to 10) { + if (i % 2 != 0) { + println(string(i)) // Prints 1, 3, 5, 7, 9 + } } ``` ## Pattern Matching -Use `match` expressions for powerful pattern matching: +`match` is written after the value being matched, and every branch starts with +`case`: ```panther val day = "Monday" -val dayType = match (day) { - "Saturday", "Sunday" => "Weekend" - "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" => "Weekday" - _ => "Unknown" +val dayType = day match { + case "Saturday" => "Weekend" + case "Sunday" => "Weekend" + case _ => "Weekday" } println(dayType) // Prints: Weekday ``` +Each pattern needs its own `case` — there is no comma or `|` alternation, and +no `if` guards on a case. + ### Matching Values ```panther val number = 42 -match (number) { - 0 => println("Zero") - 1 => println("One") - 42 => println("The answer!") - _ => println("Some other number") +number match { + case 0 => println("Zero") + case 1 => println("One") + case 42 => println("The answer!") + case _ => println("Some other number") } ``` ### Match as Expression +`match` produces a value, so it can be assigned directly: + ```panther -val result = match (value) { - 0 => "none" - 1 => "one" - _ => "many" +val value = 1 + +val result = value match { + case 0 => "none" + case 1 => "one" + case _ => "many" } + +println(result) // Prints: one ``` ## Guard Clauses -Use early returns to simplify complex logic: +Panther has no `return`, so a function cannot exit early. Express the same +logic as a chain of conditions, or as a `match`: ```panther -fun processValue(value: Int): String { - if (value < 0) { - return "Negative" - } - - if (value == 0) { - return "Zero" - } - - return "Positive" -} +def processValue(value: int): string = + if (value < 0) "Negative" + else if (value == 0) "Zero" + else "Positive" + +println(processValue(-4)) ``` ## Conditional Expressions @@ -225,15 +234,19 @@ fun processValue(value: Int): String { Use `if-else` expressions for simple conditions: ```panther +val isValid = true val message = if (isValid) "Success" else "Error" +val x = -3 val abs = if (x >= 0) x else -x + +println(message + " " + string(abs)) ``` ## Best Practices -1. **Prefer expressions over statements** - Use `if` as an expression when possible -2. **Avoid deep nesting** - Use early returns or extract functions +1. **Prefer expressions over statements** - Use `if` and `match` for their values +2. **Avoid deep nesting** - Extract functions rather than nesting conditions 3. **Use pattern matching** - It's more readable than complex if-else chains 4. **Keep loop bodies simple** - Extract complex logic into functions 5. **Be careful with infinite loops** - Always ensure termination conditions @@ -243,28 +256,31 @@ val abs = if (x >= 0) x else -x ### Finding Maximum ```panther -var max = numbers[0] -for (num in numbers) { - if (num > max) { - max = num - } +val numbers = new Array[int](4) +numbers(0) = 3 +numbers(1) = 9 +numbers(2) = 4 +numbers(3) = 1 + +var largest = numbers(0) +for (i <- 1 to (numbers.length - 1)) { + if (numbers(i) > largest) { + largest = numbers(i) + } } + +println(string(largest)) // Prints: 9 ``` ### Validation ```panther -fun validateAge(age: Int): Bool { - if (age < 0) { - return false - } - - if (age > 150) { - return false - } - - return true -} +def validateAge(age: int): bool = + if (age < 0) false + else if (age > 150) false + else true + +println(string(validateAge(42))) ``` ### State Machines @@ -273,18 +289,14 @@ fun validateAge(age: Int): Bool { var state = "start" while (state != "end") { - match (state) { - "start" => { - state = "processing" - } - "processing" => { - state = "complete" - } - "complete" => { - state = "end" - } - } + state = state match { + case "start" => "processing" + case "processing" => "complete" + case _ => "end" + } } + +println(state) // Prints: end ``` ## Next Steps diff --git a/docs/src/content/docs/guides/functions.md b/docs/src/content/docs/guides/functions.md index e2b8001..d739592 100644 --- a/docs/src/content/docs/guides/functions.md +++ b/docs/src/content/docs/guides/functions.md @@ -7,12 +7,10 @@ Functions are the building blocks of Panther programs. They allow you to organiz ## Function Declaration -Define a function using the `fun` keyword: +Define a function using the `def` keyword: ```panther -fun greet(name: String): String { - return "Hello, " + name + "!" -} +def greet(name: string): string = "Hello, " + name + "!" val message = greet("World") println(message) // Prints: Hello, World! @@ -23,9 +21,9 @@ println(message) // Prints: Hello, World! The general syntax for functions: ```panther -fun functionName(param1: Type1, param2: Type2): ReturnType { - // function body - return value +def functionName(param1: int, param2: string): bool = { + // function body — the last expression is the result + param1 > 0 } ``` @@ -34,123 +32,100 @@ fun functionName(param1: Type1, param2: Type2): ReturnType { Functions can take multiple parameters: ```panther -fun add(a: Int, b: Int): Int { - return a + b -} +def add(a: int, b: int): int = a + b -fun introduce(firstName: String, lastName: String, age: Int): String { - return firstName + " " + lastName + " is " + age + " years old" -} +def introduce(firstName: string, lastName: string, age: int): string = + firstName + " " + lastName + " is " + string(age) + " years old" ``` -## Return Types +Note the `string(age)` conversion. Panther has no automatic conversion from +`int` to `string`, so concatenating a number requires it explicitly. -### Explicit Return +## Return Values -Use the `return` keyword to return a value: +Panther has no `return` keyword. A function's value is its body — for a block +body, the last expression: ```panther -fun multiply(x: Int, y: Int): Int { - return x * y +def multiply(x: int, y: int): int = x * y + +def describe(n: int): string = { + val doubled = n * 2 + "twice " + string(n) + " is " + string(doubled) } ``` ### Unit Functions -Functions that don't return a value have a `Unit` return type (similar to `void` in other languages): +Functions that do not produce a value have the `unit` return type, the +equivalent of `void` in other languages: ```panther -fun printMessage(message: String): Unit { - println(message) -} +def printMessage(message: string): unit = println(message) -// Unit can be omitted -fun printNumber(n: Int) { - println(n) -} +def printNumber(n: int): unit = println(string(n)) ``` ## Expression Bodies -For simple functions, you can use expression syntax: +Simple functions read well as a single expression: ```panther -fun square(x: Int): Int = x * x +def square(x: int): int = x * x -fun isEven(n: Int): Bool = n % 2 == 0 +def isEven(n: int): bool = n % 2 == 0 -fun max(a: Int, b: Int): Int = if (a > b) a else b +def max(a: int, b: int): int = if (a > b) a else b ``` -## Local Functions - -Functions can be nested inside other functions: - -```panther -fun outer(x: Int): Int { - fun inner(y: Int): Int { - return y * 2 - } - - return inner(x) + 1 -} -``` +`if` is an expression in Panther, so it can be a function body directly. ## Recursion Panther supports recursive functions: ```panther -fun factorial(n: Int): Int { - if (n <= 1) { - return 1 - } - return n * factorial(n - 1) -} +def factorial(n: int): int = if (n <= 1) 1 else n * factorial(n - 1) -fun fibonacci(n: Int): Int { - if (n <= 1) { - return n - } - return fibonacci(n - 1) + fibonacci(n - 2) -} +def fibonacci(n: int): int = + if (n <= 1) n else fibonacci(n - 1) + fibonacci(n - 2) + +println(string(factorial(5))) // Prints: 120 +println(string(fibonacci(10))) // Prints: 55 ``` -## Higher-Order Functions +Recursion is the main tool for iteration over recursive data, since Panther's +`for` loop only counts over a numeric range. -Functions can take other functions as parameters: +## Not yet supported -```panther -fun applyTwice(f: (Int) => Int, x: Int): Int { - return f(f(x)) -} +Three things that functions in other languages have, and Panther does not yet: -fun increment(x: Int): Int = x + 1 +**Nested functions.** A `def` cannot appear inside another function's body. +Declare helpers at the top level instead: -val result = applyTwice(increment, 5) // Returns 7 -``` +```panther +def double(y: int): int = y * 2 -## Anonymous Functions (Lambdas) +def outer(x: int): int = double(x) + 1 +``` -Define functions inline without a name: +**Function-typed parameters.** There is no way to declare a parameter that +takes a function, so higher-order functions cannot be written yet. -```panther -val double = (x: Int) => x * 2 +**Anonymous functions.** There are no lambdas — `(x: int) => x * 2` does not +parse. -val sum = (a: Int, b: Int) => a + b - -// Use with higher-order functions -val result = applyTwice((x: Int) => x * 2, 3) // Returns 12 -``` +All three are tracked in the repository's `ROADMAP.md`. -## Method Calls +## Calling Methods -Functions can be called on objects using dot notation: +Methods are called with dot notation. Note that `length` on a `string` is a +field, not a method, so it takes no parentheses: ```panther val text = "hello" -val upper = text.toUpperCase() -val length = text.length() +println(string(text.length)) // Prints: 5 ``` ## Best Practices diff --git a/tools/doccheck/README.md b/tools/doccheck/README.md new file mode 100644 index 0000000..962b0bc --- /dev/null +++ b/tools/doccheck/README.md @@ -0,0 +1,99 @@ +# doccheck + +Checks that every ` ```panther ` code block in the documentation is actually +valid Panther. + +The docs are the language's public contract, and until now nothing verified +that the snippets in them would survive the compiler. `doccheck` walks the +markdown, extracts each Panther block, and pushes it through the real `pncs` +front end — the same lexer, parser, and binder the compiler uses. + +## Running it + +```bash +sbt "doccheck/run docs/src/content/docs" +``` + +Paths may be files or directories; directories are walked for `.md` and +`.mdx`. + +All 198 checked blocks currently compile: + +``` +panther doc check (stage: Bind) + 22 files, 200 panther blocks + + 200 blocks: 198 ok, 0 failing, 2 skipped +``` + +The two skips are on `functions/higher-order-functions.md`, which documents +lambdas and function-typed parameters — neither is implemented. + +[`baseline.txt`](baseline.txt) records blocks that are known not to compile and +is **picked up automatically**, so a run reports only what has changed. It +started at 157 entries and is now empty; keep it that way. + +Useful flags: + +| Flag | Effect | +| --- | --- | +| `--stage ` | How far to take each block. `parse` catches syntax only; `bind` (the default) also resolves symbols and types. | +| `--no-baseline` | Ignore the baseline and report every failure. This is the ~800-line view of everything that is broken. | +| `--show-known` | Detail the baselined failures instead of just counting them. | +| `--baseline ` | Use a different baseline file. | +| `--update-baseline` | Rewrite the baseline from this run. | +| `--show-passing` | List passing blocks too, not just failures. | +| `--max-problems ` | Diagnostics shown per block (default 5; `0` for all). | + +Exit code is `0` when everything the run cares about passed, `1` on a failure +or a malformed directive, `2` on bad arguments. + +## Per-block directives + +Not every snippet should be compiled as-is. Put an HTML comment on the line +before the fence — markdown renderers drop it, so it does not show up on the +docs site: + +````markdown + +```panther +println(somethingDefinedElsewhere) +``` +```` + +| Directive | Meaning | +| --- | --- | +| `check` | Parse and bind; any diagnostic fails. The default, so it rarely needs writing out. | +| `parse-only` | Syntax must be valid, but symbols are not resolved. For fragments that reference things the block does not define. | +| `expect-error` | The block *must* produce a diagnostic. For snippets that deliberately show a compile error. | +| `skip reason="..."` | Not checked at all. A reason is required. | + +A typo in a directive is reported as a failure rather than silently ignored, +so a misspelled mode cannot quietly switch checking off. + +## The baseline + +The baseline turns a red wall into a ratchet. A run fails only when a block +that used to pass starts failing — and also when a baselined block starts +passing, so the list cannot silently go stale. In that case, regenerate it: + +```bash +sbt "doccheck/run --update-baseline docs/src/content/docs" +``` + +The baseline path is resolved relative to the working directory, so run this +from the repository root. + +The baseline should only ever get shorter. See +[`ROADMAP.md`](../../ROADMAP.md) for the plan to empty it. + +## Limitations + +- Blocks are compiled one at a time, in isolation. There is no way yet to say + "this block continues the previous one", so multi-block walkthroughs need + `parse-only` on the fragments. +- There is no prelude a block can opt into, so a snippet that wants a `User` + type has to declare it. +- Checking stops after binding. Blocks are never lowered, emitted, or run, so + a snippet can pass here and still not produce the output the prose claims. + Executing blocks needs a `.pnb` writer and a runner first. diff --git a/tools/doccheck/baseline.txt b/tools/doccheck/baseline.txt new file mode 100644 index 0000000..888eacc --- /dev/null +++ b/tools/doccheck/baseline.txt @@ -0,0 +1,5 @@ +# Documentation blocks that do not yet compile. +# Regenerate with: sbt "doccheck/run --update-baseline docs/src/content/docs" +# This list is a ratchet: it should only ever get shorter. It is +# currently empty -- every panther block in docs/ compiles. + diff --git a/tools/doccheck/src/main/scala/BlockChecker.scala b/tools/doccheck/src/main/scala/BlockChecker.scala new file mode 100644 index 0000000..a4d3601 --- /dev/null +++ b/tools/doccheck/src/main/scala/BlockChecker.scala @@ -0,0 +1,153 @@ +// The compiler's runtime defines root-package Option/Either that would otherwise +// shadow the Scala ones; explicit imports outrank package members. +import scala.{Either, Left, Option, Right} + +import scala.collection.mutable.ArrayBuffer + +/** How far a block is taken through the compiler pipeline. */ +enum Stage { + case Parse + case Bind +} + +object Stage { + def parse(name: String): Option[Stage] = name match { + case "parse" => Some(Stage.Parse) + case "bind" => Some(Stage.Bind) + case _ => None + } +} + +/** One compiler complaint about a block, with the line rewritten to point at + * the markdown file rather than at the extracted snippet. + */ +case class BlockProblem(line: Int, column: Int, message: String) { + def render(block: DocBlock): String = + s"${block.file}:$line:$column: $message" +} + +enum Outcome { + + /** The block behaved as its directive says it should. */ + case Ok + + /** Not checked. */ + case Skipped(reason: String) + + /** The block was rejected by the compiler. */ + case Failed(problems: Seq[BlockProblem]) + + /** The compiler threw instead of reporting a diagnostic. Always a compiler + * bug: errors are supposed to travel through the DiagnosticBag. + */ + case Crashed(detail: String) + + /** `expect-error` was declared but the block compiled cleanly. */ + case UnexpectedlyClean +} + +case class BlockResult(block: DocBlock, outcome: Outcome) { + val failed: Boolean = outcome match { + case Outcome.Ok | Outcome.Skipped(_) => false + case _ => true + } +} + +/** Runs a single documentation snippet through the compiler front end. */ +object BlockChecker { + + private val settings = CompilerSettingsFactory.default + + private def collect( + diagnostics: Diagnostics, + into: ArrayBuffer[Diagnostic] + ): Unit = + diagnostics match { + case Diagnostics.Empty => () + case Diagnostics.Node(left, head, right) => + collect(left, into) + into += head + collect(right, into) + } + + private def problemsOf( + block: DocBlock, + diagnostics: Diagnostics + ): Seq[BlockProblem] = { + val collected = ArrayBuffer[Diagnostic]() + collect(diagnostics, collected) + collected.map { diagnostic => + val location = diagnostic.location + BlockProblem( + line = block.sourceLine(location.startLine + 1), + column = location.startCharacter + 1, + message = diagnostic.message + ) + }.toSeq + } + + /** The compiler prints progress and, in a few paths, debugging output. Doc + * checking runs it hundreds of times, so that noise is swallowed and only + * the checker's own report reaches the terminal. + */ + private def quietly[A](body: => A): A = { + val sink = new java.io.PrintStream(java.io.OutputStream.nullOutputStream()) + Console.withOut(sink)(Console.withErr(sink)(body)) + } + + def check(block: DocBlock, stage: Stage): BlockResult = { + val outcome = block.directive match { + case Directive.Skip(reason) => Outcome.Skipped(reason) + case Directive.ParseOnly => run(block, Stage.Parse, expectError = false) + case Directive.ExpectError => run(block, stage, expectError = true) + case Directive.Check => run(block, stage, expectError = false) + } + BlockResult(block, outcome) + } + + private def run( + block: DocBlock, + stage: Stage, + expectError: Boolean + ): Outcome = { + val attempt = + try Right(quietly(diagnose(block, stage))) + catch { + case error: Throwable => + Left(Option(error.getMessage) match { + case Some(message) => s"${error.getClass.getName}: $message" + case None => error.getClass.getName + }) + } + + attempt match { + case Left(detail) => Outcome.Crashed(detail) + case Right(diagnostics) => + val problems = problemsOf(block, diagnostics) + if (expectError) { + if (problems.isEmpty) Outcome.UnexpectedlyClean else Outcome.Ok + } else { + if (problems.isEmpty) Outcome.Ok else Outcome.Failed(problems) + } + } + } + + private def diagnose(block: DocBlock, stage: Stage): Diagnostics = { + val source = new SourceFile(block.code, block.file) + val tree = MakeSyntaxTree.parseSourceFile(source, settings) + + // Binding a tree that did not parse produces cascading nonsense, so stop + // at the first stage that has something to say. + val parseFailed = tree.diagnostics match { + case Diagnostics.Empty => false + case _ => true + } + + stage match { + case Stage.Parse => tree.diagnostics + case Stage.Bind if parseFailed => tree.diagnostics + case Stage.Bind => + MakeCompilation.create(ListModule.one(tree), settings).diagnostics + } + } +} diff --git a/tools/doccheck/src/main/scala/DocBlock.scala b/tools/doccheck/src/main/scala/DocBlock.scala new file mode 100644 index 0000000..8313eae --- /dev/null +++ b/tools/doccheck/src/main/scala/DocBlock.scala @@ -0,0 +1,66 @@ +/** How a documentation code block should be treated by the checker. + * + * Set with an HTML comment on the line before the fence, which markdown + * renderers drop from the output: + * + * {{{ + * + * }}} + */ +enum Directive { + + /** Parse and bind the block; any diagnostic is a failure. The default. */ + case Check + + /** Parse the block but do not bind it. For snippets that are syntactically + * complete but reference symbols the checker cannot supply. + */ + case ParseOnly + + /** The block must produce at least one diagnostic. For snippets that + * deliberately show a compile error. + */ + case ExpectError + + /** Do not check the block at all. Requires a reason. */ + case Skip(reason: String) +} + +/** A fenced `panther` block extracted from a markdown file. + * + * @param file + * repository-relative path of the markdown file + * @param ordinal + * 1-based index of this block among the panther blocks in `file` + * @param fenceLine + * 1-based line number of the opening fence + * @param meta + * the info string after the language tag, e.g. `title="hello.pn"` + * @param code + * the block's contents, without the fences + */ +case class DocBlock( + file: String, + ordinal: Int, + fenceLine: Int, + meta: String, + directive: Directive, + code: String +) { + + /** Stable identifier used in reports and in the baseline file. + * + * Deliberately an ordinal rather than a line number so that editing prose + * above a block does not invalidate its baseline entry. + */ + val id: String = file + "#" + ordinal + + /** 1-based line number in the markdown file of the block's first code line. + */ + val codeLine: Int = fenceLine + 1 + + /** Maps a 1-based line inside the block back to its line in the markdown + * file, so editors can jump straight to the offending snippet. + */ + def sourceLine(blockLine: Int): Int = codeLine + blockLine - 1 +} diff --git a/tools/doccheck/src/main/scala/DocCheck.scala b/tools/doccheck/src/main/scala/DocCheck.scala new file mode 100644 index 0000000..391d22b --- /dev/null +++ b/tools/doccheck/src/main/scala/DocCheck.scala @@ -0,0 +1,391 @@ +// The compiler's runtime defines root-package Option/Either that would otherwise +// shadow the Scala ones; explicit imports outrank package members. +import scala.{Either, Left, Option, Right} + +import java.nio.file.{Files, Path, Paths} +import scala.collection.mutable.ArrayBuffer +import scala.jdk.CollectionConverters.* + +case class DocCheckOptions( + roots: Seq[Path], + stage: Stage, + baseline: Option[Path], + updateBaseline: Boolean, + showPassing: Boolean, + showKnown: Boolean, + maxProblemsPerBlock: Int +) + +/** Checks that every `panther` code block in the documentation is accepted by + * the compiler. + * + * The docs are the language's public contract, and nothing has been enforcing + * that the snippets in them are valid Panther. This walks the markdown, + * extracts the blocks, and pushes each one through the real front end. + * + * Most of the docs predate the current language, so + * `tools/doccheck/baseline.txt` records the blocks that are known not to + * compile and is picked up automatically. A normal run therefore reports only + * what has changed. + * + * Usage: + * {{{ + * sbt "doccheck/run docs/src/content/docs" + * sbt "doccheck/run --no-baseline docs/src/content/docs" + * }}} + */ +object DocCheck { + + private val Usage = + """Usage: doccheck [options] ... + | + |Scans markdown files for ```panther code blocks and checks each one + |against the compiler. Paths may be files or directories. + | + |Most of the docs predate the current language, so known failures are + |tracked in tools/doccheck/baseline.txt. That file is used automatically + |when it exists: only new failures are reported. + | + |Options: + | --stage How far to take each block (default: bind) + | --baseline Use instead of the default baseline + | --no-baseline Ignore the baseline and report every failure + | --update-baseline Rewrite the baseline from this run's failures + | --show-known Also detail failures already in the baseline + | --show-passing List blocks that passed as well as those that failed + | --max-problems Diagnostics to show per block (default: 5, 0 for all) + | -h, --help Show this message + | + |Per-block directives go in an HTML comment on the line before the fence: + | + | + | + |""".stripMargin + + def main(args: Array[String]): Unit = { + parseArgs(args.toSeq) match { + case Left(error) => + println(s"error: $error") + println() + print(Usage) + sys.exit(2) + + case Right(None) => + print(Usage) + + case Right(Some(options)) => + sys.exit(run(options)) + } + } + + private def parseArgs( + args: Seq[String] + ): Either[String, Option[DocCheckOptions]] = { + val roots = ArrayBuffer[Path]() + var stage: Stage = Stage.Bind + var baseline: Option[Path] = None + var noBaseline = false + var updateBaseline = false + var showPassing = false + var showKnown = false + var maxProblems = 5 + var help = args.isEmpty + var error: Option[String] = None + + var index = 0 + while (index < args.length && error.isEmpty) { + def value(flag: String): Option[String] = + if (index + 1 < args.length) Some(args(index + 1)) + else { + error = Some(s"$flag requires a value") + None + } + + args(index) match { + case "-h" | "--help" => + help = true + index += 1 + case "--show-passing" => + showPassing = true + index += 1 + case "--show-known" => + showKnown = true + index += 1 + case "--no-baseline" => + noBaseline = true + index += 1 + case "--update-baseline" => + updateBaseline = true + index += 1 + case flag @ "--stage" => + value(flag).foreach { name => + Stage.parse(name) match { + case Some(parsed) => stage = parsed + case None => error = Some(s"unknown stage '$name'") + } + } + index += 2 + case flag @ "--baseline" => + value(flag).foreach(path => baseline = Some(Paths.get(path))) + index += 2 + case flag @ "--max-problems" => + value(flag).foreach { count => + count.toIntOption match { + case Some(parsed) if parsed >= 0 => maxProblems = parsed + case _ => + error = Some(s"--max-problems expects a count, got '$count'") + } + } + index += 2 + case flag if flag.startsWith("-") => + error = Some(s"unknown option '$flag'") + case path => + roots += Paths.get(path) + index += 1 + } + } + + val resolved = + if (noBaseline) None else baseline.orElse(defaultBaseline) + + error match { + case Some(message) => Left(message) + case None if help => Right(None) + case None if roots.isEmpty => + Left("no paths given") + case None if noBaseline && baseline.isDefined => + Left("--no-baseline and --baseline are mutually exclusive") + case None if noBaseline && updateBaseline => + Left("--no-baseline and --update-baseline are mutually exclusive") + case None if updateBaseline && resolved.isEmpty => + Left( + "--update-baseline needs a baseline path: pass --baseline, or run from the repository root" + ) + case None => + Right( + Some( + DocCheckOptions( + roots = roots.toSeq, + stage = stage, + baseline = resolved, + updateBaseline = updateBaseline, + showPassing = showPassing, + showKnown = showKnown, + maxProblemsPerBlock = maxProblems + ) + ) + ) + } + } + + /** The repo's baseline, used unless the caller says otherwise. + * + * Without this the bare `doccheck ` invocation reports all 157 known + * doc failures and exits non-zero, which reads as the tool being broken. + * Resolved relative to the working directory, and only when it exists, so + * running the tool elsewhere still behaves sensibly. + */ + private def defaultBaseline: Option[Path] = { + val path = Paths.get("tools/doccheck/baseline.txt") + if (Files.isRegularFile(path)) Some(path) else None + } + + private def markdownFiles(root: Path): Seq[Path] = + if (Files.isRegularFile(root)) Seq(root) + else if (!Files.isDirectory(root)) Seq.empty + else { + val stream = Files.walk(root) + try + stream + .iterator() + .asScala + .filter(Files.isRegularFile(_)) + .filter { path => + val name = path.getFileName.toString + name.endsWith(".md") || name.endsWith(".mdx") + } + .toSeq + .sortBy(_.toString) + finally stream.close() + } + + private def readBaseline(path: Path): Set[String] = + if (!Files.exists(path)) Set.empty + else + Files + .readAllLines(path) + .asScala + .map(_.trim) + .filter(line => line.nonEmpty && !line.startsWith("#")) + .toSet + + private def writeBaseline(path: Path, ids: Seq[String]): Unit = { + val header = Seq( + "# Documentation blocks that do not yet compile.", + "# Regenerate with: sbt \"doccheck/run --update-baseline docs/src/content/docs\"", + "# This list is a ratchet: it should only ever get shorter. It is", + "# currently empty -- every panther block in docs/ compiles.", + "" + ) + Option(path.getParent).foreach(Files.createDirectories(_)) + Files.write(path, (header ++ sortIds(ids)).asJava) + } + + /** Sorts by file then by block number, so `#2` lands before `#11` and the + * baseline diffs readably as blocks are fixed. + */ + private def sortIds(ids: Seq[String]): Seq[String] = + ids.sortBy { id => + id.lastIndexOf('#') match { + case -1 => (id, 0) + case at => + (id.substring(0, at), id.substring(at + 1).toIntOption.getOrElse(0)) + } + } + + /** Repository-relative path where possible, so block ids stay stable between + * machines and readable in the baseline. Files outside the working directory + * keep their absolute path rather than a wall of `../`. + */ + private def displayPath(cwd: Path, file: Path): String = { + val relative = cwd.relativize(file.toAbsolutePath.normalize).toString + if (relative.startsWith("..")) file.toAbsolutePath.normalize.toString + else relative + } + + private def describe( + result: BlockResult, + maxProblems: Int + ): Seq[String] = { + val block = result.block + result.outcome match { + case Outcome.Ok => Seq(s" ok ${block.id}") + + case Outcome.Skipped(reason) => + Seq(s" skipped ${block.id} ($reason)") + + case Outcome.UnexpectedlyClean => + Seq( + s" FAIL ${block.id} (line ${block.codeLine})", + " expected a compile error but the block was accepted" + ) + + case Outcome.Crashed(detail) => + Seq( + s" CRASH ${block.id} (line ${block.codeLine})", + s" the compiler threw instead of reporting a diagnostic: $detail" + ) + + case Outcome.Failed(problems) => + val shown = + if (maxProblems == 0) problems else problems.take(maxProblems) + val lines = ArrayBuffer[String]() + lines += s" FAIL ${block.id} (line ${block.codeLine})" + shown.foreach(problem => lines += s" ${problem.render(block)}") + val hidden = problems.length - shown.length + if (hidden > 0) lines += s" ... and $hidden more" + lines.toSeq + } + } + + private def run(options: DocCheckOptions): Int = { + val cwd = Paths.get("").toAbsolutePath + val problems = ArrayBuffer[String]() + val results = ArrayBuffer[BlockResult]() + var fileCount = 0 + + for { + root <- options.roots + file <- markdownFiles(root) + } { + fileCount += 1 + val text = Files.readString(file) + val blocks = MarkdownScanner.scan(displayPath(cwd, file), text, problems) + blocks.foreach(block => + results += BlockChecker.check(block, options.stage) + ) + } + + val skipped = results.count(isSkipped) + val failures = results.filter(_.failed).toSeq + val passing = results.length - failures.length - skipped + val failingIds = failures.map(_.block.id) + + // Most of the docs predate the current language, so without a baseline + // every run is a wall of expected failures. `known` is what we already + // accept; everything else is what the reader actually needs to see. + val known = options.baseline match { + case Some(path) if !options.updateBaseline => readBaseline(path) + case _ => Set.empty[String] + } + val regressions = failures.filterNot(r => known.contains(r.block.id)) + val fixed = sortIds(known.diff(failingIds.toSet).toSeq) + + println(s"panther doc check (stage: ${options.stage})") + println(s" $fileCount files, ${results.length} panther blocks") + println() + + val detailed = + if (options.showPassing) results.toSeq + else if (options.showKnown) + results.toSeq.filter(r => r.failed || isSkipped(r)) + else regressions ++ results.toSeq.filter(isSkipped) + + detailed.foreach { result => + describe(result, options.maxProblemsPerBlock).foreach(println) + } + if (detailed.nonEmpty) println() + + problems.foreach(problem => println(s" MALFORMED $problem")) + if (problems.nonEmpty) println() + + println( + s" ${results.length} blocks: $passing ok, ${failures.length} failing, $skipped skipped" + ) + + options.baseline match { + case Some(path) if options.updateBaseline => + writeBaseline(path, failingIds) + println(s" wrote ${failingIds.length} entries to $path") + if (problems.isEmpty) 0 else 1 + + case Some(path) => + val knownFailing = failures.length - regressions.length + if (knownFailing > 0 && !options.showKnown) { + println( + s" $knownFailing of those are known failures from $path (--show-known to list them)" + ) + } + + if (fixed.nonEmpty) { + println() + println(s" ${plural(fixed.length, "baselined block")} now passing:") + fixed.foreach(id => println(s" $id")) + println(s" drop them from $path with --update-baseline") + } + if (regressions.nonEmpty) { + println() + println(s" ${plural(regressions.length, "block")} newly failing:") + regressions.foreach(result => println(s" ${result.block.id}")) + } + if (regressions.isEmpty && fixed.isEmpty && problems.isEmpty) { + println(" no new failures") + } + + if (regressions.nonEmpty || fixed.nonEmpty || problems.nonEmpty) 1 + else 0 + + case None => + if (failures.nonEmpty || problems.nonEmpty) 1 else 0 + } + } + + private def plural(count: Int, noun: String): String = + if (count == 1) s"$count $noun" else s"$count ${noun}s" + + private def isSkipped(result: BlockResult): Boolean = + result.outcome match { + case Outcome.Skipped(_) => true + case _ => false + } +} diff --git a/tools/doccheck/src/main/scala/MarkdownScanner.scala b/tools/doccheck/src/main/scala/MarkdownScanner.scala new file mode 100644 index 0000000..d9b563d --- /dev/null +++ b/tools/doccheck/src/main/scala/MarkdownScanner.scala @@ -0,0 +1,142 @@ +// The compiler's runtime defines root-package Option/Either that would otherwise +// shadow the Scala ones; explicit imports outrank package members. +import scala.{Either, Left, Option, Right} + +import scala.collection.mutable.ArrayBuffer + +/** Pulls fenced `panther` code blocks out of markdown files. + * + * This is a deliberately small CommonMark subset: fences opened with three or + * more backticks or tildes, indented by at most three spaces, closed by a + * fence of the same character that is at least as long. That is enough for the + * docs site, and keeping it small means the checker has no markdown dependency + * to keep in step with Starlight. + */ +object MarkdownScanner { + + private val Fence = """^ {0,3}(`{3,}|~{3,})(.*)$""".r + private val DirectiveComment = + """^\s*\s*$""".r + private val Attribute = """([A-Za-z][\w-]*)="([^"]*)"""".r + + /** True when a fence's info string opens a Panther block. Matches `panther` + * and `panther title="x"` but not `pantherfoo`. + */ + private def isPantherInfo(info: String): Boolean = { + val trimmed = info.trim + val lang = trimmed.takeWhile(c => !c.isWhitespace) + lang.equalsIgnoreCase("panther") + } + + private def metaOf(info: String): String = + info.trim.dropWhile(c => !c.isWhitespace).trim + + /** Parses the body of a `panther-check:` comment. Unknown modes and a `skip` + * without a reason are reported as errors rather than silently ignored — a + * typo in a directive would otherwise quietly disable checking. + */ + def parseDirective(body: String): Either[String, Directive] = { + val mode = body.takeWhile(c => !c.isWhitespace) + val attributes = Attribute + .findAllMatchIn(body) + .map(m => m.group(1) -> m.group(2)) + .toMap + + mode match { + case "check" => Right(Directive.Check) + case "parse-only" => Right(Directive.ParseOnly) + case "expect-error" => Right(Directive.ExpectError) + case "skip" => + attributes.get("reason") match { + case Some(reason) if reason.nonEmpty => Right(Directive.Skip(reason)) + case _ => + Left("`skip` requires a reason, e.g. skip reason=\"...\"") + } + case other => + Left( + s"unknown mode '$other' (expected check, parse-only, expect-error or skip)" + ) + } + } + + /** Extracts every Panther block in `text`. + * + * @param file + * path recorded on each block; only used for reporting + * @param problems + * receives malformed directives, which are reported as failures by the + * caller instead of aborting the scan + */ + def scan( + file: String, + text: String, + problems: ArrayBuffer[String] + ): Seq[DocBlock] = { + // Some doc files are still CRLF. Split on either ending and rejoin block + // bodies with LF so the lexer never sees a stray carriage return. + val lines = text.split("\r\n|\n|\r", -1) + val blocks = ArrayBuffer[DocBlock]() + + var index = 0 + var ordinal = 0 + var pending: Option[Directive] = None + + while (index < lines.length) { + val line = lines(index) + + line match { + case DirectiveComment(body) => + parseDirective(body) match { + case Right(directive) => pending = Some(directive) + case Left(message) => + problems += s"$file:${index + 1}: $message" + } + index += 1 + + case Fence(marker, info) => + val fenceLine = index + 1 + val body = ArrayBuffer[String]() + index += 1 + + var closed = false + while (index < lines.length && !closed) { + lines(index) match { + case Fence(closing, trailing) + if closing.charAt(0) == marker.charAt(0) && + closing.length >= marker.length && + trailing.trim.isEmpty => + closed = true + case other => + body += other + } + index += 1 + } + + if (isPantherInfo(info)) { + ordinal += 1 + if (!closed) { + problems += s"$file:$fenceLine: unterminated panther code fence" + } + blocks += DocBlock( + file = file, + ordinal = ordinal, + fenceLine = fenceLine, + meta = metaOf(info), + directive = pending.getOrElse(Directive.Check), + code = body.mkString("\n") + ) + } else if (pending.isDefined) { + problems += s"$file:$fenceLine: panther-check directive precedes a non-panther fence" + } + + pending = None + + case _ => + if (line.trim.nonEmpty) pending = None + index += 1 + } + } + + blocks.toSeq + } +}