skim-md-toc: report line ranges for each heading
commit d72d74260c13298e5bb48ce4a908a57d908e5c07 · parent 4072b8e8a0
· browse this tree
Ximianth, 2026-09-03 12:53:12 UTC
Output gains a trailing range, e.g.:
- `#` Top title -- L1-40
- `##` Section -- L5-22
The range runs from the heading line to the line before the next heading
of the same or shallower level, so a section's range covers its
subsections. That's the operation worth serving: "read this whole
section", via `sed -n '5,22p'` or similar.
Placement is trailing, after the heading text. Putting the range inside
the existing backtick span, or in a leading column, both make the heading
text start-column jitter with range width -- which reads badly down a
nested list. Keeping the bullet and hashes left-flush wins.
Heading text can itself contain " -- ", so the range is the last such
separator, not the only one. Accepted: nothing parses this output
mechanically, and trailing position suits selecting the token to paste.
Internally, headings are now recorded and flushed at END rather than
printed on sight, since a heading's end isn't known until the next one
turns up. Only heading records are held, never file content, so memory
tracks heading count rather than file size.
Adds the first tests in this repo, in test-bin/ rather than bin/:
bin/ gets symlinked out to a wider audience, and tests shouldn't ride
along.
diff --git a/README.md b/README.md index e15fb51..e6fa0d1 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,19 @@ or compose into larger summaries (e.g. an agent's system prompt). - `orient-git` -- describes the surrounding git repo, if any. - `orient-project` -- inlines root orientation docs and detects a meta repo. - `skim-makefile` -- summarizes a Makefile's targets as a bullet list. -- `skim-md-toc` -- emits a markdown file's heading structure as a bullet list. +- `skim-md-toc` -- emits a markdown file's heading structure as a bullet list, with line ranges. Scripts share verb-prefix conventions (`orient-*`, `skim-*`, ...) so they tab-complete as groups when you remember the kind of thing you want but not the specific name. + +## Tests + +Tests live in `test-bin/`, named `<subject>.test`, and run directly: + +``` +./test-bin/skim-md-toc.test +``` + +They're kept out of `bin/` deliberately: +`bin/` gets symlinked out to a wider audience, and tests shouldn't ride along. diff --git a/bin/skim-md-toc b/bin/skim-md-toc index 4b259d5..5aecc5e 100755 --- a/bin/skim-md-toc +++ b/bin/skim-md-toc @@ -4,11 +4,23 @@ # Usage: skim-md-toc PATH # # Output: one bullet per heading, indented by level, with the hash prefix -# included so the level reads clearly without counting indents. Example: +# included so the level reads clearly without counting indents, and the +# heading's line range appended. Example: # -# - `#` Top title -# - `##` Section -# - `###` Subsection +# - `#` Top title -- L1-40 +# - `##` Section -- L5-22 +# - `###` Subsection -- L12-22 +# +# The range covers the heading line through the line before the next heading +# of the same or shallower level (or end of file). So a section's range +# includes its subsections: that serves "read this whole section", which is +# the reason to want ranges at all -- e.g. `sed -n '5,22p' FILE`. +# +# The range goes last, after the heading text. Heading text is arbitrary and +# may itself contain " -- ", so the range is the *last* such separator on the +# line, not the only one. That ambiguity is accepted: this output is read by +# people and agents rather than parsed by tools, and trailing position is what +# suits selecting the token to paste elsewhere. # # Silent (no output, exit 0) for files with no headings. # Errors (missing arg, missing file) go to stderr with non-zero exit. @@ -24,6 +36,9 @@ # skipped, so the closing `---` isn't misread as a setext underline. # - Heading text is emitted verbatim (minus leading hashes and whitespace). # No anchor inference, no link unwrapping. This is a TOC, not an index. +# +# A setext heading's range starts at its *text* line, not its underline, so +# extracting the range yields text that still carries its own title. set -euo pipefail @@ -46,43 +61,40 @@ fi # in_fm -- inside a YAML frontmatter block (only valid at file start). # prev -- the previous non-emitted line, for setext lookbehind. # prev_blank -- was the previous line blank (or treated as such)? +# n -- count of headings recorded so far. +# h_level[], h_text[], h_start[] -- the recorded headings, in file order. # -# Output is buffered through emit() so we can decide a heading after seeing -# the *next* line (the setext underline). +# Headings are recorded rather than printed as they're found, because a +# heading's end line isn't known until the next heading of the same or +# shallower level turns up (or the file ends). Only one record per heading is +# held, never file content, so memory tracks heading count rather than size. awk ' function trim_lead(s, t) { t = s; sub(/^[ \t]+/, "", t); return t } function trim_trail(s, t) { t = s; sub(/[ \t]+$/, "", t); return t } - function emit_atx(line, m, level, text, indent, hashes, i) { + function record(level, text, start) { + n++ + h_level[n] = level + h_text[n] = text + h_start[n] = start + } + + function record_atx(line, lineno, m, text) { if (match(line, /^(#{1,6})[ \t]+(.*)$/, m)) { - level = length(m[1]) text = m[2] # Some authors close ATX headings with trailing hashes; strip them. sub(/[ \t]+#+[ \t]*$/, "", text) - text = trim_trail(text) - indent = "" - for (i = 1; i < level; i++) indent = indent " " - hashes = "" - for (i = 0; i < level; i++) hashes = hashes "#" - printf "%s- `%s` %s\n", indent, hashes, text + record(length(m[1]), trim_trail(text), lineno) return 1 } return 0 } - function emit_setext(text, level, indent, hashes, i) { - text = trim_trail(trim_lead(text)) - indent = "" - for (i = 1; i < level; i++) indent = indent " " - hashes = "" - for (i = 0; i < level; i++) hashes = hashes "#" - printf "%s- `%s` %s\n", indent, hashes, text - } - BEGIN { in_fence = 0; fence_char = "" in_fm = 0 prev = ""; prev_blank = 1 + n = 0 } { @@ -113,20 +125,21 @@ awk ' # Setext underline detection: only valid when prev was a non-blank text line. # We require at least 2 underline characters; pure `-` or `=` of length 1 # is too easily a false positive. + # The heading starts on the text line (NR-1), not this underline. if (!prev_blank && trimmed ~ /^=+[ \t]*$/) { - emit_setext(prev, 1) + record(1, trim_trail(trim_lead(prev)), NR - 1) prev = ""; prev_blank = 1 next } if (!prev_blank && trimmed ~ /^-+[ \t]*$/ && length(trimmed) >= 2) { - emit_setext(prev, 2) + record(2, trim_trail(trim_lead(prev)), NR - 1) prev = ""; prev_blank = 1 next } - # ATX heading: emit immediately, but also clear the prev buffer so + # ATX heading: record it, and also clear the prev buffer so # the heading text itself cannot be misread as a setext target. - if (emit_atx($0)) { + if (record_atx($0, NR)) { prev = ""; prev_blank = 1 next } @@ -138,4 +151,23 @@ awk ' prev = $0; prev_blank = 0 } } + + # Close each heading against the next one of the same or shallower level, + # and print. NR here is the final line count of the file. + END { + for (i = 1; i <= n; i++) { + end = NR + for (j = i + 1; j <= n; j++) { + if (h_level[j] <= h_level[i]) { + end = h_start[j] - 1 + break + } + } + indent = "" + for (k = 1; k < h_level[i]; k++) indent = indent " " + hashes = "" + for (k = 0; k < h_level[i]; k++) hashes = hashes "#" + printf "%s- `%s` %s -- L%d-%d\n", indent, hashes, h_text[i], h_start[i], end + } + } ' "$file" diff --git a/test-bin/skim-md-toc.test b/test-bin/skim-md-toc.test new file mode 100755 index 0000000..adecd1e --- /dev/null +++ b/test-bin/skim-md-toc.test @@ -0,0 +1,158 @@ +#!/bin/bash +# Tests for `skim-md-toc`. +# +# Run directly: `./skim-md-toc.test` (exits non-zero on any failure). +# +# Lives in `test-bin/` rather than `bin/`: everything in `bin/` gets +# symlinked out to a wider audience, and tests shouldn't ride along. +# +# The fixtures are written inline as heredocs rather than kept as separate +# files, because the assertions are about *line numbers* -- keeping the input +# and its expected ranges adjacent in one place is what makes them reviewable. + +set -euo pipefail + +self_dir=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd) +subject="$self_dir/../bin/skim-md-toc" + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +fails=0 +## check runs the subject against a fixture and diffs against expected output. +## +## - @param $1 a human label for the case. +## - @param $2 the fixture's markdown content. +## - @param $3 the expected stdout. +check() { + local label="$1" fixture="$2" want="$3" got file + file="$tmpdir/fixture.md" + printf '%s' "$fixture" >"$file" + got=$("$subject" "$file") + if [ "$got" != "$want" ]; then + >&2 printf 'FAIL: %s\n' "$label" + >&2 diff -u <(printf '%s\n' "$want") <(printf '%s\n' "$got") | + sed 's/^/ /' || true + fails=$((fails + 1)) + fi +} + +# The main fixture, exercising the interactions that make ranges non-obvious: +# +# - Frontmatter is skipped, so the closing `---` isn't read as a setext rule, +# while the line numbering still counts those lines. +# - Setext headings report the *text* line as their start, not the underline. +# - A `#` inside a fenced block is not a heading and doesn't close a range. +# - An `h2`'s range covers its `h3` children (read-the-whole-section). +# - A range closes at the next heading of level <= its own, not any level. +# - The final section runs to EOF. +# +# Line numbers are annotated here rather than in the fixture, +# since the fixture content has to stay byte-exact. +# +# 1 --- +# 2 tags: [x] +# 3 --- +# 4 +# 5 Top Title +# 6 ========= +# 7 +# 8 intro prose +# 9 +# 10 ## Section One +# 11 +# 12 some text +# 13 +# 14 ### Sub A +# 15 +# 16 ``` +# 17 # not a heading +# 18 ``` +# 19 +# 20 ### Sub B +# 21 +# 22 text +# 23 +# 24 Section Two +# 25 ----------- +# 26 +# 27 tail text +main_fixture=$( + cat <<'EOF' +--- +tags: [x] +--- + +Top Title +========= + +intro prose + +## Section One + +some text + +### Sub A + +``` +# not a heading +``` + +### Sub B + +text + +Section Two +----------- + +tail text +EOF +) +main_fixture="$main_fixture"$'\n' + +check "nesting, setext, fences, frontmatter" "$main_fixture" \ + '- `#` Top Title -- L5-27 + - `##` Section One -- L10-23 + - `###` Sub A -- L14-19 + - `###` Sub B -- L20-23 + - `##` Section Two -- L24-27' + +# A lone heading with no body still gets a range: itself to EOF. +check "single heading to EOF" '# Only +' \ + '- `#` Only -- L1-1' + +# A deeper heading followed by a shallower one closes at the shallower one. +# (Guards the `<=` comparison against being written as `==`.) +check "deep then shallow" '### Deep + +body + +# Shallow + +more +' \ + ' - `###` Deep -- L1-4 +- `#` Shallow -- L5-7' + +# Heading text containing the same ` -- ` separator we append. +# The range is the last dash-pair; the text is passed through verbatim. +check "dashes in heading text" '## bwrap -- **recommended** + +body +' \ + ' - `##` bwrap -- **recommended** -- L1-3' + +# No headings at all: silent, exit 0. +printf 'just prose\n\nmore prose\n' >"$tmpdir/plain.md" +got=$("$subject" "$tmpdir/plain.md") +if [ -n "$got" ]; then + >&2 printf 'FAIL: headingless file should be silent, got: %q\n' "$got" + fails=$((fails + 1)) +fi + +if [ "$fails" -gt 0 ]; then + >&2 printf '\n%d test(s) failed.\n' "$fails" + exit 1 +fi +echo "ok"