aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/fixtures/c/directives.c7
-rw-r--r--tests/fixtures/c/inline_ref.c5
-rw-r--r--tests/fixtures/c/multi_chunk.c11
-rw-r--r--tests/fixtures/c/no_comments.c3
-rw-r--r--tests/fixtures/c/ref_string.c6
-rw-r--r--tests/fixtures/c/rst_exec.c8
-rw-r--r--tests/fixtures/c/shared_namespace.c11
-rw-r--r--tests/fixtures/c/simple.c5
-rw-r--r--tests/fixtures/c/src_repo.c5
-rw-r--r--tests/fixtures/c/toc.c5
-rw-r--r--tests/fixtures/lua/simple.lua5
-rw-r--r--tests/fixtures/lua/single_line.lua2
-rw-r--r--tests/fixtures/md/readme.md2
-rw-r--r--tests/fixtures/md/simple.md3
-rw-r--r--tests/fixtures/py/simple.py6
-rw-r--r--tests/fixtures/py/single_line.py2
-rw-r--r--tests/helpers/common.bash263
-rw-r--r--tests/helpers/profiler.bash53
-rw-r--r--tests/integration/cli.bats215
-rw-r--r--tests/integration/pipeline.bats842
-rw-r--r--tests/run_tests.sh57
-rw-r--r--tests/unit/directives.bats235
-rw-r--r--tests/unit/extraction.bats171
-rw-r--r--tests/unit/template_vars.bats160
24 files changed, 2082 insertions, 0 deletions
diff --git a/tests/fixtures/c/directives.c b/tests/fixtures/c/directives.c
new file mode 100644
index 0000000..4becc88
--- /dev/null
+++ b/tests/fixtures/c/directives.c
@@ -0,0 +1,7 @@
+/* md
+@name test/directives
+@file generated/source.c
+@ref test/directives-ref
+Directive test body content.
+*/
+void directives_demo() {}
diff --git a/tests/fixtures/c/inline_ref.c b/tests/fixtures/c/inline_ref.c
new file mode 100644
index 0000000..72ef395
--- /dev/null
+++ b/tests/fixtures/c/inline_ref.c
@@ -0,0 +1,5 @@
+/* md
+@name test/inline-ref
+See @lua/Coroutines for details on cooperative multitasking.
+*/
+void inline_ref_func() {}
diff --git a/tests/fixtures/c/multi_chunk.c b/tests/fixtures/c/multi_chunk.c
new file mode 100644
index 0000000..57fd463
--- /dev/null
+++ b/tests/fixtures/c/multi_chunk.c
@@ -0,0 +1,11 @@
+/* md
+@name test/alpha
+First chunk content.
+*/
+void foo() {}
+
+/* md
+@name test/beta
+Second chunk content.
+*/
+void bar() {}
diff --git a/tests/fixtures/c/no_comments.c b/tests/fixtures/c/no_comments.c
new file mode 100644
index 0000000..a667cef
--- /dev/null
+++ b/tests/fixtures/c/no_comments.c
@@ -0,0 +1,3 @@
+/* This is a regular comment, not a doc comment */
+// Another non-doc comment
+int noop() { return 42; }
diff --git a/tests/fixtures/c/ref_string.c b/tests/fixtures/c/ref_string.c
new file mode 100644
index 0000000..711ac20
--- /dev/null
+++ b/tests/fixtures/c/ref_string.c
@@ -0,0 +1,6 @@
+/* md
+@name test/ref-string
+@ref lua/Coroutines
+Return type: {{ ref_string }}
+*/
+void ref_string_func() {}
diff --git a/tests/fixtures/c/rst_exec.c b/tests/fixtures/c/rst_exec.c
new file mode 100644
index 0000000..3aabf9f
--- /dev/null
+++ b/tests/fixtures/c/rst_exec.c
@@ -0,0 +1,8 @@
+/* rst
+@name test/rst-exec
+RST Heading
+===========
+
+This chunk should be rendered by the rst renderer.
+*/
+int noop() { return 0; }
diff --git a/tests/fixtures/c/shared_namespace.c b/tests/fixtures/c/shared_namespace.c
new file mode 100644
index 0000000..25f064a
--- /dev/null
+++ b/tests/fixtures/c/shared_namespace.c
@@ -0,0 +1,11 @@
+/* md
+@name test/shared
+Part one of the shared page.
+*/
+void part_one() {}
+
+/* md
+@name test/shared
+Part two of the shared page.
+*/
+void part_two() {}
diff --git a/tests/fixtures/c/simple.c b/tests/fixtures/c/simple.c
new file mode 100644
index 0000000..2595929
--- /dev/null
+++ b/tests/fixtures/c/simple.c
@@ -0,0 +1,5 @@
+/* md
+@name test/simple
+Hello from a C comment.
+*/
+int main() { return 0; }
diff --git a/tests/fixtures/c/src_repo.c b/tests/fixtures/c/src_repo.c
new file mode 100644
index 0000000..fb0d8b6
--- /dev/null
+++ b/tests/fixtures/c/src_repo.c
@@ -0,0 +1,5 @@
+/* md
+@name test/src-repo
+Generated from {{ src_repo }}/src.c
+*/
+void src_repo_func() {}
diff --git a/tests/fixtures/c/toc.c b/tests/fixtures/c/toc.c
new file mode 100644
index 0000000..bb0782b
--- /dev/null
+++ b/tests/fixtures/c/toc.c
@@ -0,0 +1,5 @@
+/* md
+@name test/toc-chunk
+Navigation: {{ toc }}
+*/
+void toc_func() {}
diff --git a/tests/fixtures/lua/simple.lua b/tests/fixtures/lua/simple.lua
new file mode 100644
index 0000000..de527a9
--- /dev/null
+++ b/tests/fixtures/lua/simple.lua
@@ -0,0 +1,5 @@
+--[[ md
+@name test/lua-simple
+Hello from a Lua comment.
+]]
+local x = 1
diff --git a/tests/fixtures/lua/single_line.lua b/tests/fixtures/lua/single_line.lua
new file mode 100644
index 0000000..f541825
--- /dev/null
+++ b/tests/fixtures/lua/single_line.lua
@@ -0,0 +1,2 @@
+--[[ md This comment opens and closes on one line — no chunk. ]]
+local x = 1
diff --git a/tests/fixtures/md/readme.md b/tests/fixtures/md/readme.md
new file mode 100644
index 0000000..5833cab
--- /dev/null
+++ b/tests/fixtures/md/readme.md
@@ -0,0 +1,2 @@
+# Project Overview
+Hello from the readme.
diff --git a/tests/fixtures/md/simple.md b/tests/fixtures/md/simple.md
new file mode 100644
index 0000000..494ade7
--- /dev/null
+++ b/tests/fixtures/md/simple.md
@@ -0,0 +1,3 @@
+@name test/md-simple
+# Markdown Page
+Hello from a markdown file.
diff --git a/tests/fixtures/py/simple.py b/tests/fixtures/py/simple.py
new file mode 100644
index 0000000..b8e2ce9
--- /dev/null
+++ b/tests/fixtures/py/simple.py
@@ -0,0 +1,6 @@
+'''md
+@name test/py-simple
+Hello from a Python doc comment.
+'''
+def noop():
+ pass
diff --git a/tests/fixtures/py/single_line.py b/tests/fixtures/py/single_line.py
new file mode 100644
index 0000000..baafa10
--- /dev/null
+++ b/tests/fixtures/py/single_line.py
@@ -0,0 +1,2 @@
+'''This comment opens and closes on one line — no chunk.'''
+x = 1
diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash
new file mode 100644
index 0000000..779cc96
--- /dev/null
+++ b/tests/helpers/common.bash
@@ -0,0 +1,263 @@
+# tests/helpers/common.bash
+# Sourced by every bats test file via `load`.
+# Provides: workspace isolation, PATH stubs, assertion helpers.
+
+# ── workspace setup/teardown ────────────────────────────────────────────────
+
+# Call in setup(): creates an isolated temp directory and cds into it.
+# A stub for md2html is pre-installed in a fake bin/ on PATH.
+setup_workspace() {
+ TEST_TMPDIR="$(mktemp -d)"
+ # trap 'rm -rf $TEST_TMPDIR' EXIT
+ # Fake bin injected before the real PATH so stubs take priority
+ STUB_BIN="$TEST_TMPDIR/bin"
+ mkdir -p "$STUB_BIN"
+ install_stub_md2html "$STUB_BIN"
+ ORIG_DIR="$(pwd)"
+ export PATH="$STUB_BIN:$PATH"
+ # Work inside the temp dir so .trblcache is isolated
+ pushd "$TEST_TMPDIR" >/dev/null
+}
+
+# Call in teardown(): removes the temp directory.
+teardown_workspace() {
+ popd >/dev/null 2>&1 || true
+ rm -rf "$TEST_TMPDIR"
+}
+
+# ── stubs ───────────────────────────────────────────────────────────────────
+
+# md2html stub: echoes a predictable HTML marker for easy assertion.
+# Accepts any flags; always succeeds.
+install_stub_md2html() {
+ local bin_dir="$1"
+ cat > "$bin_dir/md2html" <<'EOF'
+#!/usr/bin/env bash
+# Stub: find the last non-flag argument as the input file, or read stdin.
+# Flags (args starting with -) from the md alias are ignored.
+input=""
+for arg in "$@"; do
+ [[ "$arg" == -* ]] || input="$arg"
+done
+if [[ -n "$input" ]]; then
+ content="$(cat "$input")"
+else
+ content="$(cat)"
+fi
+echo "<p class=\"stub-md\">$content</p>"
+EOF
+ chmod +x "$bin_dir/md2html"
+}
+
+
+# ── AWK program builders ─────────────────────────────────────────────────────
+# These mirror the comment_form patterns from trbldoc.sh so extraction
+# logic can be unit-tested without sourcing the whole script.
+
+CACHE_DIR=".trblcache"
+
+# Returns the AWK program string for C-style /* */ comments.
+awk_prog_c() {
+ local cache="$1"
+ cat <<EOF
+BEGIN{}
+/\*\// {
+ if(in_comment) {print "EOF"}
+ in_comment=0
+}
+in_comment {print}
+/\/\* .+/ && !/\*\// {
+ print "chunkfile=\$(mktemp -p ${cache}/chunks)"
+ print "cat > \$chunkfile << \"EOF\"\\n@file " name ":" NR ;
+ if(\$2) {\$1=""; print "@exec " \$0}
+ in_comment=1;
+}
+EOF
+}
+
+# Returns the AWK program string for Lua
+awk_prog_lua() {
+ local cache="$1"
+ cat <<EOF
+BEGIN{}
+/\]\]/ {
+ if(in_comment) {print "EOF"}
+ in_comment=0
+}
+in_comment {print}
+/--\[\[ .+/ && !/\]\]$/ {
+ print "chunkfile=\$(mktemp -p ${cache}/chunks)"
+ print "cat > \$chunkfile << \"EOF\"\\n@file " name ":" NR ;
+ if(\$2) {\$1=""; print "@exec " \$0}
+ in_comment=1;
+}
+EOF
+}
+
+# Returns the AWK program string for Python.
+# Handles '''renderer format (renderer glued directly to ''', no space required).
+awk_prog_py() {
+ local cache="$1"
+ sed "s|__PY_CACHE__|${cache}|g" << 'PYEOF'
+BEGIN{}
+/'''/ {
+ if(in_comment) {print "EOF"}
+ in_comment=0
+}
+in_comment {print}
+/'''.+/ && !/'''$/ {
+ print "chunkfile=$(mktemp -p __PY_CACHE__/chunks)"
+ print "cat > $chunkfile << \"EOF\"\n@file " name ":" NR ;
+ renderer = $0
+ sub(/'''/, "", renderer)
+ sub(/^[[:space:]]+/, "", renderer)
+ sub(/[[:space:]]+$/, "", renderer)
+ if (renderer != "") {print "@exec " renderer}
+ in_comment=1;
+}
+PYEOF
+}
+
+# Returns the AWK program string for .md files
+awk_prog_md() {
+ local cache="$1"
+ cat <<EOF
+BEGIN{
+ print "chunkfile=\$(mktemp -p ${cache}/chunks)"
+ print "cat > \$chunkfile << \"EOF\"\\n@file " name ":1\\n@exec md" ;
+}
+{print \$0}
+END{print "EOF"}
+EOF
+}
+
+# ── extraction helper ────────────────────────────────────────────────────────
+
+# run_extraction FILE AWK_PROG CACHE_DIR
+# Runs AWK against FILE, evals the output shell script, and populates CACHE_DIR/chunks.
+run_extraction() {
+ local file="$1"
+ local awk_prog="$2"
+ local cache="$3"
+ mkdir -p "$cache/chunks"
+ local chunk_script
+ chunk_script=$(awk -v name="$file" "$awk_prog" < "$file")
+ eval "$chunk_script"
+}
+
+# chunk_count CACHE_DIR
+# Prints the number of chunk files produced.
+chunk_count() {
+ find "$1/chunks" -type f 2>/dev/null | wc -l | tr -d ' '
+}
+
+# chunk_contains CACHE_DIR PATTERN
+# Succeeds if any chunk file contains a line matching PATTERN (grep -q).
+chunk_contains() {
+ grep -rl "$2" "$1/chunks" >/dev/null 2>&1
+}
+
+# ── assertion helpers ────────────────────────────────────────────────────────
+
+# assert_file_exists PATH
+assert_file_exists() {
+ if [[ ! -f "$1" ]]; then
+ echo "ASSERTION FAILED: expected file to exist: $1" >&2
+ return 1
+ fi
+}
+
+# assert_dir_exists PATH
+assert_dir_exists() {
+ if [[ ! -d "$1" ]]; then
+ echo "ASSERTION FAILED: expected directory to exist: $1" >&2
+ return 1
+ fi
+}
+
+# assert_contains FILE PATTERN
+assert_contains() {
+ if ! grep -q "$2" "$1" 2>/dev/null; then
+ echo "ASSERTION FAILED: '$2' not found in $1" >&2
+ echo "--- file contents ---" >&2
+ cat "$1" >&2
+ return 1
+ fi
+}
+
+# assert_not_contains FILE PATTERN
+assert_not_contains() {
+ if grep -q "$2" "$1" 2>/dev/null; then
+ echo "ASSERTION FAILED: '$2' unexpectedly found in $1" >&2
+ return 1
+ fi
+}
+
+# assert_equal ACTUAL EXPECTED
+assert_equal() {
+ if [[ "$1" != "$2" ]]; then
+ echo "ASSERTION FAILED: expected '$2', got '$1'" >&2
+ return 1
+ fi
+}
+
+# ── HTML validation helpers ──────────────────────────────────────────────────
+
+# assert_valid_html FILE
+# Validates FILE as a complete HTML5 document using tidy.
+# Mustache template markers ({{...}}, {{{...}}}) are substituted with
+# structurally valid HTML before linting to avoid false positives from
+# template syntax that tidy cannot parse.
+# Skips automatically if tidy is not installed.
+# tidy exit codes: 0 = clean, 1 = warnings only, 2 = errors.
+# This helper fails only on exit code 2 (actual errors).
+assert_valid_html() {
+ local file="$1"
+ if ! command -v tidy >/dev/null 2>&1; then
+ skip "tidy not installed (apt install tidy)"
+ fi
+ local tmp
+ tmp=$(mktemp --suffix=.html)
+ # Replace {{ var }} template markers with valid HTML equivalents so tidy
+ # sees a structurally correct document.
+ sed \
+ -e 's/{{[^}]*}}/placeholder/g' \
+ "$file" > "$tmp"
+ local tidy_out rc
+ tidy_out=$(tidy -errors -quiet --show-warnings no -utf8 "$tmp" 2>&1)
+ rc=$?
+ rm -f "$tmp"
+ if [ "$rc" -ge 2 ]; then
+ echo "HTML VALIDATION FAILED: $file" >&2
+ echo "$tidy_out" >&2
+ return 1
+ fi
+}
+
+# assert_valid_html_fragment FILE
+# Wraps FILE in a minimal HTML5 document shell, then validates with tidy.
+# Use for HTML fragment files (e.g. rendered chunk index.html output) that
+# are not complete documents on their own.
+# Skips automatically if tidy is not installed.
+assert_valid_html_fragment() {
+ local file="$1"
+ if ! command -v tidy >/dev/null 2>&1; then
+ skip "tidy not installed (apt install tidy)"
+ fi
+ local tmp
+ tmp=$(mktemp --suffix=.html)
+ {
+ echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>fragment</title></head><body>'
+ cat "$file"
+ echo '</body></html>'
+ } > "$tmp"
+ local tidy_out rc
+ tidy_out=$(tidy -errors -quiet --show-warnings no -utf8 "$tmp" 2>&1)
+ rc=$?
+ rm -f "$tmp"
+ if [ "$rc" -ge 2 ]; then
+ echo "HTML VALIDATION FAILED: $file" >&2
+ echo "$tidy_out" >&2
+ return 1
+ fi
+}
diff --git a/tests/helpers/profiler.bash b/tests/helpers/profiler.bash
new file mode 100644
index 0000000..c3d5207
--- /dev/null
+++ b/tests/helpers/profiler.bash
@@ -0,0 +1,53 @@
+# Profiler implementation
+profile() {
+ # Set default options
+ local file=profiler.log
+
+ # Open a file descriptor for writing to $file and save it in $tracefd
+ exec {tracefd}>"$file"
+ # Send trace output to $tracefd
+ export BASH_XTRACEFD="$tracefd"
+ # Print microsecond time in trace output
+ export PS4='+ $EPOCHREALTIME '
+ # Enable tracing, run script, and disable tracing
+ set -x
+ bash -x -- "$@"
+ set +x
+ # Un-redirect the trace output. This also closes the file descriptor.
+ unset BASH_XTRACEFD
+ export -n BASH_XTRACEFD PS4
+
+ # Remove "source" line from output and change last line to only include the
+ # timestamp with no name.
+ sed -i -e 1d -e '$s/\(+\+ [0-9\.]\+\) .*$/\1/' "$file"
+}
+
+analyze() {
+ # Set defaults
+ local file=profiler.log
+ local -a sortcmd=(cat) tablecmd=(cat)
+
+ # Declare variables
+ local timestamp nestlvl cmd next_timestamp next_nestlvl next_cmd duration
+ # Open file as a file descriptor so we can re-use the same stream
+ exec {fd}<"$file";
+ # Read first line
+ read -r nestlvl timestamp cmd <&"$fd"
+ # Process each line
+ while read -r next_nestlvl next_timestamp next_cmd
+ do
+ duration="$(echo "scale=6; $next_timestamp" - "$timestamp" | bc)"
+ # Prepend leading zero
+ if [ "${duration:0:1}" = . ]
+ then
+ duration="0$duration"
+ fi
+ echo "$duration" "$nestlvl" "$cmd"
+
+ timestamp="$next_timestamp"
+ nestlvl="$next_nestlvl"
+ cmd="$next_cmd"
+ done <&"$fd" | "${sortcmd[@]}" | "${tablecmd[@]}"
+ # Close file descriptor
+ exec {fd}<&-
+}
diff --git a/tests/integration/cli.bats b/tests/integration/cli.bats
new file mode 100644
index 0000000..5ffc55c
--- /dev/null
+++ b/tests/integration/cli.bats
@@ -0,0 +1,215 @@
+#!/usr/bin/env bats
+# tests/integration/cli.bats
+# Tests for trbldoc.sh command-line option parsing.
+#
+# Each test runs the full script (with md2html stubs) and asserts that
+# the correct sources are scanned and outputs produced, based on the options
+# passed.
+
+load "../helpers/common.bash"
+load "../helpers/profiler.bash"
+
+FIXTURES="$BATS_TEST_DIRNAME/../fixtures"
+_REPO_ROOT="${REPO_ROOT:-$(cd "$BATS_TEST_DIRNAME/../.." && pwd)}"
+TRBLDOC="$_REPO_ROOT/trbldoc.sh"
+
+setup() {
+ setup_workspace
+ CACHE=".trblcache"
+ NS="$CACHE/namespace"
+}
+
+teardown() {
+ teardown_workspace
+}
+
+# ── helpers ──────────────────────────────────────────────────────────────────
+
+run_pipeline() {
+ bash "$TRBLDOC" "$@" # 2>/dev/null
+ #profile "$TRBLDOC" "$@" 2>/dev/null
+ #analyze >> profile.txt
+}
+
+stage_fixture() {
+ local src="$1"
+ local dest="$2"
+ mkdir -p "$(dirname "$dest")"
+ cp -r "$src" "$dest"
+}
+
+# ── error cases ──────────────────────────────────────────────────────────────
+
+@test "cli: no arguments exits non-zero with an error message" {
+ run bash "$TRBLDOC" 2>&1
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"trbldoc:"* ]]
+}
+
+@test "cli: unknown option exits non-zero" {
+ run bash "$TRBLDOC" -z 2>&1
+ [ "$status" -ne 0 ]
+}
+
+@test "cli: option missing its required argument exits non-zero" {
+ run bash "$TRBLDOC" -s 2>&1
+ [ "$status" -ne 0 ]
+}
+
+# ── -s (source folder) ───────────────────────────────────────────────────────
+
+@test "cli: -s flag specifies the source folder to scan" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_dir_exists "$NS/test/simple"
+}
+
+@test "cli: multiple -s flags scan all specified folders" {
+ stage_fixture "$FIXTURES/c/simple.c" "src1/simple.c"
+ stage_fixture "$FIXTURES/c/multi_chunk.c" "src2/multi_chunk.c"
+ run_pipeline -s src1 -s src2
+ assert_dir_exists "$NS/test/simple"
+ assert_dir_exists "$NS/test/alpha"
+}
+
+# ── -o (output directory) ────────────────────────────────────────────────────
+
+@test "cli: -o flag creates the specified output directory" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src -o my_output
+ assert_dir_exists "my_output"
+ assert_file_exists "my_output/test/simple/index.html"
+}
+
+@test "cli: without -o the default output directory .trblcache/built is created" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_dir_exists "$CACHE/built"
+}
+
+# ── -f (custom file extension) ───────────────────────────────────────────────
+
+@test "cli: -f flag registers a custom file extension for scanning" {
+ mkdir -p src
+ # Create a .myext file with C-style doc comments.
+ # The -f option takes ext;start_regex;end_regex (AWK patterns without slashes).
+ cat > src/custom.myext <<'SRCEOF'
+/* md
+@name test/custom-ext
+Hello from a custom extension.
+*/
+int noop() {}
+SRCEOF
+ run_pipeline -s src -f 'myext;\/\*;\*\/'
+ assert_dir_exists "$NS/test/custom-ext"
+}
+
+@test "cli: -f registered extension produces correct chunk content" {
+ mkdir -p src
+ cat > src/custom.myext <<'SRCEOF'
+/* md
+@name test/custom-content
+Content from custom ext.
+*/
+int noop() {}
+SRCEOF
+ run_pipeline -s src -f 'myext;\/\*;\*\/'
+ assert_contains "$NS/test/custom-content/index.html" "Content from custom ext"
+}
+
+# ── TRBLDOC_MD (markdown renderer override) ──────────────────────────────────
+
+@test "cli: TRBLDOC_MD overrides the markdown renderer for .md files" {
+ mkdir -p src
+ cat > src/doc.md <<'EOF'
+@name test/md-override
+# Heading
+Some content.
+EOF
+ cat > custom_md.sh <<'EOF'
+#!/usr/bin/env bash
+# Stub: emit a recognisable marker instead of real HTML.
+cat "$@" > /dev/null
+echo "<p class=\"custom-md-renderer\">custom renderer was here</p>"
+EOF
+ chmod +x custom_md.sh
+ TRBLDOC_MD="./custom_md.sh" run_pipeline -s src
+ assert_contains "$NS/test/md-override/index.html" "custom-md-renderer"
+}
+
+@test "cli: TRBLDOC_MD override is not used when unset (default md2html stub runs)" {
+ stage_fixture "$FIXTURES/md/simple.md" "src/simple.md"
+ run_pipeline -s src
+ # The default stub wraps content in class=stub-md; confirm it ran.
+ assert_contains "$NS/test/md-simple/index.html" "stub-md"
+}
+
+# ── built-in site assembly ───────────────────────────────────────────────────
+
+@test "cli: built output is assembled by default without external zod" {
+ mkdir -p src
+ cat > src/simple.c <<'EOF'
+/* md
+@name test/simple
+Hello from a C comment.
+*/
+int main() { return 0; }
+EOF
+ run_pipeline -s src
+ assert_file_exists "$CACHE/built/test/simple/index.html"
+ assert_contains "$CACHE/built/test/simple/index.html" "<!DOCTYPE html>"
+ assert_contains "$CACHE/built/test/simple/index.html" "Hello from a C comment"
+ assert_not_contains "$CACHE/built/test/simple/index.html" "{{{yield}}}"
+}
+
+@test "cli: default main.layout includes stylesheet and excludes highlightjs mermaid and mathjax" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_contains "$NS/main.layout" '<link href="/stylesheets/style.css" rel="stylesheet">'
+ assert_not_contains "$NS/main.layout" "highlight.min.js"
+ assert_not_contains "$NS/main.layout" "highlightAll()"
+ assert_not_contains "$NS/main.layout" "mermaid.min.js"
+ assert_not_contains "$NS/main.layout" "mermaid.initialize"
+ assert_not_contains "$NS/main.layout" "mathjax"
+}
+
+@test "cli: -l uses the provided main.layout template" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ cat > alt.layout <<'EOF'
+<!DOCTYPE html>
+<html lang="en">
+<head><meta charset="utf-8"><title>{{ title }}</title></head>
+<body>
+<header>ALT {{ breadcrumb }}</header>
+<nav><ul>{{ nav }}</ul></nav>
+<main>{{ yield }}</main>
+</body>
+</html>
+EOF
+ run_pipeline -s src -l alt.layout
+ assert_contains "$NS/main.layout" "ALT {{ breadcrumb }}"
+ assert_not_contains "$NS/main.layout" "Generated by trbldoc"
+}
+
+@test "cli: -l fails when layout template file does not exist" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run bash "$TRBLDOC" -s src -l does-not-exist.layout 2>&1
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"layout template not found"* ]]
+}
+
+@test "cli: files without a registered extension are not scanned" {
+ mkdir -p src
+ # .xyz has no registered comment form; no output namespace should appear.
+ cat > src/ignored.xyz <<'SRCEOF'
+/* md
+@name test/should-not-exist
+This should not be processed.
+*/
+SRCEOF
+ run_pipeline -s src
+ if [[ -d "$NS/test/should-not-exist" ]]; then
+ echo "FAILED: .xyz file was unexpectedly processed" >&2
+ return 1
+ fi
+}
diff --git a/tests/integration/pipeline.bats b/tests/integration/pipeline.bats
new file mode 100644
index 0000000..2f43304
--- /dev/null
+++ b/tests/integration/pipeline.bats
@@ -0,0 +1,842 @@
+#!/usr/bin/env bats
+# tests/integration/pipeline.bats
+# End-to-end tests for trbldoc.sh.
+#
+# External markdown rendering is stubbed via helpers/common.bash.
+# Tests inspect both .trblcache/namespace intermediate output and the assembled
+# .trblcache/built pages produced by trbldoc itself.
+#
+# Tests marked `skip` document known bugs from todo.md. Remove `skip` after
+# the corresponding bug is fixed.
+
+load "../helpers/common.bash"
+
+FIXTURES="$BATS_TEST_DIRNAME/../fixtures"
+# REPO_ROOT is exported by run_tests.sh; fall back to relative path when bats
+# is invoked directly on this file.
+_REPO_ROOT="${REPO_ROOT:-$(cd "$BATS_TEST_DIRNAME/../.." && pwd)}"
+TRBLDOC="$_REPO_ROOT/trbldoc.sh"
+
+setup() {
+ setup_workspace
+ CACHE=".trblcache"
+ NS="$CACHE/namespace"
+}
+
+teardown() {
+ teardown_workspace
+}
+
+# ── helpers ─────────────────────────────────────────────────────────────────
+
+# Copy a fixture file into a temporary source tree and run the pipeline.
+# Usage: run_pipeline SRC_DIR [additional source dirs...]
+run_pipeline() {
+ bash "$TRBLDOC" "$@"
+}
+
+# Copy a fixture into a local source dir for a clean run.
+stage_fixture() {
+ local src="$1" # path to fixture file or dir
+ local dest="$2" # destination relative path in tmp workspace
+ mkdir -p "$(dirname "$dest")"
+ cp -r "$src" "$dest"
+}
+
+install_busybox_tool_wrapper() {
+ local tool="$1"
+ local busybox_bin="$2"
+ cat > "$STUB_BIN/$tool" <<EOF
+#!/bin/sh
+if [ -n "\${TRBLDOC_TEST_TOOL_LOG:-}" ]; then
+ printf '%s\n' "$tool" >> "\$TRBLDOC_TEST_TOOL_LOG"
+fi
+exec "$busybox_bin" $tool "\$@"
+EOF
+ chmod +x "$STUB_BIN/$tool"
+}
+
+# ── cache structure ──────────────────────────────────────────────────────────
+
+@test "pipeline: .trblcache directory is created on run" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+run_pipeline -s src
+ assert_dir_exists ".trblcache"
+}
+
+@test "pipeline: namespace subdirectory is created on run" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_dir_exists ".trblcache/namespace"
+}
+
+@test "pipeline: chunks subdirectory is created on run" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_dir_exists ".trblcache/chunks"
+}
+
+@test "pipeline: global.meta is created" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_file_exists "$NS/global.meta"
+}
+
+@test "pipeline: main.layout is created" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_file_exists "$NS/main.layout"
+}
+
+@test "pipeline: main.layout passes HTML validation" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_valid_html "$NS/main.layout"
+}
+
+@test "pipeline: main.layout has well-formed HTML structure" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ local layout="$NS/main.layout"
+ # <html> must appear before <head>
+ local html_line head_line body_line footer_line close_body_line
+ html_line=$(grep -n '<html' "$layout" | head -1 | cut -d: -f1)
+ head_line=$(grep -n '<head' "$layout" | head -1 | cut -d: -f1)
+ body_line=$(grep -n '<body' "$layout" | head -1 | cut -d: -f1)
+ footer_line=$(grep -n '<footer' "$layout" | head -1 | cut -d: -f1)
+ close_body_line=$(grep -n '</body>' "$layout" | head -1 | cut -d: -f1)
+ # <html> before <head>
+ [ "$html_line" -lt "$head_line" ]
+ # exactly one <head>
+ local head_count
+ head_count=$(grep -c '<head>' "$layout")
+ assert_equal "$head_count" "1"
+ # <footer> inside <body>: footer before </body>
+ [ "$footer_line" -lt "$close_body_line" ]
+}
+
+@test "pipeline: nav.partial is created" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_file_exists "$NS/nav.partial"
+}
+
+# ── namespace output ─────────────────────────────────────────────────────────
+
+@test "pipeline: named chunk creates namespace output folder" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_dir_exists "$NS/test/simple"
+}
+
+@test "pipeline: namespace output folder contains index.html" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_file_exists "$NS/test/simple/index.html"
+}
+
+@test "pipeline: built page exists for rendered namespace" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_file_exists "$CACHE/built/test/simple/index.html"
+}
+
+@test "pipeline: built page applies layout tokens and contains rendered body" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_contains "$CACHE/built/test/simple/index.html" "<!DOCTYPE html>"
+ assert_contains "$CACHE/built/test/simple/index.html" "<title>test/simple</title>"
+ assert_contains "$CACHE/built/test/simple/index.html" "Hello from a C comment"
+ assert_contains "$CACHE/built/test/simple/index.html" "href='/test/simple'"
+}
+
+@test "pipeline: index.html contains rendered chunk body" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_contains "$NS/test/simple/index.html" "Hello from a C comment"
+}
+
+@test "pipeline: runs with BusyBox awk and xargs from PATH" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+
+ if ! command -v busybox >/dev/null 2>&1; then
+ skip "busybox is not installed"
+ fi
+
+ local busybox_bin
+ busybox_bin="$(command -v busybox)"
+ export TRBLDOC_TEST_TOOL_LOG="$TEST_TMPDIR/busybox-tools.log"
+ : > "$TRBLDOC_TEST_TOOL_LOG"
+
+ install_busybox_tool_wrapper awk "$busybox_bin"
+ install_busybox_tool_wrapper xargs "$busybox_bin"
+
+ run bash "$TRBLDOC" -s src 2>&1
+ [ "$status" -eq 0 ]
+
+ assert_file_exists "$NS/test/simple/index.html"
+ assert_contains "$NS/test/simple/index.html" "Hello from a C comment"
+
+ local awk_count xargs_count
+ awk_count=$(grep -c '^awk$' "$TRBLDOC_TEST_TOOL_LOG" || true)
+ xargs_count=$(grep -c '^xargs$' "$TRBLDOC_TEST_TOOL_LOG" || true)
+ [ "$awk_count" -gt 0 ]
+ [ "$xargs_count" -gt 0 ]
+}
+
+@test "pipeline: rendered namespace index.html passes HTML validation" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_valid_html_fragment "$NS/test/simple/index.html"
+}
+
+@test "pipeline: multi-chunk file creates two separate namespace folders" {
+ stage_fixture "$FIXTURES/c/multi_chunk.c" "src/multi_chunk.c"
+ run_pipeline -s src
+ assert_dir_exists "$NS/test/alpha"
+ assert_dir_exists "$NS/test/beta"
+}
+
+@test "pipeline: each namespace folder has its own index.html" {
+ stage_fixture "$FIXTURES/c/multi_chunk.c" "src/multi_chunk.c"
+ run_pipeline -s src
+ assert_file_exists "$NS/test/alpha/index.html"
+ assert_file_exists "$NS/test/beta/index.html"
+}
+
+@test "pipeline: each namespace index.html contains the correct body" {
+ stage_fixture "$FIXTURES/c/multi_chunk.c" "src/multi_chunk.c"
+ run_pipeline -s src
+ assert_contains "$NS/test/alpha/index.html" "First chunk content"
+ assert_contains "$NS/test/beta/index.html" "Second chunk content"
+}
+
+@test "pipeline: two chunks with same @name both appear in the shared index.html" {
+ stage_fixture "$FIXTURES/c/shared_namespace.c" "src/shared_namespace.c"
+ run_pipeline -s src
+ assert_contains "$NS/test/shared/index.html" "Part one"
+ assert_contains "$NS/test/shared/index.html" "Part two"
+}
+
+@test "pipeline: @priority orders chunks before namespace/source ordering" {
+ mkdir -p src
+ cat > src/priority_high.c <<'EOF'
+/* md
+@name test/priority-order
+@priority 100
+Priority high
+*/
+int high() { return 0; }
+EOF
+ cat > src/priority_low.c <<'EOF'
+/* md
+@name test/priority-order
+@priority 1
+Priority low
+*/
+int low() { return 0; }
+EOF
+ cat > src/priority_default.c <<'EOF'
+/* md
+@name test/priority-order
+Priority default
+*/
+int def() { return 0; }
+EOF
+ run_pipeline -s src
+ local out="$NS/test/priority-order/index.html"
+ local line_high line_low line_default first_render second_render
+ line_high=$(grep -n "Priority high" "$out" | head -1 | cut -d: -f1)
+ line_low=$(grep -n "Priority low" "$out" | head -1 | cut -d: -f1)
+ line_default=$(grep -n "Priority default" "$out" | head -1 | cut -d: -f1)
+ [ "$line_high" -lt "$line_low" ]
+ [ "$line_low" -lt "$line_default" ]
+ first_render="$(cat "$out")"
+ run_pipeline -s src
+ second_render="$(cat "$out")"
+ assert_equal "$first_render" "$second_render"
+}
+
+@test "pipeline: Lua file chunk creates correct namespace output" {
+ stage_fixture "$FIXTURES/lua/simple.lua" "src/simple.lua"
+ run_pipeline -s src
+ assert_file_exists "$NS/test/lua-simple/index.html"
+}
+
+@test "pipeline: Python file chunk creates correct namespace output" {
+ stage_fixture "$FIXTURES/py/simple.py" "src/simple.py"
+ run_pipeline -s src
+ assert_file_exists "$NS/test/py-simple/index.html"
+}
+
+@test "pipeline: Python chunk body appears in namespace output" {
+ stage_fixture "$FIXTURES/py/simple.py" "src/simple.py"
+ run_pipeline -s src
+ assert_contains "$NS/test/py-simple/index.html" "Hello from a Python doc comment"
+}
+
+@test "pipeline: Markdown file chunk creates correct namespace output" {
+ stage_fixture "$FIXTURES/md/simple.md" "src/simple.md"
+ run_pipeline -s src
+ assert_file_exists "$NS/test/md-simple/index.html"
+}
+
+@test "pipeline: @exec rst chunk is rendered using TRBLDOC_RST override" {
+ stage_fixture "$FIXTURES/c/rst_exec.c" "src/rst_exec.c"
+ cat > custom_rst.sh <<'EOF'
+#!/usr/bin/env bash
+if [[ $# -gt 0 ]]; then
+ content="$(cat "$1")"
+else
+ content="$(cat)"
+fi
+echo "<section class=\"stub-rst\">$content</section>"
+EOF
+ chmod +x custom_rst.sh
+ TRBLDOC_RST="./custom_rst.sh" run_pipeline -s src
+ assert_file_exists "$NS/test/rst-exec/index.html"
+ assert_contains "$NS/test/rst-exec/index.html" "stub-rst"
+ assert_contains "$NS/test/rst-exec/index.html" "RST Heading"
+}
+
+@test "pipeline: file with no doc comments creates no namespace folders" {
+ stage_fixture "$FIXTURES/c/no_comments.c" "src/no_comments.c"
+ run_pipeline -s src
+ # Only the fixed namespace files (global.meta, nav.partial, etc.) should exist;
+ # no test/* content namespace folder should be present.
+ if [[ -d "$NS/test" ]]; then
+ echo "FAILED: unexpected namespace folder created for file with no doc comments" >&2
+ ls "$NS/test" >&2
+ return 1
+ fi
+}
+
+# ── global.meta ─────────────────────────────────────────────────────────────
+
+@test "pipeline: global.meta contains entry for the processed namespace" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_contains "$NS/global.meta" "test/simple"
+}
+
+# ── nav.partial ──────────────────────────────────────────────────────────────
+
+@test "pipeline: nav.partial contains link for processed namespace" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_contains "$NS/nav.partial" "test/simple"
+}
+
+@test "pipeline: nav.partial does not contain duplicate entries" {
+ stage_fixture "$FIXTURES/c/shared_namespace.c" "src/shared_namespace.c"
+ run_pipeline -s src
+ # Count occurrences of the shared namespace link; must be exactly 1.
+ local count
+ count=$(grep -c "test/shared" "$NS/nav.partial" || true)
+ assert_equal "$count" "1"
+}
+
+@test "pipeline: nav.partial groups related namespaces as a tree" {
+ mkdir -p doc
+ cat > doc/doc.md <<'EOF'
+# Doc
+EOF
+ cat > doc/index.md <<'EOF'
+# Index
+EOF
+ cat > doc/love_loader.md <<'EOF'
+# Love Loader
+EOF
+ cat > doc/ui_notes.md <<'EOF'
+# UI Notes
+EOF
+ run_pipeline -s doc
+ assert_contains "$NS/nav.partial" "<li>doc/<ul>"
+ assert_contains "$NS/nav.partial" "href='/doc/doc.md'>doc.md</a>"
+ assert_contains "$NS/nav.partial" "href='/doc/index.md'>index.md</a>"
+ assert_contains "$NS/nav.partial" "href='/doc/love_loader.md'>love_loader.md</a>"
+ assert_contains "$NS/nav.partial" "href='/doc/ui_notes.md'>ui_notes.md</a>"
+}
+
+@test "pipeline: dotted @name values are allowed" {
+ mkdir -p src
+ cat > src/dotted.c <<'EOF'
+/* md
+@name ui.element.Element
+Dotted namespace.
+*/
+int dotted() { return 0; }
+EOF
+ run_pipeline -s src
+ assert_dir_exists "$NS/ui.element.Element"
+ assert_contains "$NS/nav.partial" "href='/ui.element.Element'"
+}
+
+@test "pipeline: explicit slash-separated @name builds correct nav tree" {
+ mkdir -p src
+ cat > src/ui_parent.c <<'EOF'
+/* md
+@name ui
+UI root documentation.
+*/
+int ui_root() { return 0; }
+EOF
+ cat > src/ui_element.c <<'EOF'
+/* md
+@name ui/element/Element
+Element details.
+*/
+int ui_element() { return 0; }
+EOF
+ run_pipeline -s src
+ assert_contains "$NS/nav.partial" "href='/ui'>ui</a>/<ul>"
+ assert_contains "$NS/nav.partial" "href='/ui/element/Element'>Element</a>"
+ assert_dir_exists "$NS/ui/element/Element"
+}
+
+# ── inline reference resolution ─────────────────────────────────────────────
+
+@test "pipeline: @lua/<path> tokens are resolved to Markdown links" {
+ stage_fixture "$FIXTURES/c/inline_ref.c" "src/inline_ref.c"
+ run_pipeline -s src
+ # ref_resolvers/lua.sh "Coroutines" returns https://www.lua.org/manual/5.2/manual.html#2.6
+ assert_contains "$NS/test/inline-ref/index.html" "lua.org/manual"
+ assert_not_contains "$NS/test/inline-ref/index.html" "@lua/Coroutines"
+}
+
+@test "pipeline: -R adds a custom resolver prefix" {
+ mkdir -p src
+ cat > src/custom_ref.c <<'EOF'
+/* md
+@name test/custom-ref
+Link: @custom/topic
+*/
+int noop() {}
+EOF
+ cat > custom_resolver.sh <<'EOF'
+#!/usr/bin/env bash
+opt="$1"
+path="$(cat)"
+printf "https://example.test/%s/%s" "$opt" "$path"
+EOF
+ chmod +x custom_resolver.sh
+ run_pipeline -s src -R 'custom=./custom_resolver.sh;modeA'
+ assert_contains "$NS/test/custom-ref/index.html" "https://example.test/modeA/topic"
+ assert_not_contains "$NS/test/custom-ref/index.html" "@custom/topic"
+}
+
+@test "pipeline: -R overrides an existing resolver prefix" {
+ mkdir -p src
+ cat > src/lua_override.c <<'EOF'
+/* md
+@name test/lua-override
+Link: @lua/Coroutines
+*/
+int noop() {}
+EOF
+ cat > lua_override.sh <<'EOF'
+#!/usr/bin/env bash
+path="$(cat)"
+printf "https://override.test/%s" "$path"
+EOF
+ chmod +x lua_override.sh
+ run_pipeline -s src -R 'lua=./lua_override.sh'
+ assert_contains "$NS/test/lua-override/index.html" "https://override.test/Coroutines"
+ assert_not_contains "$NS/test/lua-override/index.html" "lua.org/manual"
+}
+
+@test "pipeline: @exec renderer receives TRBLCACHE environment variable (repro)" {
+ mkdir -p src
+ cat > src/renderer_cache_env.c <<'EOF'
+/* ./require_trblcache.sh
+@name test/renderer-cache-env
+Renderer env test.
+*/
+int noop() { return 0; }
+EOF
+ cat > require_trblcache.sh <<'EOF'
+#!/usr/bin/env bash
+if [[ -z "${TRBLCACHE:-}" ]]; then
+ echo "TRBLCACHE is missing in renderer" >&2
+ exit 23
+fi
+echo "<p>cache=$TRBLCACHE</p>"
+EOF
+ chmod +x require_trblcache.sh
+ run bash "$TRBLDOC" -s src 2>&1
+ [ "$status" -eq 0 ]
+ assert_contains "$NS/test/renderer-cache-env/index.html" "cache=./.trblcache"
+ [[ "$output" != *"TRBLCACHE is missing in renderer"* ]]
+ [[ "$output" != *"renderer failed (exit 23)"* ]]
+}
+
+@test "pipeline: @exec renderer receives FILEPATH environment variable (repro)" {
+ mkdir -p src
+ cat > src/renderer_filepath_env.c <<'EOF'
+/* ./require_filepath.sh
+@name test/renderer-filepath-env
+Renderer FILEPATH env test.
+*/
+int noop() { return 0; }
+EOF
+ cat > require_filepath.sh <<'EOF'
+#!/usr/bin/env bash
+if [[ -z "${FILEPATH:-}" ]]; then
+ echo "FILEPATH is missing in renderer" >&2
+ exit 24
+fi
+if [[ "${FILEPATH}" != "src/renderer_filepath_env.c" ]]; then
+ echo "FILEPATH is incorrect in renderer: $FILEPATH" >&2
+ exit 25
+fi
+echo "<p>filepath=$FILEPATH</p>"
+EOF
+ chmod +x require_filepath.sh
+ run bash "$TRBLDOC" -s src 2>&1
+ [ "$status" -eq 0 ]
+ assert_contains "$NS/test/renderer-filepath-env/index.html" "filepath=src/renderer_filepath_env.c"
+ [[ "$output" != *"FILEPATH is missing in renderer"* ]]
+ [[ "$output" != *"FILEPATH is incorrect in renderer"* ]]
+ [[ "$output" != *"renderer failed (exit 24)"* ]]
+ [[ "$output" != *"renderer failed (exit 25)"* ]]
+}
+
+@test "pipeline: @exec renderer receives LINENUM environment variable (repro)" {
+ mkdir -p src
+ cat > src/renderer_linenum_env.c <<'EOF'
+int prelude = 0;
+
+/* ./require_linenum.sh
+@name test/renderer-linenum-env
+Renderer LINENUM env test.
+*/
+int noop() { return 0; }
+EOF
+ cat > require_linenum.sh <<'EOF'
+#!/usr/bin/env bash
+if [[ -z "${LINENUM:-}" ]]; then
+ echo "LINENUM is missing in renderer" >&2
+ exit 26
+fi
+if [[ "${LINENUM}" != "3" ]]; then
+ echo "LINENUM is incorrect in renderer: $LINENUM" >&2
+ exit 27
+fi
+echo "<p>linenum=$LINENUM</p>"
+EOF
+ chmod +x require_linenum.sh
+ run bash "$TRBLDOC" -s src 2>&1
+ [ "$status" -eq 0 ]
+ assert_contains "$NS/test/renderer-linenum-env/index.html" "linenum=3"
+ [[ "$output" != *"LINENUM is missing in renderer"* ]]
+ [[ "$output" != *"LINENUM is incorrect in renderer"* ]]
+ [[ "$output" != *"renderer failed (exit 26)"* ]]
+ [[ "$output" != *"renderer failed (exit 27)"* ]]
+}
+@test "pipeline: inline @exec function receives TRBLCACHE FILEPATH and LINENUM (repro)" {
+ mkdir -p src
+ cat > src/renderer_inline_function_env.c <<'EOF'
+/* render_inline(){ if [[ -z "${TRBLCACHE:-}" || -z "${FILEPATH:-}" || -z "${LINENUM:-}" ]]; then echo "missing env in inline renderer function" >&2; return 28; fi; printf "<p>cache=%s filepath=%s linenum=%s</p>\n" "$TRBLCACHE" "$FILEPATH" "$LINENUM"; }; render_inline
+@name test/renderer-inline-function-env
+Renderer inline function env test.
+*/
+int noop() { return 0; }
+EOF
+ run bash "$TRBLDOC" -s src 2>&1
+ [ "$status" -eq 0 ]
+ assert_contains "$NS/test/renderer-inline-function-env/index.html" "cache=./.trblcache"
+ assert_contains "$NS/test/renderer-inline-function-env/index.html" "filepath=src/renderer_inline_function_env.c"
+ assert_contains "$NS/test/renderer-inline-function-env/index.html" "linenum=1"
+ [[ "$output" != *"missing env in inline renderer function"* ]]
+ [[ "$output" != *"renderer failed (exit 28)"* ]]
+}
+
+@test "pipeline: @name tokens resolve to internal refs before external resolvers" {
+ # Internal refs: @<name> resolves to the first chunk with @ref <name>
+ mkdir -p src
+ cat > src/target.c <<'EOF'
+/* md
+@name api/target
+@ref my-target
+Target documentation.
+*/
+int target() {}
+EOF
+ cat > src/source.c <<'EOF'
+/* md
+@name api/source
+See also: @my-target for details.
+*/
+int source() {}
+EOF
+ run_pipeline -s src
+ # @my-target should resolve to the internal link from global.meta
+ assert_contains "$NS/api/source/index.html" "href='/api/target#my-target'"
+ assert_not_contains "$NS/api/source/index.html" "@my-target"
+}
+
+# ── template variable substitution ─────────────────────────────────────────
+
+@test "pipeline: {{ src_repo }} is substituted with TRBLDOC_SRC_REPO value" {
+ stage_fixture "$FIXTURES/c/src_repo.c" "src/src_repo.c"
+ TRBLDOC_SRC_REPO="https://example.com/repo" run_pipeline -s src
+ assert_contains "$NS/test/src-repo/index.html" "https://example.com/repo"
+ assert_not_contains "$NS/test/src-repo/index.html" '{{ src_repo }}'
+}
+
+@test "pipeline: {{ ref_string }} is substituted with the resolved reference link" {
+ stage_fixture "$FIXTURES/c/ref_string.c" "src/ref_string.c"
+ run_pipeline -s src
+ # @ref lua/Coroutines is resolved via ref_resolvers/lua.sh; the output
+ # contains the full ref name as link text and the resolved URL.
+ assert_contains "$NS/test/ref-string/index.html" "lua/Coroutines"
+ assert_contains "$NS/test/ref-string/index.html" "lua.org/manual"
+ assert_not_contains "$NS/test/ref-string/index.html" '{{ ref_string }}'
+}
+@test "pipeline: From attribution is linked to source when TRBLDOC_SRC_REPO is set" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ TRBLDOC_SRC_REPO="https://example.com/repo/blob/main" run_pipeline -s src
+ assert_contains "$NS/test/simple/index.html" "From <a href='https://example.com/repo/blob/main/src/simple.c#L1'>src/simple.c:1</a>"
+}
+
+@test "pipeline: {{ toc }} is substituted with nav links after rendering" {
+ stage_fixture "$FIXTURES/c/toc.c" "src/toc.c"
+ run_pipeline -s src
+ # After substitution, the chunk output should contain the nav link for the
+ # namespace itself, and the literal marker should be gone.
+ assert_contains "$NS/test/toc-chunk/index.html" "/test/toc-chunk"
+ assert_not_contains "$NS/test/toc-chunk/index.html" '{{ toc }}'
+}
+
+@test "pipeline: main.layout uses {{ title }} two-bracket format" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_contains "$NS/main.layout" '{{ title }}'
+ assert_not_contains "$NS/main.layout" '{{title}}'
+}
+
+@test "pipeline: main.layout uses {{ nav }} format instead of {{> nav}}" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_contains "$NS/main.layout" '{{ nav }}'
+ assert_not_contains "$NS/main.layout" '{{> nav}}'
+}
+
+@test "pipeline: main.layout uses {{ yield }} format instead of {{{yield}}}" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_contains "$NS/main.layout" '{{ yield }}'
+ assert_not_contains "$NS/main.layout" '{{{yield}}}'
+}
+
+@test "pipeline: main.layout uses {{ breadcrumb }} format" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_contains "$NS/main.layout" '{{ breadcrumb }}'
+ assert_not_contains "$NS/main.layout" '{{breadcrumb}}'
+}
+
+@test "pipeline: old-style {{src_repo}} without spaces is not substituted" {
+ mkdir -p src
+ cat > src/old_fmt.c <<'EOF'
+/* md
+@name test/old-fmt-src-repo
+Old format: {{src_repo}}
+*/
+void f() {}
+EOF
+ TRBLDOC_SRC_REPO="https://example.com" run_pipeline -s src
+ assert_contains "$NS/test/old-fmt-src-repo/index.html" '{{src_repo}}'
+}
+
+@test "pipeline: old-style {{toc}} without spaces is not substituted" {
+ mkdir -p src
+ cat > src/old_fmt_toc.c <<'EOF'
+/* md
+@name test/old-fmt-toc
+Old format: {{toc}}
+*/
+void f() {}
+EOF
+ run_pipeline -s src
+ assert_contains "$NS/test/old-fmt-toc/index.html" '{{toc}}'
+}
+
+# ── known defects (skipped – convert to active tests after bug fixes) ────────
+
+@test "pipeline: chunk without @name uses source file path as namespace" {
+ # A chunk with no @name should use its own @file value (the source file path),
+ # not whatever file was last iterated in the scan loop.
+ mkdir -p src
+ cat > src/anon.c <<'EOF'
+/* md
+Chunk with no @name directive.
+*/
+void anon() {}
+EOF
+ run_pipeline -s src
+ # Expect a namespace folder derived from src/anon.c.
+ assert_dir_exists "$NS/src/anon.c"
+}
+
+@test "pipeline: second run does not duplicate nav entries" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ run_pipeline -s src # second run
+ local count
+ count=$(grep -c "test/simple" "$NS/nav.partial" || true)
+ assert_equal "$count" "1"
+}
+
+# ── logic bug regression tests ────────────────────────────────────────────
+
+@test "pipeline: source folder with spaces in name is discovered via -s" {
+ # Bug: infolders is a space-separated string; folder names with spaces break discovery.
+ mkdir -p "src folder"
+ stage_fixture "$FIXTURES/c/simple.c" "src folder/simple.c"
+ run_pipeline -s "src folder"
+ assert_dir_exists "$NS/test/simple"
+}
+
+@test "pipeline: source filename with spaces is processed correctly" {
+ # Bug: source_files and chunk_files use unquoted iteration which breaks on spaces.
+ # This fixture has no @name directive, so namespace is derived from source path.
+ mkdir -p src
+ cat > 'src/simple file.c' <<'EOF'
+/* md
+Hello from a C comment.
+*/
+int main() { return 0; }
+EOF
+ run_pipeline -s src
+ # Namespace is derived from source file path (no @name means use file path)
+ assert_file_exists "$NS/src/simple file.c/index.html"
+ assert_contains "$NS/src/simple file.c/index.html" "Hello from a C comment"
+}
+
+@test "pipeline: nav entries with ampersands in {{ toc }} are substituted correctly" {
+ # Bug: toc_oneline sed substitution treats & as matched text.
+ mkdir -p src
+ cat > 'src/a.c' <<'EOF'
+/* md
+@name test/a
+Navigation: {{ toc }}
+*/
+int a() {}
+EOF
+ cat > 'src/b.c' <<'EOF'
+/* md
+@name test/b&c
+Chunk with ampersand.
+*/
+int b() {}
+EOF
+ run_pipeline -s src
+ # Verify the nav.partial contains the entries
+ assert_contains "$NS/nav.partial" "test/b&c"
+ # Verify the rendered output preserves this (not garbled by sed)
+ assert_contains "$NS/test/a/index.html" "test/b&c"
+ assert_not_contains "$NS/test/a/index.html" '{{ toc }}'
+}
+
+# ── readme.md → top-level index ─────────────────────────────────────────────
+
+@test "pipeline: readme.md creates root-level index.html" {
+ stage_fixture "$FIXTURES/md/readme.md" "src/readme.md"
+ run_pipeline -s src
+ assert_file_exists "$NS/index.html"
+}
+
+@test "pipeline: readme.md content appears in root index.html" {
+ stage_fixture "$FIXTURES/md/readme.md" "src/readme.md"
+ run_pipeline -s src
+ assert_contains "$NS/index.html" "Hello from the readme"
+}
+
+@test "pipeline: README.md (uppercase) creates root-level index.html" {
+ mkdir -p src
+ cat > src/README.md <<'EOF'
+# Overview
+Uppercase README content.
+EOF
+ run_pipeline -s src
+ assert_file_exists "$NS/index.html"
+ assert_contains "$NS/index.html" "Uppercase README content"
+}
+
+@test "pipeline: readme.md with explicit @name uses custom namespace, not root" {
+ mkdir -p src
+ cat > src/readme.md <<'EOF'
+@name custom/readme
+# Custom Name
+Content with custom name.
+EOF
+ run_pipeline -s src
+ assert_file_exists "$NS/custom/readme/index.html"
+ if [[ -f "$NS/index.html" ]]; then
+ echo "ASSERTION FAILED: unexpected $NS/index.html created for readme with @name" >&2
+ return 1
+ fi
+}
+
+@test "pipeline: root index.html from readme.md contains rendered HTML tags" {
+ stage_fixture "$FIXTURES/md/readme.md" "src/readme.md"
+ run_pipeline -s src
+ assert_contains "$NS/index.html" "<p"
+}
+
+@test "pipeline: root index.html from readme.md passes HTML fragment validation" {
+ stage_fixture "$FIXTURES/md/readme.md" "src/readme.md"
+ run_pipeline -s src
+ assert_valid_html_fragment "$NS/index.html"
+}
+
+@test "pipeline: nav.partial contains root href when readme.md is present" {
+ stage_fixture "$FIXTURES/md/readme.md" "src/readme.md"
+ run_pipeline -s src
+ assert_contains "$NS/nav.partial" "href='/'"
+}
+
+@test "pipeline: global.meta contains entry for index when readme.md is present" {
+ stage_fixture "$FIXTURES/md/readme.md" "src/readme.md"
+ run_pipeline -s src
+ assert_contains "$NS/global.meta" "index:"
+}
+
+@test "pipeline: readme.md alongside other source files produces both root index and other pages" {
+ stage_fixture "$FIXTURES/md/readme.md" "src/readme.md"
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ assert_file_exists "$NS/index.html"
+ assert_file_exists "$NS/test/simple/index.html"
+ assert_contains "$NS/index.html" "Hello from the readme"
+ assert_contains "$NS/test/simple/index.html" "Hello from a C comment"
+}
+
+@test "pipeline: root index.html source attribution points to readme.md" {
+ stage_fixture "$FIXTURES/md/readme.md" "src/readme.md"
+ run_pipeline -s src
+ assert_contains "$NS/index.html" "readme.md"
+}
+
+@test "pipeline: readme.md is assembled to the built root, not a subdirectory" {
+ stage_fixture "$FIXTURES/md/readme.md" "src/readme.md"
+ run_pipeline -s src
+ # Must land at the built root, not inside an "index" subdirectory.
+ assert_file_exists ".trblcache/built/index.html"
+ if [[ -f ".trblcache/built/index/index.html" ]]; then
+ echo "ASSERTION FAILED: readme was built to built/index/index.html instead of built/index.html" >&2
+ return 1
+ fi
+}
+
+# ── known defects
+
+@test "pipeline: second run does not duplicate index.html content" {
+ stage_fixture "$FIXTURES/c/simple.c" "src/simple.c"
+ run_pipeline -s src
+ run_pipeline -s src
+ local count
+ count=$(grep -c "Hello from a C comment" "$NS/test/simple/index.html" || true)
+ assert_equal "$count" "1"
+}
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
new file mode 100644
index 0000000..4569be3
--- /dev/null
+++ b/tests/run_tests.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# tests/run_tests.sh
+# Entry point for the trbldoc test suite.
+# Requires bats-core: https://github.com/bats-core/bats-core
+#
+# Quick install (pick one):
+# npm install -g bats
+# brew install bats-core
+# git clone https://github.com/bats-core/bats-core ~/.bats && ~/.bats/install.sh /usr/local
+#
+# Usage:
+# bash tests/run_tests.sh # run all tests
+# bash tests/run_tests.sh unit # run only unit tests
+# bash tests/run_tests.sh integration # run only integration tests
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME:-${BASH_SOURCE[0]}}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+# ── bats check ──────────────────────────────────────────────────────────────
+if ! command -v bats &>/dev/null; then
+ echo "ERROR: bats is not installed or not on PATH." >&2
+ echo "Install with one of:" >&2
+ echo " npm install -g bats" >&2
+ echo " brew install bats-core" >&2
+ echo " git clone https://github.com/bats-core/bats-core ~/.bats && ~/.bats/install.sh /usr/local" >&2
+ exit 1
+fi
+
+# ── suite selection ──────────────────────────────────────────────────────────
+TARGET="${1:-all}"
+
+case "$TARGET" in
+ unit)
+ SUITE_DIRS=("$SCRIPT_DIR/unit")
+ ;;
+ integration)
+ SUITE_DIRS=("$SCRIPT_DIR/integration")
+ ;;
+ all)
+ SUITE_DIRS=("$SCRIPT_DIR/unit" "$SCRIPT_DIR/integration")
+ ;;
+ *)
+ echo "Usage: $0 [all|unit|integration]" >&2
+ exit 1
+ ;;
+esac
+
+# Export so bats test files can locate project files
+export REPO_ROOT
+
+echo "Running trbldoc test suite (target: $TARGET)"
+echo "bats $(bats --version)"
+echo ""
+
+bats --print-output-on-failure "${SUITE_DIRS[@]}"
diff --git a/tests/unit/directives.bats b/tests/unit/directives.bats
new file mode 100644
index 0000000..3a57951
--- /dev/null
+++ b/tests/unit/directives.bats
@@ -0,0 +1,235 @@
+#!/usr/bin/env bats
+# tests/unit/directives.bats
+# Verifies that chunk directive parsing (the awk one-liners used in the chunk
+# loop of trbldoc.sh) correctly reads @name, @exec, @file, and @ref, and that
+# the sed strip pass removes directives from chunk bodies without touching
+# non-directive body lines.
+
+load "../helpers/common.bash"
+
+FIXTURES="$BATS_TEST_DIRNAME/../fixtures"
+
+setup() {
+ setup_workspace
+ CACHE=".trblcache"
+ mkdir -p "$CACHE/chunks"
+}
+
+teardown() {
+ teardown_workspace
+}
+
+# ── helpers used in this file ───────────────────────────────────────────────
+
+# Write a chunk file directly (bypassing extraction) so directive tests
+# are independent of extraction correctness.
+make_chunk() {
+ local path="$1"
+ shift
+ mkdir -p "$(dirname "$path")"
+ printf '%s\n' "$@" > "$path"
+}
+
+# Read @name from a chunk file (mirrors trbldoc.sh logic).
+parse_name() {
+ awk '/@name .+/ {print $2}' "$1"
+}
+
+# Read @exec from a chunk file.
+parse_exec() {
+ awk '/@exec .+/ {$1=""; print}' "$1" | sed 's/^ //'
+}
+
+# Read @file from a chunk file.
+parse_file() {
+ awk '/@file .+/ {print $2}' "$1"
+}
+
+# Read @ref from a chunk file.
+parse_ref() {
+ awk '/@ref .+/ {print $2}' "$1"
+}
+# Parse chunk metadata using the same awk program as trbldoc.sh's
+# parse_chunk_metadata() helper.
+parse_chunk_metadata_record() {
+ awk '
+BEGIN {
+ us = sprintf("%c", 31)
+ from_p = ""
+ namespace_p = ""
+ priority_p = ""
+ program_p = ""
+ ref_p = ""
+}
+/^[ \t]*@file / && from_p == "" {
+ from_p = $0
+ sub(/^[ \t]*@file /, "", from_p)
+}
+/^[ \t]*@name / && namespace_p == "" {
+ namespace_p = $2
+}
+/^[ \t]*@priority / && priority_p == "" {
+ priority_p = $2
+}
+/^[ \t]*@exec / && program_p == "" {
+ program_p = $0
+ sub(/^[ \t]*@exec /, "", program_p)
+}
+/^[ \t]*@ref / && ref_p == "" {
+ ref_p = $2
+}
+END {
+ printf "%s%s%s%s%s%s%s%s%s\n", from_p, us, namespace_p, us, priority_p, us, program_p, us, ref_p
+}
+' "$1"
+}
+
+# Apply the directive strip (mirrors trbldoc.sh line 231).
+strip_directives() {
+ sed -i "s/^@.*//g" "$1"
+}
+
+# ── @name ───────────────────────────────────────────────────────────────────
+
+@test "@name: parsed correctly from chunk" {
+ local chunk="$CACHE/chunks/test_name"
+ make_chunk "$chunk" "@file src.c:1" "@exec md" "@name my/namespace" "body text"
+ local result
+ result="$(parse_name "$chunk")"
+ assert_equal "$result" "my/namespace"
+}
+
+@test "@name: absent when directive is missing" {
+ local chunk="$CACHE/chunks/test_no_name"
+ make_chunk "$chunk" "@file src.c:1" "@exec md" "body text"
+ local result
+ result="$(parse_name "$chunk")"
+ assert_equal "$result" ""
+}
+
+@test "@name: only the first token after @name is captured" {
+ local chunk="$CACHE/chunks/test_name_token"
+ make_chunk "$chunk" "@name some/path extra ignored"
+ local result
+ result="$(parse_name "$chunk")"
+ assert_equal "$result" "some/path"
+}
+
+# ── @exec ───────────────────────────────────────────────────────────────────
+
+@test "@exec: renderer name parsed correctly" {
+ local chunk="$CACHE/chunks/test_exec"
+ make_chunk "$chunk" "@file src.c:1" "@exec md2html --github" "@name ns"
+ local result
+ result="$(parse_exec "$chunk")"
+ assert_equal "$result" "md2html --github"
+}
+
+@test "@exec: absent when directive is missing" {
+ local chunk="$CACHE/chunks/test_no_exec"
+ make_chunk "$chunk" "@file src.c:1" "@name ns" "body"
+ local result
+ result="$(parse_exec "$chunk")"
+ assert_equal "$result" ""
+}
+
+# ── @file ───────────────────────────────────────────────────────────────────
+
+@test "@file: source path parsed correctly" {
+ local chunk="$CACHE/chunks/test_file"
+ make_chunk "$chunk" "@file src/lib.c:42" "@exec md" "@name ns"
+ local result
+ result="$(parse_file "$chunk")"
+ assert_equal "$result" "src/lib.c:42"
+}
+
+# ── @ref ────────────────────────────────────────────────────────────────────
+
+@test "@ref: reference name parsed correctly" {
+ local chunk="$CACHE/chunks/test_ref"
+ make_chunk "$chunk" "@file src.c:1" "@exec md" "@name ns" "@ref my/ref-name"
+ local result
+ result="$(parse_ref "$chunk")"
+ assert_equal "$result" "my/ref-name"
+}
+
+@test "@ref: absent when directive is missing" {
+ local chunk="$CACHE/chunks/test_no_ref"
+ make_chunk "$chunk" "@file src.c:1" "@exec md" "@name ns"
+ local result
+ result="$(parse_ref "$chunk")"
+ assert_equal "$result" ""
+}
+
+@test "metadata parser: directives with leading spaces or tabs are parsed" {
+ local chunk="$CACHE/chunks/test_directive_leading_whitespace"
+ local sep expected result
+ make_chunk \
+ "$chunk" \
+ " @file src/lib.c:42" \
+ $'\t@name docs/indented' \
+ " @priority 99" \
+ $' \t@exec md2html --github' \
+ " @ref refs/anchor"
+ sep="$(printf '\037')"
+ expected="src/lib.c:42${sep}docs/indented${sep}99${sep}md2html --github${sep}refs/anchor"
+ result="$(parse_chunk_metadata_record "$chunk")"
+ assert_equal "$result" "$expected"
+}
+
+# ── directive stripping (sed pass) ──────────────────────────────────────────
+
+@test "strip: @name line is removed from chunk body" {
+ local chunk="$CACHE/chunks/test_strip_name"
+ make_chunk "$chunk" "@file src.c:1" "@name my/ns" "real body content"
+ strip_directives "$chunk"
+ assert_not_contains "$chunk" "^@name"
+}
+
+@test "strip: @exec line is removed from chunk body" {
+ local chunk="$CACHE/chunks/test_strip_exec"
+ make_chunk "$chunk" "@exec md" "real body content"
+ strip_directives "$chunk"
+ assert_not_contains "$chunk" "^@exec"
+}
+
+@test "strip: @file line is removed from chunk body" {
+ local chunk="$CACHE/chunks/test_strip_file"
+ make_chunk "$chunk" "@file src.c:1" "real body content"
+ strip_directives "$chunk"
+ assert_not_contains "$chunk" "^@file"
+}
+
+@test "strip: @ref line is removed from chunk body" {
+ local chunk="$CACHE/chunks/test_strip_ref"
+ make_chunk "$chunk" "@ref some/ref" "real body content"
+ strip_directives "$chunk"
+ assert_not_contains "$chunk" "^@ref"
+}
+
+@test "strip: non-directive body lines are preserved after stripping" {
+ local chunk="$CACHE/chunks/test_strip_body"
+ make_chunk "$chunk" "@name ns" "@exec md" "keep this line" "and this one"
+ strip_directives "$chunk"
+ assert_contains "$chunk" "keep this line"
+ assert_contains "$chunk" "and this one"
+}
+
+@test "strip: directives from all-directives fixture are present before strip" {
+ local prog
+ prog="$(awk_prog_c "$CACHE")"
+ run_extraction "$FIXTURES/c/directives.c" "$prog" "$CACHE"
+ # Before stripping, @ref should be present
+ chunk_contains "$CACHE" "@ref"
+}
+
+@test "strip: body content from all-directives fixture survives strip" {
+ local prog
+ prog="$(awk_prog_c "$CACHE")"
+ run_extraction "$FIXTURES/c/directives.c" "$prog" "$CACHE"
+ # Strip all chunks
+ for f in "$CACHE/chunks/"*; do
+ strip_directives "$f"
+ done
+ chunk_contains "$CACHE" "Directive test body content"
+}
diff --git a/tests/unit/extraction.bats b/tests/unit/extraction.bats
new file mode 100644
index 0000000..21c6784
--- /dev/null
+++ b/tests/unit/extraction.bats
@@ -0,0 +1,171 @@
+#!/usr/bin/env bats
+# tests/unit/extraction.bats
+# Verifies that the AWK extraction programs correctly identify doc comment
+# boundaries and produce chunk files for each supported language.
+#
+# Tests operate directly on the AWK programs (via helpers/common.bash) without
+# running the full trbldoc.sh pipeline.
+
+load "../helpers/common.bash"
+
+FIXTURES="$BATS_TEST_DIRNAME/../fixtures"
+
+setup() {
+ setup_workspace
+ CACHE=".trblcache"
+ mkdir -p "$CACHE/chunks"
+}
+
+teardown() {
+ teardown_workspace
+}
+
+# ── C (/* ... */) ──────────────────────────────────────────────────────────
+
+@test "C: single doc comment produces one chunk" {
+ local prog
+ prog="$(awk_prog_c "$CACHE")"
+ run_extraction "$FIXTURES/c/simple.c" "$prog" "$CACHE"
+ assert_equal "$(chunk_count "$CACHE")" "1"
+}
+
+@test "C: two doc comments produce two chunks" {
+ local prog
+ prog="$(awk_prog_c "$CACHE")"
+ run_extraction "$FIXTURES/c/multi_chunk.c" "$prog" "$CACHE"
+ assert_equal "$(chunk_count "$CACHE")" "2"
+}
+
+@test "C: file with no doc comments produces no chunks" {
+ local prog
+ prog="$(awk_prog_c "$CACHE")"
+ run_extraction "$FIXTURES/c/no_comments.c" "$prog" "$CACHE"
+ assert_equal "$(chunk_count "$CACHE")" "0"
+}
+
+@test "C: chunk contains @exec directive for renderer" {
+ local prog
+ prog="$(awk_prog_c "$CACHE")"
+ run_extraction "$FIXTURES/c/simple.c" "$prog" "$CACHE"
+ chunk_contains "$CACHE" "@exec"
+}
+
+@test "C: chunk contains @file directive with source file name" {
+ local prog
+ prog="$(awk_prog_c "$CACHE")"
+ run_extraction "$FIXTURES/c/simple.c" "$prog" "$CACHE"
+ chunk_contains "$CACHE" "@file"
+}
+
+@test "C: chunk body lines are captured" {
+ local prog
+ prog="$(awk_prog_c "$CACHE")"
+ run_extraction "$FIXTURES/c/simple.c" "$prog" "$CACHE"
+ chunk_contains "$CACHE" "Hello from a C comment"
+}
+
+@test "C: non-comment source lines are not captured in chunk" {
+ local prog
+ prog="$(awk_prog_c "$CACHE")"
+ run_extraction "$FIXTURES/c/simple.c" "$prog" "$CACHE"
+ # 'int main' is code, not doc
+ if grep -rl "int main" "$CACHE/chunks" >/dev/null 2>&1; then
+ echo "FAILED: code line 'int main' was captured in a chunk" >&2
+ return 1
+ fi
+}
+
+@test "C: single-line /* */ comment produces no chunks" {
+ local prog
+ prog="$(awk_prog_c "$CACHE")"
+ # no_comments.c contains a single-line /* ... */ comment on the first line
+ run_extraction "$FIXTURES/c/no_comments.c" "$prog" "$CACHE"
+ assert_equal "$(chunk_count "$CACHE")" "0"
+}
+
+@test "C: two-chunk file produces chunks with distinct content" {
+ local prog
+ prog="$(awk_prog_c "$CACHE")"
+ run_extraction "$FIXTURES/c/multi_chunk.c" "$prog" "$CACHE"
+ chunk_contains "$CACHE" "First chunk content"
+ chunk_contains "$CACHE" "Second chunk content"
+}
+
+# ── Lua (--[[ ... ]]) ──────────────────────────────────────────────────────
+
+@test "Lua: single doc comment produces one chunk" {
+ local prog
+ prog="$(awk_prog_lua "$CACHE")"
+ run_extraction "$FIXTURES/lua/simple.lua" "$prog" "$CACHE"
+ assert_equal "$(chunk_count "$CACHE")" "1"
+}
+
+@test "Lua: chunk body lines are captured" {
+ local prog
+ prog="$(awk_prog_lua "$CACHE")"
+ run_extraction "$FIXTURES/lua/simple.lua" "$prog" "$CACHE"
+ chunk_contains "$CACHE" "Hello from a Lua comment"
+}
+
+@test "Lua: non-comment source lines are not captured" {
+ local prog
+ prog="$(awk_prog_lua "$CACHE")"
+ run_extraction "$FIXTURES/lua/simple.lua" "$prog" "$CACHE"
+ if grep -rl "local x" "$CACHE/chunks" >/dev/null 2>&1; then
+ echo "FAILED: code line 'local x' was captured in a chunk" >&2
+ return 1
+ fi
+}
+
+@test "Lua: single-line --[[ ]] comment produces no chunks" {
+ local prog
+ prog="$(awk_prog_lua "$CACHE")"
+ run_extraction "$FIXTURES/lua/single_line.lua" "$prog" "$CACHE"
+ assert_equal "$(chunk_count "$CACHE")" "0"
+}
+
+# ── Python ('''renderer ... ''') ───────────────────────────────────────────
+
+@test "Python: single doc comment produces one chunk" {
+ local prog
+ prog="$(awk_prog_py "$CACHE")"
+ run_extraction "$FIXTURES/py/simple.py" "$prog" "$CACHE"
+ assert_equal "$(chunk_count "$CACHE")" "1"
+}
+
+@test "Python: chunk body lines are captured" {
+ local prog
+ prog="$(awk_prog_py "$CACHE")"
+ run_extraction "$FIXTURES/py/simple.py" "$prog" "$CACHE"
+ chunk_contains "$CACHE" "Hello from a Python doc comment"
+}
+
+@test "Python: single-line '''content''' on one line produces no chunks" {
+ local prog
+ prog="$(awk_prog_py "$CACHE")"
+ run_extraction "$FIXTURES/py/single_line.py" "$prog" "$CACHE"
+ assert_equal "$(chunk_count "$CACHE")" "0"
+}
+
+# ── Markdown (whole file = one chunk) ──────────────────────────────────────
+
+@test "Markdown: whole file produces exactly one chunk" {
+ local prog
+ prog="$(awk_prog_md "$CACHE")"
+ run_extraction "$FIXTURES/md/simple.md" "$prog" "$CACHE"
+ assert_equal "$(chunk_count "$CACHE")" "1"
+}
+
+@test "Markdown: chunk contains @exec md directive" {
+ local prog
+ prog="$(awk_prog_md "$CACHE")"
+ run_extraction "$FIXTURES/md/simple.md" "$prog" "$CACHE"
+ chunk_contains "$CACHE" "@exec md"
+}
+
+@test "Markdown: chunk includes file body content" {
+ local prog
+ prog="$(awk_prog_md "$CACHE")"
+ run_extraction "$FIXTURES/md/simple.md" "$prog" "$CACHE"
+ chunk_contains "$CACHE" "Hello from a markdown file"
+}
diff --git a/tests/unit/template_vars.bats b/tests/unit/template_vars.bats
new file mode 100644
index 0000000..5d95667
--- /dev/null
+++ b/tests/unit/template_vars.bats
@@ -0,0 +1,160 @@
+#!/usr/bin/env bats
+# tests/unit/template_vars.bats
+# Unit tests for template variable substitution patterns.
+#
+# These tests replicate the escape_sed_pattern_literal logic from trbldoc.sh
+# and verify that:
+# - the canonical {{ var }} format (two brackets, one space each side) is matched
+# - the old {{varname}} format (two brackets, no spaces) is NOT matched
+# - the layout-specific patterns ({{ title }}, {{ nav }}, {{ yield }},
+# {{ breadcrumb }}) work correctly in sed address and substitution position
+
+load "../helpers/common.bash"
+
+setup() {
+ setup_workspace
+}
+
+teardown() {
+ teardown_workspace
+}
+
+# Replicate escape_sed_pattern_literal from trbldoc.sh.
+escape_pat() {
+ printf '%s' "$1" | sed -e 's/[][\\.^$*+?(){}|]/\\&/g'
+}
+
+# ── chunk template variables ──────────────────────────────────────────────────
+
+@test "template vars: {{ src_repo }} is matched and substituted" {
+ local pat result
+ pat=$(escape_pat '{{ src_repo }}')
+ result=$(printf '%s' 'Link: {{ src_repo }}/file.c' | sed -E "s|$pat|https://example.com|g")
+ assert_equal "$result" 'Link: https://example.com/file.c'
+}
+
+@test "template vars: {{src_repo}} without spaces is NOT matched" {
+ local pat result
+ pat=$(escape_pat '{{ src_repo }}')
+ result=$(printf '%s' 'Old: {{src_repo}}' | sed -E "s|$pat|https://example.com|g")
+ assert_equal "$result" 'Old: {{src_repo}}'
+}
+
+@test "template vars: {{ toc }} is matched and substituted" {
+ local pat result
+ pat=$(escape_pat '{{ toc }}')
+ result=$(printf '%s' 'Nav: {{ toc }}' | sed -E "s|$pat|<ul><li>nav</li></ul>|g")
+ assert_equal "$result" 'Nav: <ul><li>nav</li></ul>'
+}
+
+@test "template vars: {{toc}} without spaces is NOT matched" {
+ local pat result
+ pat=$(escape_pat '{{ toc }}')
+ result=$(printf '%s' 'Old: {{toc}}' | sed -E "s|$pat|<ul><li>nav</li></ul>|g")
+ assert_equal "$result" 'Old: {{toc}}'
+}
+
+@test "template vars: {{ ref_string }} is matched and substituted" {
+ local pat result
+ pat=$(escape_pat '{{ ref_string }}')
+ result=$(printf '%s' "See: {{ ref_string }}." | sed -E "s|$pat|<a href='/x'>x</a>|g")
+ assert_equal "$result" "See: <a href='/x'>x</a>."
+}
+
+@test "template vars: {{ref_string}} without spaces is NOT matched" {
+ local pat result
+ pat=$(escape_pat '{{ ref_string }}')
+ result=$(printf '%s' 'Old: {{ref_string}}' | sed -E "s|$pat|<a href='/x'>x</a>|g")
+ assert_equal "$result" 'Old: {{ref_string}}'
+}
+
+@test "template vars: multiple {{ src_repo }} occurrences are all substituted" {
+ local pat result
+ pat=$(escape_pat '{{ src_repo }}')
+ result=$(printf '%s' '{{ src_repo }}/a and {{ src_repo }}/b' | sed -E "s|$pat|https://r|g")
+ assert_equal "$result" 'https://r/a and https://r/b'
+}
+
+# ── layout template variables ─────────────────────────────────────────────────
+
+@test "template vars: layout {{ title }} is matched and substituted" {
+ local result
+ result=$(printf '%s' '<title>{{ title }}</title>' \
+ | sed -E "s|\\{\\{ title \\}\\}|My Page|g")
+ assert_equal "$result" '<title>My Page</title>'
+}
+
+@test "template vars: layout {{title}} without spaces is NOT matched" {
+ local result
+ result=$(printf '%s' '<title>{{title}}</title>' \
+ | sed -E "s|\\{\\{ title \\}\\}|My Page|g")
+ assert_equal "$result" '<title>{{title}}</title>'
+}
+
+@test "template vars: layout {{ breadcrumb }} is matched and substituted" {
+ local result
+ result=$(printf '%s' '<h1>{{ breadcrumb }}</h1>' \
+ | sed -E "s|\\{\\{ breadcrumb \\}\\}|Section|g")
+ assert_equal "$result" '<h1>Section</h1>'
+}
+
+@test "template vars: layout {{breadcrumb}} without spaces is NOT matched" {
+ local result
+ result=$(printf '%s' '{{breadcrumb}}' \
+ | sed -E "s|\\{\\{ breadcrumb \\}\\}|Section|g")
+ assert_equal "$result" '{{breadcrumb}}'
+}
+
+@test "template vars: layout {{ nav }} is matched and substituted" {
+ local result
+ result=$(printf '%s' '<ul>{{ nav }}</ul>' \
+ | sed -E "s|\\{\\{ nav \\}\\}|<li>item</li>|g")
+ assert_equal "$result" '<ul><li>item</li></ul>'
+}
+
+@test "template vars: layout old {{> nav}} partial syntax is NOT matched by {{ nav }} pattern" {
+ local result
+ result=$(printf '%s' '<ul>{{> nav}}</ul>' \
+ | sed -E "s|\\{\\{ nav \\}\\}|<li>item</li>|g")
+ assert_equal "$result" '<ul>{{> nav}}</ul>'
+}
+
+@test "template vars: layout {{ yield }} works as sed address to include a file" {
+ local tmpbody tmplayout output
+ tmpbody=$(mktemp)
+ tmplayout=$(mktemp)
+ printf '<p>body content</p>\n' > "$tmpbody"
+ printf '{{ yield }}\n' > "$tmplayout"
+ output=$(sed -E \
+ -e "/\\{\\{ yield \\}\\}/r $tmpbody" \
+ -e "/\\{\\{ yield \\}\\}/d" \
+ "$tmplayout")
+ rm -f "$tmpbody" "$tmplayout"
+ assert_equal "$output" '<p>body content</p>'
+}
+
+@test "template vars: layout old {{{yield}}} triple-brace is NOT matched by {{ yield }} pattern" {
+ local tmpbody tmplayout output
+ tmpbody=$(mktemp)
+ tmplayout=$(mktemp)
+ printf '<p>body content</p>\n' > "$tmpbody"
+ printf '{{{yield}}}\n' > "$tmplayout"
+ output=$(sed -E \
+ -e "/\\{\\{ yield \\}\\}/r $tmpbody" \
+ -e "/\\{\\{ yield \\}\\}/d" \
+ "$tmplayout")
+ rm -f "$tmpbody" "$tmplayout"
+ # {{{yield}}} should remain unchanged — the new pattern only matches {{ yield }}
+ assert_equal "$output" '{{{yield}}}'
+}
+
+@test "template vars: substitution value containing special sed chars is safe" {
+ # Verify that replacement values with & and \ don't corrupt the output
+ # (this exercises the escape_sed_replacement_literal path)
+ local pat esc_repl result
+ pat=$(escape_pat '{{ src_repo }}')
+ # Simulate escape_sed_replacement_literal for a URL containing &
+ esc_repl=$(printf '%s' 'https://example.com/a&b' | sed -e 's/[\\&|]/\\&/g')
+ result=$(printf '%s' '{{ src_repo }}/path' | sed -E "s|$pat|$esc_repl|g")
+ assert_equal "$result" 'https://example.com/a&b/path'
+}