From a7380243e95eeda18f3d2b4eef4f00d965f8412e Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 28 Oct 2026 13:11:23 +0100 Subject: [PATCH 1/9] feat(snippets): check the ; => comments in docs code blocks The suite only proved that each block runs. It now rewrites every single-line form with a ; => comment into a check and fails on a mismatch, with the same baseline and ratchet used for blocks that cannot run. --- build/run-doc-snippets.php | 110 +++++- .../src/DocSnippet/ExpectedValueRewriter.php | 347 ++++++++++++++++++ .../DocSnippet/ExpectedValueRewriterTest.php | 174 +++++++++ 3 files changed, 617 insertions(+), 14 deletions(-) create mode 100644 build/src/DocSnippet/ExpectedValueRewriter.php create mode 100644 build/tests/php/DocSnippet/ExpectedValueRewriterTest.php diff --git a/build/run-doc-snippets.php b/build/run-doc-snippets.php index 9f52764..203aac9 100644 --- a/build/run-doc-snippets.php +++ b/build/run-doc-snippets.php @@ -4,7 +4,9 @@ /** * Executes every ```phel snippet in content/ against the real Phel runtime, - * so documentation cannot silently rot when the language changes. + * so documentation cannot silently rot when the language changes. Documented + * results (`; => value`) are checked too (see ExpectedValueRewriter); a + * mismatch fails like a broken snippet. * * Each snippet runs in an isolated `phel run` subprocess under a wall-clock cap * enforced here in PHP, so a hanging or memory-heavy snippet cannot take down @@ -15,7 +17,8 @@ * templates and narrative continuations exist by design. Those are either * auto-skipped (see SnippetExtractor) or recorded in a checked-in baseline * (build/doc-snippets-baseline.json), keyed by snippet content so the entry - * survives line moves. The suite fails on: + * survives line moves. A mismatch entry adds the expectation's text to that + * key. The suite fails on: * - a NEW failure not present in the baseline (a regression), or * - a baseline entry that now passes (ratchet: remove it). * @@ -27,6 +30,7 @@ require __DIR__ . '/../vendor/autoload.php'; +use PhelWeb\DocSnippet\ExpectedValueRewriter; use PhelWeb\DocSnippet\SnippetExtractor; $root = dirname(__DIR__); @@ -47,6 +51,7 @@ $CONCURRENCY = 8; $extractor = new SnippetExtractor(); +$rewriter = new ExpectedValueRewriter(); /** Stable per-snippet key, independent of its line number. */ $keyOf = static function (string $relFile, string $code): string { @@ -54,6 +59,11 @@ return $relFile . '#' . substr(sha1((string) $normalized), 0, 12); }; +$expectationKeyOf = static function (string $snippetKey, string $text): string { + $normalized = preg_replace('/\s+/', ' ', trim($text)); + return $snippetKey . '=>' . substr(sha1((string) $normalized), 0, 8); +}; + // Collect markdown files. $files = []; foreach ($paths as $path) { @@ -77,6 +87,7 @@ $jobs = []; $skipped = 0; +$skippedExpectations = []; $index = 0; foreach ($files as $file) { $rel = str_replace($root . '/', '', $file); @@ -88,16 +99,28 @@ $index++; $code = $snippet['code']; $hasNs = preg_match('/^\s*\(ns\s/', $code) === 1; - // Dot separator, not backslash: Phel deprecated `\` in namespaces, and - // the notice it prints carries a full PHP stack trace (~150KB of stderr - // per snippet), which both slows the run and floods the pipes. - $source = $hasNs ? $code : "(ns doctest.s{$index})\n{$code}"; + // `user`, as in the REPL, so documented values such as `(user.point 1 2)` match. + $header = $hasNs ? '' : "(ns user)\n"; + $rewrite = $rewriter->rewrite($code, $rel, $snippet['startLine']); + array_push($skippedExpectations, ...$rewrite['skipped']); + $key = $keyOf($rel, $code); $tmpFile = "{$tmpDir}/s{$index}.phel"; - file_put_contents($tmpFile, $source); + file_put_contents($tmpFile, $header . $rewrite['code']); + $originalFile = null; + if ($rewrite['expectations'] !== []) { + $originalFile = "{$tmpDir}/s{$index}-original.phel"; + file_put_contents($originalFile, $header . $code); + } + $expectations = []; + foreach ($rewrite['expectations'] as $expectation) { + $expectations[$expectation['location']] = $expectation + ['key' => $expectationKeyOf($key, $expectation['text'])]; + } $jobs[] = [ - 'key' => $keyOf($rel, $code), + 'key' => $key, 'location' => "{$rel}:{$snippet['startLine']}", 'file' => $tmpFile, + 'originalFile' => $originalFile, + 'expectations' => $expectations, ]; } } @@ -220,6 +243,28 @@ $passed = 0; $queue = $jobs; +// Expectations of a snippet whose instrumented run passed: a PHEL-DOC-MISMATCH +// line in stdout fails that expectation, its absence passes it. +$mismatches = []; // key => ['location' => ..., 'note' => ...] +$checked = 0; +$unverifiable = []; +$recordExpectations = static function (array $job, string $out) use (&$mismatches, &$checked, &$passedKeys): void { + $marker = preg_quote(ExpectedValueRewriter::MISMATCH_MARKER, '/'); + preg_match_all('/^' . $marker . ' (\S+) (expected .*)$/m', $out, $found, PREG_SET_ORDER); + $notes = []; + foreach ($found as $m) { + $notes[$m[1]] = $m[2]; + } + foreach ($job['expectations'] as $location => $expectation) { + ++$checked; + if (isset($notes[$location])) { + $mismatches[$expectation['key']] = ['location' => $location, 'note' => $notes[$location]]; + } else { + $passedKeys[$expectation['key']] = true; + } + } +}; + while ($queue !== [] || $running !== []) { while (count($running) < $CONCURRENCY && $queue !== []) { $running[] = $startProc(array_shift($queue)); @@ -247,6 +292,7 @@ if ($exit === 0 && !$r['timedOut']) { $passed++; $passedKeys[$job['key']] = true; + $recordExpectations($job, $r['out']); } else { $failed[$job['key']] = [ 'location' => $job['location'], @@ -286,6 +332,23 @@ unset($failed[$key]); $passed++; $passedKeys[$key] = true; + $recordExpectations($job, $out); + continue; + } + $failed[$key]['note'] = $noteOf($exit, $out, $err, $timedOut); + if ($job['originalFile'] === null) { + continue; + } + // The checks must never break a snippet that runs on its own: if the + // original passes, its expectations are reported as unverifiable instead. + [$exit, $out, $err, $timedOut] = $reap($startProc(['file' => $job['originalFile']] + $job, 'original-')); + if ($exit === 0 && !$timedOut) { + unset($failed[$key]); + $passed++; + $passedKeys[$key] = true; + foreach ($job['expectations'] as $expectation) { + $unverifiable[] = $expectation; + } } else { $failed[$key]['note'] = $noteOf($exit, $out, $err, $timedOut); } @@ -305,9 +368,12 @@ $rmTree($tmpDir); // --- Baseline handling --- +$failures = $failed + $mismatches; +$expectationSummary = "Expectations: checked {$checked}, mismatched " . count($mismatches) . ', unverifiable ' . count($unverifiable); + if ($updateBaseline) { $entries = []; - foreach ($failed as $key => $info) { + foreach ($failures as $key => $info) { $entries[$key] = $info['location'] . ' :: ' . $info['note']; } ksort($entries); @@ -315,8 +381,9 @@ $baselinePath, json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n", ); - echo "\nWrote baseline with " . count($entries) . " known-failing snippets to build/doc-snippets-baseline.json\n"; + echo "\nWrote baseline with " . count($entries) . " known failures to build/doc-snippets-baseline.json\n"; echo "Snippets: {$total} Passed: {$passed} Failed: " . count($failed) . " Skipped: {$skipped}\n"; + echo "{$expectationSummary}\n"; exit(0); } @@ -325,12 +392,13 @@ $baseline = (array) json_decode((string) file_get_contents($baselinePath), true); } -$regressions = array_diff_key($failed, $baseline); // failing now, not in baseline +$regressions = array_diff_key($failures, $baseline); // failing now, not in baseline $fixed = array_intersect_key($passedKeys, $baseline); // in baseline but now passing echo "\n"; echo "Snippets: {$total} Passed: {$passed} Failed: " . count($failed) . " Skipped: {$skipped} Baseline: " . count($baseline) . "\n"; +echo "{$expectationSummary}\n"; $exit = 0; @@ -350,15 +418,29 @@ } } -if ($verbose && $failed !== []) { +if ($verbose && $failures !== []) { echo "\nAll current failures:\n"; - foreach ($failed as $info) { + foreach ($failures as $info) { echo " {$info['location']} {$info['note']}\n"; } } +if ($verbose && $unverifiable !== []) { + echo "\nUnverifiable expectations (the instrumented snippet fails, the original passes):\n"; + foreach ($unverifiable as $expectation) { + echo " {$expectation['location']} {$expectation['text']}\n"; + } +} + +if ($verbose && $skippedExpectations !== []) { + echo "\nSkipped expectations:\n"; + foreach ($skippedExpectations as $expectation) { + echo " {$expectation['location']} [{$expectation['reason']}] {$expectation['text']}\n"; + } +} + if ($exit === 0) { - echo "\nNo regressions. " . count($baseline) . " known-unrunnable snippets tracked in baseline.\n"; + echo "\nNo regressions. " . count($baseline) . " known failures tracked in baseline.\n"; } exit($exit); diff --git a/build/src/DocSnippet/ExpectedValueRewriter.php b/build/src/DocSnippet/ExpectedValueRewriter.php new file mode 100644 index 0000000..c1ef854 --- /dev/null +++ b/build/src/DocSnippet/ExpectedValueRewriter.php @@ -0,0 +1,347 @@ + EXPECTED`, or a + * `; => EXPECTED` line right below FORM) into checks that print a + * PHEL-DOC-MISMATCH line when the runtime value differs. The rewrite keeps the + * line count, so error locations still point at the markdown source. + * + * @psalm-type TExpectation = array{line: int, location: string, text: string, form: string, expected: string} + * @psalm-type TSkippedExpectation = array{line: int, location: string, text: string, reason: string} + * @psalm-type TRewrite = array{ + * code: string, + * expectations: list, + * skipped: list, + * } + */ +final class ExpectedValueRewriter +{ + public const MISMATCH_MARKER = 'PHEL-DOC-MISMATCH'; + + private const DEFINITION_PREFIXES = [ + '(def', + '(ns', + '(require', + '(use', + '(declare', + '(comment', + ]; + + private const UNREADABLE_PREFIXES = ['#\'', '#<', '<']; + + /** Words that describe a value instead of printing it; keywords such as `:error` stay checkable. */ + private const NON_VALUE_WORDS = '/(? ')', '[' => ']', '{' => '}']; + + /** + * @return TRewrite + */ + public function rewrite(string $code, string $file, int $startLine): array + { + $lines = explode("\n", $code); + $codeEnds = []; + $expectations = []; + $skipped = []; + $depth = 0; + $inString = false; + $formStart = null; + $lastForm = null; + + foreach ($lines as $offset => $line) { + $topLevelAtStart = $depth === 0 && !$inString; + $commentStart = $this->scanLine($line, $depth, $inString); + $codeEnds[$offset] = $commentStart ?? strlen($line); + $hasCode = trim(substr($line, 0, $codeEnds[$offset])) !== ''; + + if ($topLevelAtStart && $hasCode) { + $formStart = $offset; + } + $closedForm = null; + if ($formStart !== null && $depth === 0 && !$inString && $hasCode) { + $closedForm = ['start' => $formStart, 'end' => $offset]; + $formStart = null; + } + + $expectedText = $this->expectedText($line, $commentStart); + if ($expectedText === null) { + if ($closedForm !== null) { + $lastForm = $closedForm; + } elseif ($hasCode) { + $lastForm = null; + } + continue; + } + + $lineNumber = $startLine + $offset; + $location = "{$file}:{$lineNumber}"; + + $span = null; + $reason = null; + if ($hasCode) { + $span = $closedForm; + $reason = $span === null ? 'inside an enclosing form' : null; + } elseif (!$topLevelAtStart) { + $reason = 'inside an enclosing form'; + } elseif ($lastForm !== null && $lastForm['end'] === $offset - 1) { + $span = $lastForm; + } else { + $reason = 'result comment not right below a form'; + } + $lastForm = null; + + $form = $span === null ? '' : $this->formText($lines, $codeEnds, $span['start'], $span['end']); + $shownForm = $span === null ? substr($line, 0, $codeEnds[$offset]) : $form; + $text = trim((string) preg_replace('/\s+/', ' ', $shownForm . ' ' . substr($line, (int) $commentStart))); + $reason ??= $this->skipReason($form, $expectedText); + if ($reason !== null || $span === null) { + $skipped[] = ['line' => $lineNumber, 'location' => $location, 'text' => $text, 'reason' => (string) $reason]; + continue; + } + + $expected = $this->firstExpectedForm($expectedText); + $expectations[] = [ + 'line' => $lineNumber, + 'location' => $location, + 'text' => $text, + 'form' => $form, + 'expected' => $expected, + ]; + $this->instrument($lines, $codeEnds, $span['start'], $offset, $hasCode, $expected, $location); + } + + return [ + 'code' => implode("\n", $lines), + 'expectations' => $expectations, + 'skipped' => $skipped, + ]; + } + + /** + * The first form of the documented value: a delimited form when it opens + * with a bracket or a quote, otherwise the text up to the first whitespace. + */ + public function firstExpectedForm(string $expectedText): string + { + $text = ltrim($expectedText); + $end = $this->delimitedValueEnd($text); + if ($end !== null) { + return substr($text, 0, $end); + } + + return (preg_split('/\s/', $text, 2) ?: [''])[0]; + } + + private function expectedText(string $line, ?int $commentStart): ?string + { + if ($commentStart === null) { + return null; + } + if (preg_match('/^;(?!;)\s*=>(.*)$/', substr($line, $commentStart), $m) !== 1) { + return null; + } + + return trim($m[1]); + } + + private function skipReason(string $form, string $expectedText): ?string + { + if (!$this->isSingleForm($form)) { + return 'not one form'; + } + foreach (self::DEFINITION_PREFIXES as $prefix) { + if (str_starts_with($form, $prefix)) { + return 'definition form'; + } + } + if ($expectedText === '') { + return 'no expected value'; + } + if (str_contains($expectedText, '...')) { + return 'elided value'; + } + foreach (self::UNREADABLE_PREFIXES as $prefix) { + if (str_starts_with($expectedText, $prefix)) { + return 'unreadable value'; + } + } + if ($this->opensDelimitedValue($expectedText) && $this->delimitedValueEnd($expectedText) === null) { + return 'value does not close on this line'; + } + if (preg_match(self::GENERATED_SYMBOL, $expectedText) === 1) { + return 'generated symbol'; + } + $outsideStrings = (string) preg_replace('/"(?:[^"\\\\]|\\\\.)*"/s', '""', $expectedText); + if (preg_match(self::NON_VALUE_WORDS, $outsideStrings, $m) === 1) { + return "describes the value ('" . strtolower($m[1]) . "')"; + } + + return null; + } + + /** + * @param array $lines + * @param array $codeEnds + */ + private function formText(array $lines, array $codeEnds, int $start, int $end): string + { + $parts = []; + for ($i = $start; $i <= $end; $i++) { + $parts[] = substr($lines[$i], 0, $codeEnds[$i]); + } + + return trim(implode("\n", $parts)); + } + + /** + * @param array $lines + * @param array $codeEnds + */ + private function instrument(array &$lines, array $codeEnds, int $start, int $commentLine, bool $sameLine, string $expected, string $location): void + { + $head = '(let [__expected (quote ' . $expected . ') __actual '; + $tail = '] (when-not (or (= __expected __actual) (= (pr-str __expected) (pr-str __actual))) ' + . '(println (str "' . self::MISMATCH_MARKER . ' ' . addcslashes($location, '"\\') + . ' expected " (pr-str __expected) " got " (pr-str __actual)))))'; + + if ($sameLine && $start === $commentLine) { + $lines[$start] = $head . trim(substr($lines[$start], 0, $codeEnds[$start])) . $tail; + return; + } + + $lines[$start] = $head . $lines[$start]; + $lines[$commentLine] = $sameLine ? rtrim(substr($lines[$commentLine], 0, $codeEnds[$commentLine])) . $tail : $tail; + } + + /** + * Advances the bracket depth and string state across one line and returns + * the offset of the comment that ends it, if any. + */ + private function scanLine(string $line, int &$depth, bool &$inString): ?int + { + $length = strlen($line); + for ($i = 0; $i < $length; $i++) { + $char = $line[$i]; + if ($inString) { + if ($char === '\\') { + $i++; + } elseif ($char === '"') { + $inString = false; + } + continue; + } + if ($char === '"') { + $inString = true; + } elseif ($char === ';') { + return $i; + } elseif (isset(self::OPENERS[$char])) { + $depth++; + } elseif (in_array($char, self::OPENERS, true)) { + $depth--; + } + } + + return null; + } + + private function isSingleForm(string $form): bool + { + $end = $this->formEnd($form, 0); + + return $end !== null && trim(substr($form, $end)) === ''; + } + + /** Reads through prefixes such as quote, deref, `|` and `#?` to the end of one form. */ + private function formEnd(string $text, int $pos): ?int + { + $length = strlen($text); + while ($pos < $length && ctype_space($text[$pos])) { + $pos++; + } + if ($pos >= $length) { + return null; + } + + $char = $text[$pos]; + if (str_contains("'`,@|^", $char)) { + return $this->formEnd($text, $pos + 1); + } + if ($char === '#' && ($text[$pos + 1] ?? '') === '?') { + return $this->formEnd($text, $pos + 2); + } + if ($char === '#' && ($text[$pos + 1] ?? '') === '{') { + return $this->delimitedEnd($text, $pos + 1); + } + if ($char === '"' || isset(self::OPENERS[$char])) { + return $this->delimitedEnd($text, $pos); + } + if (in_array($char, self::OPENERS, true)) { + return null; + } + + while ($pos < $length && !ctype_space($text[$pos]) && !str_contains('()[]{}";', $text[$pos])) { + $pos++; + } + + return $pos; + } + + private function opensDelimitedValue(string $text): bool + { + $first = $text[str_starts_with($text, '#{') ? 1 : 0] ?? ''; + + return $first === '"' || isset(self::OPENERS[$first]); + } + + private function delimitedValueEnd(string $text): ?int + { + if (!$this->opensDelimitedValue($text)) { + return null; + } + + return $this->delimitedEnd($text, str_starts_with($text, '#{') ? 1 : 0); + } + + /** End offset of the string or bracketed form that opens at $pos; null when it never closes or closes with the wrong bracket. */ + private function delimitedEnd(string $text, int $pos): ?int + { + $length = strlen($text); + $stack = []; + $inString = false; + for ($i = $pos; $i < $length; $i++) { + $char = $text[$i]; + if ($inString) { + if ($char === '\\') { + $i++; + } elseif ($char === '"') { + $inString = false; + if ($stack === []) { + return $i + 1; + } + } + continue; + } + if ($char === '"') { + $inString = true; + } elseif (isset(self::OPENERS[$char])) { + $stack[] = self::OPENERS[$char]; + } elseif (in_array($char, self::OPENERS, true)) { + if (array_pop($stack) !== $char) { + return null; + } + if ($stack === []) { + return $i + 1; + } + } + } + + return null; + } +} diff --git a/build/tests/php/DocSnippet/ExpectedValueRewriterTest.php b/build/tests/php/DocSnippet/ExpectedValueRewriterTest.php new file mode 100644 index 0000000..1f373ed --- /dev/null +++ b/build/tests/php/DocSnippet/ExpectedValueRewriterTest.php @@ -0,0 +1,174 @@ +rewriter = new ExpectedValueRewriter(); + } + + public function test_rewrites_a_single_line_expectation(): void + { + $result = $this->rewriter->rewrite('(+ 1 2) ; => 3', 'doc.md', 10); + + self::assertSame( + '(let [__expected (quote 3) __actual (+ 1 2)] (when-not (or (= __expected __actual) (= (pr-str __expected) (pr-str __actual))) ' + . '(println (str "PHEL-DOC-MISMATCH doc.md:10 expected " (pr-str __expected) " got " (pr-str __actual)))))', + $result['code'], + ); + self::assertCount(1, $result['expectations']); + self::assertSame('doc.md:10', $result['expectations'][0]['location']); + self::assertSame('(+ 1 2)', $result['expectations'][0]['form']); + self::assertSame('3', $result['expectations'][0]['expected']); + self::assertSame([], $result['skipped']); + } + + public function test_keeps_the_line_count(): void + { + $code = "(def a 1)\n(+ a 1) ; => 2\n(let [b 2]\n (* a b)) ; => 2\n(inc a)\n; => 2"; + + $result = $this->rewriter->rewrite($code, 'doc.md', 1); + + self::assertCount(3, $result['expectations']); + self::assertSame(6, substr_count($result['code'], "\n") + 1); + } + + public function test_multi_line_form_is_wrapped_from_its_first_line(): void + { + $result = $this->rewriter->rewrite("(let [b 2]\n (* 3 b)) ; => 6", 'doc.md', 5); + $lines = explode("\n", $result['code']); + + self::assertSame('(let [__expected (quote 6) __actual (let [b 2]', $lines[0]); + self::assertStringStartsWith(' (* 3 b))] (when-not', $lines[1]); + self::assertStringContainsString('doc.md:6 expected', $lines[1]); + self::assertSame("(let [b 2]\n (* 3 b))", $result['expectations'][0]['form']); + } + + public function test_result_comment_right_below_a_form_checks_that_form(): void + { + $result = $this->rewriter->rewrite("(str \"a\" \"b\")\n; => \"ab\"", 'doc.md', 1); + $lines = explode("\n", $result['code']); + + self::assertSame('(let [__expected (quote "ab") __actual (str "a" "b")', $lines[0]); + self::assertStringStartsWith('] (when-not', $lines[1]); + self::assertSame('(str "a" "b") ; => "ab"', $result['expectations'][0]['text']); + } + + public function test_arrow_inside_a_string_is_not_an_expectation(): void + { + $code = '(println "a ; => b")'; + + $result = $this->rewriter->rewrite($code, 'doc.md', 1); + + self::assertSame($code, $result['code']); + self::assertSame([], $result['expectations']); + self::assertSame([], $result['skipped']); + } + + public function test_arrow_after_a_string_holding_a_semicolon_is_an_expectation(): void + { + $result = $this->rewriter->rewrite('(str "a;b") ; => "a;b"', 'doc.md', 1); + + self::assertSame('"a;b"', $result['expectations'][0]['expected']); + } + + public function test_double_semicolon_is_not_an_expectation(): void + { + $code = '(+ 1 2) ;; => 3'; + + $result = $this->rewriter->rewrite($code, 'doc.md', 1); + + self::assertSame($code, $result['code']); + self::assertSame([], $result['expectations']); + } + + public function test_plain_comments_are_left_alone(): void + { + $code = "; a comment\n(+ 1 2) ; prints nothing"; + + $result = $this->rewriter->rewrite($code, 'doc.md', 1); + + self::assertSame($code, $result['code']); + self::assertSame([], $result['skipped']); + } + + /** + * @return iterable + */ + public static function skippedLines(): iterable + { + yield 'definition' => ['(def x (+ 1 2)) ; => 3', 'definition form']; + yield 'function definition' => ['(defn f [] 1) ; => 1', 'definition form']; + yield 'namespace' => ['(ns app) ; => nil', 'definition form']; + yield 'two forms' => ['(inc 1) (inc 2) ; => 2 3', 'not one form']; + yield 'nested' => ["(do\n (inc 1) ; => 2\n 3)", 'inside an enclosing form']; + yield 'own line after a blank' => ["(inc 1)\n\n; => 2", 'result comment not right below a form']; + yield 'own line at the start' => ['; => 2', 'result comment not right below a form']; + yield 'elided' => ['(range 100) ; => (0 1 2 ...)', 'elided value']; + yield 'var' => ['(var x) ; => #\'user/x', 'unreadable value']; + yield 'object' => ['(fn [] 1) ; => ', 'unreadable value']; + yield 'description' => ['(DateTime.) ; => DateTime instance', "describes the value ('instance')"]; + yield 'random' => ['(rand) ; => 0.42 (random)', "describes the value ('random')"]; + yield 'throws' => ['(/ 1 0) ; => throws DivisionByZeroError', "describes the value ('throws')"]; + yield 'unclosed value' => ['(range 3) ; => (0 1', 'value does not close on this line']; + yield 'generated symbol' => ['(gensym) ; => __phel_1', 'generated symbol']; + yield 'empty' => ['(inc 1) ; =>', 'no expected value']; + } + + #[DataProvider('skippedLines')] + public function test_skips_lines_it_cannot_check(string $code, string $reason): void + { + $result = $this->rewriter->rewrite($code, 'doc.md', 1); + + self::assertSame([], $result['expectations']); + self::assertSame($code, $result['code']); + self::assertCount(1, $result['skipped']); + self::assertSame($reason, $result['skipped'][0]['reason']); + } + + public function test_keyword_or_string_holding_a_skip_word_is_still_checked(): void + { + $result = $this->rewriter->rewrite("(f) ; => {:error nil}\n(g) ; => \"error: boom\"", 'doc.md', 1); + + self::assertCount(2, $result['expectations']); + } + + /** + * @return iterable + */ + public static function expectedTexts(): iterable + { + yield 'number with a note' => ['1337 (hex)', '1337']; + yield 'string' => ['"a b" (string)', '"a b"']; + yield 'string with escaped quote' => ['"say \"hi\"" ok', '"say \"hi\""']; + yield 'list' => ['(1 2 3) lazily', '(1 2 3)']; + yield 'nested vector' => ['[[1 :a] [2 :b]], in order', '[[1 :a] [2 :b]]']; + yield 'map with string holding a bracket' => ['{:a "]"} done', '{:a "]"}']; + yield 'set' => ['#{1 2} unordered', '#{1 2}']; + yield 'keyword' => [':timeout (the future keeps running)', ':timeout']; + yield 'nil' => ['nil', 'nil']; + } + + #[DataProvider('expectedTexts')] + public function test_takes_the_first_form_of_the_expected_text(string $text, string $first): void + { + self::assertSame($first, $this->rewriter->firstExpectedForm($text)); + } + + public function test_reader_shortcuts_count_as_one_form(): void + { + $result = $this->rewriter->rewrite("@a ; => 1\n'(1 2) ; => (1 2)\n#?(:phel 1 :default 0) ; => 1", 'doc.md', 1); + + self::assertCount(3, $result['expectations']); + } +} From b56afb7e510e57efae91bc2e8c7c1e45b14da84a Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 28 Oct 2026 13:11:24 +0100 Subject: [PATCH 2/9] docs(language): show the struct name assoc prints --- content/documentation/language/data-structures.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/documentation/language/data-structures.md b/content/documentation/language/data-structures.md index 5ec0753..4a6c8d8 100644 --- a/content/documentation/language/data-structures.md +++ b/content/documentation/language/data-structures.md @@ -202,7 +202,7 @@ A struct is a map with a fixed set of keys and a name. `defstruct` defines a con (point? p) ; => true (get p :x) ; => 1 (:y p) ; => 2 -(assoc p :x 10) ; => (point 10 2) +(assoc p :x 10) ; => (user.point 10 2) ``` A struct compiles to a PHP class with one property per key, so it is faster than a map. PHP code can call `count($p)` and read `$p['x']`: every struct implements `\Countable`, `\ArrayAccess`, and `\IteratorAggregate`. From 20fed6863d2c147a1ea114fbb772f0f7796c7c8b Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Wed, 28 Oct 2026 13:20:01 +0100 Subject: [PATCH 3/9] feat(docs): check the house rules of every docs page npm run check:docs fails on a description outside 50 to 160 characters, a language page without a difficulty, ;; => or ;; prints, a blockquote, a Phel release older than config.toml outside upgrading.md, a page that opens with a heading, and an H1 in the body. CI runs it. The pages that broke a rule are fixed. --- .github/workflows/ci.yml | 3 + .../ErrorReferenceGenerator.php | 2 +- content/documentation/getting-started.md | 2 + content/documentation/guides/cookbook.md | 10 +- content/documentation/guides/debugging.md | 2 +- content/documentation/installation.md | 2 +- content/documentation/language/basic-types.md | 2 + .../language/functions-and-recursion.md | 2 + .../language/global-and-local-bindings.md | 2 + .../documentation/language/lazy-sequences.md | 2 +- content/documentation/language/namespaces.md | 2 + content/documentation/language/php-interop.md | 3 + .../language/reader-shortcuts.md | 2 +- .../documentation/reference/configuration.md | 2 + content/documentation/reference/errors.md | 2 +- content/documentation/tooling/repl.md | 2 + content/documentation/web/html-rendering.md | 2 + .../web/http-request-and-response.md | 2 + package.json | 1 + scripts/check-docs.js | 105 ++++++++++++++++++ 20 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 scripts/check-docs.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 805f170..3b52ccf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -271,6 +271,9 @@ jobs: - name: Install Node.js dependencies run: npm ci + - name: Check documentation pages + run: npm run check:docs + - name: Build CSS run: npm run build-css-prod diff --git a/build/src/ErrorReference/ErrorReferenceGenerator.php b/build/src/ErrorReference/ErrorReferenceGenerator.php index 50a3f34..b5e8804 100644 --- a/build/src/ErrorReference/ErrorReferenceGenerator.php +++ b/build/src/ErrorReference/ErrorReferenceGenerator.php @@ -234,7 +234,7 @@ private function render(): string description = "Every Phel error code (PHEL001-PHEL404), what it means, and how to fix it." +++ - > This page is the canonical index of error **codes**. To learn how to `throw`, `catch`, and attach data to errors in your own code, see [Error handling](/documentation/language/error-handling/). + This page lists every Phel error code. To learn how to `throw`, `catch`, and attach data to errors in your own code, see [Error handling](/documentation/language/error-handling/). Phel errors are tagged with a stable code like `[PHEL001]`. The code survives wording changes, so it is the reliable thing to search for. An error diff --git a/content/documentation/getting-started.md b/content/documentation/getting-started.md index 9b02423..ecb9bd8 100644 --- a/content/documentation/getting-started.md +++ b/content/documentation/getting-started.md @@ -4,6 +4,8 @@ weight = 2 description = "Create a Phel project, try the REPL, run your first file, and learn the project layout." +++ +This page takes you from an empty folder to a running Phel project. You create the project with Composer, try code in the REPL, run a file, and see where each part of the project lives. + ## Requirements - **PHP 8.5+** (`php -v`) diff --git a/content/documentation/guides/cookbook.md b/content/documentation/guides/cookbook.md index 94b15e5..a5a1b95 100644 --- a/content/documentation/guides/cookbook.md +++ b/content/documentation/guides/cookbook.md @@ -27,11 +27,11 @@ Copy a recipe, change the data, and run it. For the concepts behind the code, se (map #(update % :name str/upper-case)) (sort-by :age) (group-by :role))) -;; => {"engineer" [{:name "ALICE" ...} {:name "CHARLIE" ...}], "manager" [{:name "DIANA" ...}]} +; => {"engineer" [{:name "ALICE" ...} {:name "CHARLIE" ...}], "manager" [{:name "DIANA" ...}]} (foreach [role members by-role] (println role (count members))) -;; prints: engineer 2, manager 1 +; prints: engineer 2, manager 1 (->> users (map :role) (into #{})) ; => #{"engineer" "designer" "manager"} ``` @@ -101,8 +101,8 @@ A transducer is a pipeline you define once and apply to any target (`into`, `tra (mapv #(zipmap ks %) rows))) (read-csv path) -;; => [{:name "Alice", :email "alice@example.com", :role "admin"} -;; {:name "Bob", :email "bob@example.com", :role "editor"}] +; => [{:name "Alice", :email "alice@example.com", :role "admin"} +; {:name "Bob", :email "bob@example.com", :role "editor"}] ``` `csv-seq` reads rows lazily as vectors of strings. Zipping each row with the header row gives you maps with keyword keys. @@ -175,7 +175,7 @@ A transducer is a pipeline you define once and apply to any target (`into`, `tra (DateTimeImmutable. "2026-12-31"))) ; => 364 (.format (DateTimeImmutable/createFromFormat "d/m/Y" "25/12/2026") "Y-m-d") -;; => "2026-12-25" +; => "2026-12-25" ``` Use `DateTimeImmutable`: every change returns a new object, as Phel values do. `<` and `>` compare dates directly. The `#inst "2026-01-15T12:00:00Z"` literal also reads as a `DateTimeImmutable`. diff --git a/content/documentation/guides/debugging.md b/content/documentation/guides/debugging.md index c3a39ec..ddf321d 100644 --- a/content/documentation/guides/debugging.md +++ b/content/documentation/guides/debugging.md @@ -28,7 +28,7 @@ Start with the simplest tool that answers your question. (area 3 4) ;; stderr: [src/main.phel:2] w => 3 -;; => 12 +; => 12 ``` Because the value passes through, `dbg` works inside threading macros: diff --git a/content/documentation/installation.md b/content/documentation/installation.md index 0fc089c..5286fed 100644 --- a/content/documentation/installation.md +++ b/content/documentation/installation.md @@ -74,7 +74,7 @@ phel run src/main.phel | Variable | Default | Effect | |---|---|---| -| `PHEL_VERSION` | `latest` | Pin a Phel release, e.g. `0.50.0` | +| `PHEL_VERSION` | `latest` | Pin a Phel release, e.g. `0.54.0` | | `PHP_VERSION` | `8.5.8` | Pin a [static PHP build](https://dl.static-php.dev/static-php-cli/common/). Only the default has a pinned checksum | | `PREFIX` | `${TMPDIR}/phel-quick`, or `/tmp/phel-quick-$UID` | Where everything is downloaded | | `PHEL_FORCE` | `0` | `1` downloads again instead of reusing | diff --git a/content/documentation/language/basic-types.md b/content/documentation/language/basic-types.md index f8bfa83..fe7039b 100644 --- a/content/documentation/language/basic-types.md +++ b/content/documentation/language/basic-types.md @@ -8,6 +8,8 @@ aliases = ["/documentation/basic-types", "/documentation/arithmetic", "/document difficulty = "beginner" +++ +Every Phel program is built from a few simple values: `nil`, booleans, numbers, strings, keywords and symbols. This page shows how to write each one, which values count as true, and how Phel compares them. + ## Nil, true, false ```phel diff --git a/content/documentation/language/functions-and-recursion.md b/content/documentation/language/functions-and-recursion.md index ca0a520..c7da6ea 100644 --- a/content/documentation/language/functions-and-recursion.md +++ b/content/documentation/language/functions-and-recursion.md @@ -8,6 +8,8 @@ aliases = ["/documentation/functions-and-recursion"] difficulty = "intermediate" +++ +Functions are the main building block of a Phel program. This page shows how to define them with `defn` and `fn`, give one function several arities, accept any number of arguments, loop with `recur`, and choose an implementation at run time with multimethods. + ## Global functions diff --git a/content/documentation/language/global-and-local-bindings.md b/content/documentation/language/global-and-local-bindings.md index 2e76dea..0e2d4b7 100644 --- a/content/documentation/language/global-and-local-bindings.md +++ b/content/documentation/language/global-and-local-bindings.md @@ -8,6 +8,8 @@ aliases = ["/documentation/global-and-local-bindings"] difficulty = "beginner" +++ +A binding gives a value a name. Use `def` for a name the whole namespace sees, `let` for a name that exists only inside one expression, an atom for state that changes, and `binding` to give a dynamic var a new value for a while. + ## Definition (def) diff --git a/content/documentation/language/lazy-sequences.md b/content/documentation/language/lazy-sequences.md index 01a08d0..bc1d6b9 100644 --- a/content/documentation/language/lazy-sequences.md +++ b/content/documentation/language/lazy-sequences.md @@ -62,7 +62,7 @@ A lazy sequence can compute more values than you read. Side effects inside `map` ```phel (def xs (take 5 (map (fn [x] (println x) x) (range 100)))) -;; prints 0 through 5 when defined: six side effects for five results +; prints 0 through 5 when defined: six side effects for five results ``` Keep side effects out of lazy pipelines. Use `foreach` or `doseq`. diff --git a/content/documentation/language/namespaces.md b/content/documentation/language/namespaces.md index 5d463bd..072e5fc 100644 --- a/content/documentation/language/namespaces.md +++ b/content/documentation/language/namespaces.md @@ -8,6 +8,8 @@ aliases = ["/documentation/namespaces"] difficulty = "intermediate" +++ +A namespace groups related definitions and keeps their names apart from other code. This page shows how to declare one, load other Phel modules and PHP classes, and shorten names with aliases and `:refer`. + ## Declare a namespace Every Phel file starts with `ns`. The name has parts separated by `.`, and the last part matches the file name. Use one namespace per file. Each part starts with a letter or underscore, followed by letters, numbers, underscores or dashes. Use kebab-case (`my.user-service`); the compiler turns dashes into a valid PHP namespace. diff --git a/content/documentation/language/php-interop.md b/content/documentation/language/php-interop.md index 61d74a3..ec3a701 100644 --- a/content/documentation/language/php-interop.md +++ b/content/documentation/language/php-interop.md @@ -3,6 +3,9 @@ title = "PHP Interop" weight = 11 description = "Call PHP functions, create objects, convert PHP arrays, use Composer packages, and call Phel back from PHP." aliases = ["/documentation/php-interop/"] + +[extra] +difficulty = "intermediate" +++ Phel compiles to PHP, so every PHP function, class and Composer package is available. diff --git a/content/documentation/language/reader-shortcuts.md b/content/documentation/language/reader-shortcuts.md index 50782e1..3bbccef 100644 --- a/content/documentation/language/reader-shortcuts.md +++ b/content/documentation/language/reader-shortcuts.md @@ -1,7 +1,7 @@ +++ title = "Reader Shortcuts" weight = 8 -description = "Catalog of the Phel reader macros and special syntax: literals, quote/quasiquote, tagged literals, anonymous functions, reader conditionals for shared .cljc files, and metadata." +description = "Every Phel reader shortcut: literals, quote and quasiquote, tagged literals, anonymous functions, reader conditionals for .cljc files, and metadata." aliases = ["/documentation/language/reader-conditionals/"] [extra] diff --git a/content/documentation/reference/configuration.md b/content/documentation/reference/configuration.md index cb7127a..4b17c3c 100644 --- a/content/documentation/reference/configuration.md +++ b/content/documentation/reference/configuration.md @@ -5,6 +5,8 @@ description = "Every phel-config.php option with its default, the local override aliases = ["/documentation/configuration/"] +++ +Most projects run with the defaults. To change the source folders, the build output or other settings, edit `phel-config.php`. This page lists every option with its default, and the environment variables Phel reads. + ## Minimal config Phel reads `phel-config.php` from the project root. It returns a `PhelConfig`. Most projects need only the factory: diff --git a/content/documentation/reference/errors.md b/content/documentation/reference/errors.md index 1ecf122..e9f6371 100644 --- a/content/documentation/reference/errors.md +++ b/content/documentation/reference/errors.md @@ -4,7 +4,7 @@ weight = 4 description = "Every Phel error code (PHEL001-PHEL404), what it means, and how to fix it." +++ -> This page is the canonical index of error **codes**. To learn how to `throw`, `catch`, and attach data to errors in your own code, see [Error handling](/documentation/language/error-handling/). +This page lists every Phel error code. To learn how to `throw`, `catch`, and attach data to errors in your own code, see [Error handling](/documentation/language/error-handling/). Phel errors are tagged with a stable code like `[PHEL001]`. The code survives wording changes, so it is the reliable thing to search for. An error diff --git a/content/documentation/tooling/repl.md b/content/documentation/tooling/repl.md index fb0c4cd..4bb6446 100644 --- a/content/documentation/tooling/repl.md +++ b/content/documentation/tooling/repl.md @@ -5,6 +5,8 @@ description = "Use the Phel REPL: history vars, doc/dir/apropos helpers, introsp aliases = ["/documentation/repl", "/documentation/tooling/phel-helpers"] +++ +The REPL runs Phel code as you type it and prints the result. Use it to try an idea, read the docs of a function, or check a value while you debug. + ## Start the REPL ```bash diff --git a/content/documentation/web/html-rendering.md b/content/documentation/web/html-rendering.md index a791264..8d7eb16 100644 --- a/content/documentation/web/html-rendering.md +++ b/content/documentation/web/html-rendering.md @@ -5,6 +5,8 @@ description = "Render HTML from Phel data structures: vectors are elements, maps aliases = ["/documentation/html-rendering"] +++ +`phel.html` builds HTML from plain Phel data. A vector is an element, a map holds its attributes, and text is escaped for you. + ## Elements and attributes `html` from `phel.html` turns a vector into an HTML string, with no template language. The first item is the tag (keyword or string), an optional map holds the attributes, and the rest is the body: strings, numbers, nested vectors, or lists of them. diff --git a/content/documentation/web/http-request-and-response.md b/content/documentation/web/http-request-and-response.md index c0f6c16..a42449d 100644 --- a/content/documentation/web/http-request-and-response.md +++ b/content/documentation/web/http-request-and-response.md @@ -5,6 +5,8 @@ description = "Read an HTTP request, build and emit a response, and test handler aliases = ["/documentation/http-request-and-response"] +++ +The `phel.http` namespace turns a PHP web request into Phel data and turns your response back into output. This page shows how to read a request, build and send a response, and test a handler without a web server. + ## The request-response cycle Everything lives in `phel.http`. A web entry point reads the request from PHP's globals, turns it into a response, and sends it with `emit-response` (once, at the end of the request): diff --git a/package.json b/package.json index 859baaf..ef95b8a 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "generate-llms-full": "node ./scripts/generate-llms-full.js", "generate-doc-dates": "node ./scripts/generate-doc-dates.js", "check:links": "node ./scripts/check-internal-links.js", + "check:docs": "node ./scripts/check-docs.js", "prebuild-css": "node ./scripts/concat-tailwind.js && node ./scripts/generate-agent-doc.js && node ./scripts/generate-llms-full.js && node ./scripts/generate-doc-dates.js", "build-css": "npm run prebuild-css && postcss ./css/tailwind.entry.css -o ./static/tailwind.css --watch", "build-css-dev": "concurrently \"node ./scripts/concat-tailwind.js --watch\" \"postcss ./css/tailwind.entry.css -o ./static/tailwind.css --watch\" --names \"CONCAT,TAILWIND\" --prefix-colors \"yellow,cyan\"", diff --git a/scripts/check-docs.js b/scripts/check-docs.js new file mode 100644 index 0000000..07588f9 --- /dev/null +++ b/scripts/check-docs.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * Check the house rules of every documentation page. + * + * Reads: content/documentation/**.md (except the generated API reference), + * phel_version from config.toml + * Exits: 1 when a page breaks a rule + * + * Rules: a description of 50 to 160 characters, a difficulty on every language + * page, `; =>` and `; prints` comments, the callout component instead of a + * blockquote, no Phel release older than the current one outside upgrading.md, + * a lead paragraph before the first H2, and no H1 in the body. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); +const DOCS = path.join(ROOT, 'content', 'documentation'); +const GENERATED = path.join(DOCS, 'reference', 'api'); + +function currentMinor() { + const match = fs.readFileSync(path.join(ROOT, 'config.toml'), 'utf8').match(/^phel_version\s*=\s*"v?0\.(\d+)\.\d+"/m); + if (!match) throw new Error('config.toml has no phel_version'); + return Number(match[1]); +} + +function markdownFiles(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const abs = path.join(dir, entry.name); + if (abs === GENERATED) return []; + if (entry.isDirectory()) return markdownFiles(abs); + return entry.name.endsWith('.md') ? [abs] : []; + }); +} + +function split(source) { + const match = source.match(/^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+(?:\r?\n|$)/); + if (!match) return { frontMatter: '', body: source, bodyStart: 1 }; + return { frontMatter: match[1], body: source.slice(match[0].length), bodyStart: match[0].split('\n').length }; +} + +/** Body lines with their line number and whether they sit inside a code fence. */ +function lines(body, bodyStart) { + let fence = null; + return body.split('\n').map((text, i) => { + const marker = text.match(/^\s*(`{3,}|~{3,})(.*)$/); + const closes = + marker && fence !== null && marker[1][0] === fence[0] && marker[1].length >= fence.length && marker[2].trim() === ''; + const inCode = fence !== null || Boolean(marker); + if (closes) fence = null; + else if (marker && fence === null) fence = marker[1]; + return { text, line: bodyStart + i, inCode }; + }); +} + +function check(file, minor) { + const rel = path.relative(ROOT, file); + const { frontMatter, body, bodyStart } = split(fs.readFileSync(file, 'utf8')); + const problems = []; + const report = (line, message) => problems.push(`${rel}:${line} ${message}`); + + const description = frontMatter.match(/^description\s*=\s*"((?:[^"\\]|\\.)*)"/m); + if (!description) report(1, 'no description'); + else if (description[1].length < 50 || description[1].length > 160) { + report(1, `description is ${description[1].length} characters, keep it between 50 and 160`); + } + + const isLanguagePage = path.dirname(file) === path.join(DOCS, 'language') && path.basename(file) !== '_index.md'; + if (isLanguagePage && !/^difficulty\s*=/m.test(frontMatter)) report(1, 'no difficulty in [extra]'); + + const bodyLines = lines(body, bodyStart); + let seenLead = false; + let firstH2Checked = false; + for (const { text, line, inCode } of bodyLines) { + if (/;;\s*(=>|prints)/.test(text)) report(line, 'write `; =>` and `; prints`, with one semicolon'); + if (/phel/i.test(text) && rel !== path.join('content', 'documentation', 'reference', 'upgrading.md')) { + for (const [version, minorText] of text.matchAll(/(?/.test(text)) report(line, 'use {% %} instead of a blockquote'); + if (/^# /.test(text)) report(line, 'no H1 in the body: the template renders the title'); + if (/^## /.test(text) && !firstH2Checked) { + firstH2Checked = true; + if (!seenLead) report(line, 'add a lead paragraph before the first H2'); + } + if (text.trim() !== '' && !/^(#{1,6} |