From 81856f6478f8011073656e18db4a2cc8f7874c44 Mon Sep 17 00:00:00 2001 From: Kevin Thompson Date: Tue, 1 Sep 2026 22:02:14 -0700 Subject: [PATCH 1/3] feat(tools): add doccheck to verify documentation code blocks Nothing was checking that the ```panther snippets in docs/ are valid Panther, and most of them were not. doccheck extracts every block and runs it through the real pncs front end (lexer, parser, binder), so the docs cannot drift away from the language the compiler accepts. Blocks can be annotated in place with an HTML comment above the fence -- parse-only, expect-error, or skip reason="..." -- which markdown renderers drop from the output. A malformed directive is reported rather than silently ignored, so a typo cannot quietly disable checking. Known-broken blocks live in tools/doccheck/baseline.txt, which is picked up automatically so a normal run reports only what changed. The check fails in both directions: a passing snippet breaking, and a baselined snippet starting to pass, so the list cannot go stale. Wired into CI as a fourth job. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 21 + build.sbt | 13 + docs/architecture/primitives.yaml | 13 + tools/doccheck/README.md | 99 +++++ tools/doccheck/baseline.txt | 5 + .../src/main/scala/BlockChecker.scala | 153 +++++++ tools/doccheck/src/main/scala/DocBlock.scala | 66 +++ tools/doccheck/src/main/scala/DocCheck.scala | 391 ++++++++++++++++++ .../src/main/scala/MarkdownScanner.scala | 142 +++++++ 9 files changed, 903 insertions(+) create mode 100644 tools/doccheck/README.md create mode 100644 tools/doccheck/baseline.txt create mode 100644 tools/doccheck/src/main/scala/BlockChecker.scala create mode 100644 tools/doccheck/src/main/scala/DocBlock.scala create mode 100644 tools/doccheck/src/main/scala/DocCheck.scala create mode 100644 tools/doccheck/src/main/scala/MarkdownScanner.scala 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/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/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 + } +} From 43cabf09892a370b9c4c9ec206937a0eab10013e Mon Sep 17 00:00:00 2001 From: Kevin Thompson Date: Tue, 1 Sep 2026 22:02:25 -0700 Subject: [PATCH 2/3] docs: rewrite code blocks so every documentation snippet compiles 157 of 215 panther blocks did not compile; four crashed the compiler outright, including the second example on the Getting Started page. The docs described an aspirational language, not the implemented one. Systematic corrections across every page: Array -> Array[int] (square brackets, lowercase) Int/String/Bool -> int/string/bool fun f(): T { .. } -> def f(): T = .. (no fun, no return) for (x in xs) -> for (i <- 0 to n) (only numeric ranges exist) match (x) { .. } -> x match { case .. } "n: " + count -> "n: " + string(count) Several sections documented features the language does not have and were rewritten to teach what it does: array literals, float literals, compound assignment, nested block comments, pattern guards and alternation, tuples, break/continue, and readLine. basics/variables.md no longer claims val reassignment is a compile error, because it is not. functions/higher-order-functions.md was 275 lines teaching lambdas. Neither lambdas nor function-typed parameters nor nested defs parse, so higher-order functions do not exist in any form. It is now a short page that says so and shows the workarounds: top-level named functions, generic functions, and enum + match for behaviour dispatch. Its two skip-marked blocks are the only skips in the tree. Result: 200 blocks, 198 compiling, 0 failing, 2 skipped. The baseline is empty, so CI now enforces a clean run rather than a ratchet. Co-Authored-By: Claude Opus 5 --- docs/src/content/docs/basics/comments.md | 46 ++- .../content/docs/basics/getting-started.md | 2 +- docs/src/content/docs/basics/operators.md | 25 +- docs/src/content/docs/basics/variables.md | 11 +- docs/src/content/docs/data-types/index.md | 10 +- .../flow-control/conditional-statements.md | 33 +- docs/src/content/docs/flow-control/loops.md | 249 ++++++------- .../docs/flow-control/pattern-matching.md | 332 ++++++++++------- .../docs/functions/defining-functions.md | 50 ++- .../docs/functions/higher-order-functions.md | 305 +++++----------- docs/src/content/docs/functions/parameters.md | 84 +++-- .../content/docs/functions/return-values.md | 138 +++++--- docs/src/content/docs/guides/basics.md | 9 +- docs/src/content/docs/guides/data-types.md | 335 +++++++----------- docs/src/content/docs/guides/flow-control.md | 250 ++++++------- docs/src/content/docs/guides/functions.md | 129 +++---- 16 files changed, 993 insertions(+), 1015 deletions(-) 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 From a2b13099d14ef6b3e8a42d934cb88e005dfaf0ec Mon Sep 17 00:00:00 2001 From: Kevin Thompson Date: Tue, 1 Sep 2026 22:02:36 -0700 Subject: [PATCH 3/3] docs: add ROADMAP and correct README claims ROADMAP.md covers the three stated goals -- self-hosting, generics, and runnable sample programs -- with measured numbers and the command that reproduces each, rather than estimates. What the measurements turned up: sbt pnc/compile reports 1058 diagnostics, and 685 of them mention an unsolved type variable, which is why generic inference leads the plan. pnc/src/ast.pn and printer.pn are stale committed artifacts that transpile no longer regenerates; CI cannot see the drift because git diff --exit-code ignores the untracked Ast.pn it now emits. The compiler never exits non-zero, so pnc/compile "succeeds" with 1058 errors. Nothing is ever written to disk. Compilation.emit(output) ignores its argument and Emitter.emit() returns in-memory structures, so the documented pncs output.pnb source.pn produces no file, and there is no reader and no runner. This blocks the sample programs. The in/out variance keywords are inverted in both the transpiler and the binder relative to Variance.scala's own comments. The two inversions cancel, so nothing breaks until someone writes [out T]. Fixing the docs surfaced nine further compiler gaps, all verified and recorded under cross-cutting: val immutability is not enforced at all, break/continue panic the binder through boundErrorStatement, decimal literals do not lex, and there is no return, no compound assignment, no pattern guards, alternation, or tuples, and no nested functions. README corrections: the output path is not honoured, and pnc/compile exits zero despite failing. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 35 +++- README.md | 33 +++- ROADMAP.md | 481 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 543 insertions(+), 6 deletions(-) create mode 100644 ROADMAP.md 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 |