From 8d790f371e54a8cc3e79a5ce72cd4dd5b3fa4513 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 11 Jun 2026 14:25:55 -0500 Subject: feat(org): table standard as a rule, reflow helper, and lint check Wide org tables overflow the page in exported PDF/docx, and hand-wrapping a cell into continuation rows is tedious and error-prone. The standard existed only as a work-project convention with nothing enforcing it. claude-rules/org-tables.md carries the generalized standard: 120-column budget measured at render width (a link counts as its visible label and is never split), over-budget cells wrap onto continuation rows, and a rule sits under the header and every logical row. wrap-org-table.el reflows a table to that shape mechanically. Columns shrink from natural width toward a floor of their widest atomic token, cells wrap link-safe, and rule-delimited continuation groups merge back into their logical row before re-wrapping, which makes the reflow idempotent. A table whose floors still exceed the budget reflows best-effort and stays flagged for restructuring. lint-org.el gains an org-table-standard judgment check: width overruns and missing rules surface during the sweep with a pointer to the helper. Conformant wrapped tables don't false-flag, since the check reuses the helper's continuation-group reading. The check is judgment-only by design: reflowing is a visible layout change the sweep shouldn't make silently. --- .ai/scripts/lint-org.el | 60 +++++ .ai/scripts/tests/test-lint-org.el | 42 +++ .ai/scripts/tests/test-wrap-org-table.el | 188 +++++++++++++ .ai/scripts/wrap-org-table.el | 296 +++++++++++++++++++++ claude-rules/org-tables.md | 60 +++++ claude-templates/.ai/scripts/lint-org.el | 60 +++++ .../.ai/scripts/tests/test-lint-org.el | 42 +++ .../.ai/scripts/tests/test-wrap-org-table.el | 188 +++++++++++++ claude-templates/.ai/scripts/wrap-org-table.el | 296 +++++++++++++++++++++ todo.org | 7 +- 10 files changed, 1237 insertions(+), 2 deletions(-) create mode 100644 .ai/scripts/tests/test-wrap-org-table.el create mode 100644 .ai/scripts/wrap-org-table.el create mode 100644 claude-rules/org-tables.md create mode 100644 claude-templates/.ai/scripts/tests/test-wrap-org-table.el create mode 100644 claude-templates/.ai/scripts/wrap-org-table.el diff --git a/.ai/scripts/lint-org.el b/.ai/scripts/lint-org.el index 85886af..5d47644 100644 --- a/.ai/scripts/lint-org.el +++ b/.ai/scripts/lint-org.el @@ -42,6 +42,7 @@ (require 'org-lint) (require 'cl-lib) (require 'subr-x) +(require 'wrap-org-table) ; render-width + table parsing for the table check (defvar lo-fixes 0 "Count of mechanical fixes applied (or would-apply in --check) on the last file.") @@ -284,6 +285,62 @@ Craig-specific annotation marker rather than Babel src-block syntax." (t (lo--emit-judgment name line msg))))) +;;; --------------------------------------------------------------------------- +;;; org-table-standard check (claude-rules/org-tables.md) +;; +;; Not an org-lint checker — a custom scan run alongside the org-lint pass. +;; Violations surface as judgment items (checker `org-table-standard'), never +;; auto-fixed: reflowing a table is a visible layout change that +;; wrap-org-table.el performs on request, not something a lint sweep does +;; silently. + +(defun lo--table-violations (lines) + "Standard violations for the table given as LINES, as message strings. +Width is render-measured (links count as their labels, per wot-render-width). +Rules: an hline must follow the header and every logical data row, closing +rule included; continuation lines inside a rule-delimited group are one +logical row, matching wrap-org-table.el's grouping." + (let ((violations nil) + (max-width (apply #'max (mapcar #'wot-render-width lines)))) + (when (> max-width wot-default-budget) + (push (format "renders %d wide (budget %d)" max-width wot-default-budget) + violations)) + (let* ((parsed (mapcar #'wot--parse-row lines)) + (header-p (and (listp (car parsed)) (eq (cadr parsed) 'hline))) + (data (if header-p (cddr parsed) parsed))) + (when (and (cl-some #'listp data) + (not (eq (car (last data)) 'hline))) + (push "no closing rule" violations)) + (let ((group nil)) + (cl-loop for e in data + if (eq e 'hline) do (setq group nil) + else do (push e group) + when (and (> (length group) 1) + (not (wot--continuation-group-p (reverse group)))) + return (push "missing rule between rows" violations)))) + (nreverse violations))) + +(defun lo--check-tables () + "Scan the current buffer for org tables violating the table standard. +Emits one judgment item per violating table." + (save-excursion + (goto-char (point-min)) + (while (re-search-forward "^[ \t]*|" nil t) + (let ((start-line (line-number-at-pos)) + (lines nil)) + (beginning-of-line) + (while (and (not (eobp)) (looking-at "[ \t]*|")) + (push (buffer-substring-no-properties (line-beginning-position) + (line-end-position)) + lines) + (forward-line 1)) + (let ((violations (lo--table-violations (nreverse lines)))) + (when violations + (lo--emit-judgment + 'org-table-standard start-line + (format "table violates the org-table standard: %s — wrap-org-table.el reflows it" + (string-join violations "; "))))))))) + ;;; --------------------------------------------------------------------------- ;;; File processing @@ -314,6 +371,9 @@ left unmodified and mechanical entries are recorded with :preview t." (lambda (a b) (> (lo--line a) (lo--line b)))))) (dolist (item sorted) (lo--handle-item item))) + ;; After org-lint items: the custom table-standard scan. Runs on the + ;; post-fix buffer; judgment-only, so order doesn't perturb fixes. + (lo--check-tables) (when (and (not lo-check-only) (buffer-modified-p)) (save-buffer))) (with-current-buffer buf (set-buffer-modified-p nil)) diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el index 60deb8c..d4b3ba0 100644 --- a/.ai/scripts/tests/test-lint-org.el +++ b/.ai/scripts/tests/test-lint-org.el @@ -599,5 +599,47 @@ followups file on the next run." (lo-test--drop-buffer file) (when (file-exists-p file) (delete-file file))))) +;;; --------------------------------------------------------------------------- +;;; org-table-standard check (width budget + rules between rows) + +(ert-deftest lo-table-over-budget-emits-judgment () + "A table line rendering wider than 120 surfaces as an org-table-standard judgment." + (let* ((wide (make-string 130 ?x)) + (run (lo-test--run (format "* H\n\n| a |\n|---|\n| %s |\n|---|\n" wide))) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should (memq 'org-table-standard (lo-test--checkers judgments))) + (should (cl-some (lambda (i) (lo-test--has (plist-get i :msg) "120")) + judgments)))) + +(ert-deftest lo-table-compliant-not-flagged () + "A narrow table with rules under header and every row passes silently." + (let* ((run (lo-test--run "* H\n\n| a |\n|---|\n| ok |\n|---|\n")) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) + +(ert-deftest lo-table-missing-interrow-rules-emits-judgment () + "Data rows without a rule between them violate the standard." + (let* ((run (lo-test--run "* H\n\n| a | b |\n|---+---|\n| 1 | 2 |\n| 3 | 4 |\n")) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should (memq 'org-table-standard (lo-test--checkers judgments))) + (should (cl-some (lambda (i) (lo-test--has (plist-get i :msg) "rule")) + judgments)))) + +(ert-deftest lo-table-wide-link-source-measures-render-width () + "A long link target doesn't trip the budget — width is render-measured." + (let* ((target (concat "https://example.com/" (make-string 120 ?p))) + (run (lo-test--run + (format "* H\n\n| [[%s][ok]] |\n|---|\n| x |\n|---|\n" target))) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) + +(ert-deftest lo-table-conformant-wrapped-table-not-flagged () + "Continuation rows inside a rule-delimited group are one logical row, not a +missing-rules violation." + (let* ((run (lo-test--run + "* H\n\n| Name | Notes |\n|-------+-------------------|\n| alpha | wrapped text that |\n| | continues here |\n|-------+-------------------|\n")) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) + (provide 'test-lint-org) ;;; test-lint-org.el ends here diff --git a/.ai/scripts/tests/test-wrap-org-table.el b/.ai/scripts/tests/test-wrap-org-table.el new file mode 100644 index 0000000..8d1ecb6 --- /dev/null +++ b/.ai/scripts/tests/test-wrap-org-table.el @@ -0,0 +1,188 @@ +;;; test-wrap-org-table.el --- ERT tests for wrap-org-table.el -*- lexical-binding: t; -*- +;; +;; Run from the repo root: +;; emacs --batch -q -L .ai/scripts -l ert \ +;; -l .ai/scripts/tests/test-wrap-org-table.el \ +;; -f ert-run-tests-batch-and-exit +;; +;; Covers the pure core (render width, tokenizing, cell wrap, column +;; allocation), the table reformat (exact-output cases at small budgets), and +;; the file layer. + +(require 'ert) +(require 'cl-lib) + +(defconst wot-test--dir + (file-name-directory (or load-file-name buffer-file-name default-directory)) + "Directory of this test file, captured at load time.") + +(add-to-list 'load-path (expand-file-name ".." wot-test--dir)) +(require 'wrap-org-table) + +;;; --------------------------------------------------------------------------- +;;; render width — links measure at their visible label + +(ert-deftest wot-render-width-plain-text () + (should (= (wot-render-width "hello") 5))) + +(ert-deftest wot-render-width-empty () + (should (= (wot-render-width "") 0))) + +(ert-deftest wot-render-width-descriptive-link-counts-label-only () + (should (= (wot-render-width "[[file:long-target-path.org][label]]") 5))) + +(ert-deftest wot-render-width-bare-link-counts-target () + (should (= (wot-render-width "[[file:x.org]]") 10))) + +(ert-deftest wot-render-width-mixed-text-and-link () + ;; "see doc now" = 11 + (should (= (wot-render-width "see [[file:a.org][doc]] now") 11))) + +;;; --------------------------------------------------------------------------- +;;; tokenize — links are atomic tokens + +(ert-deftest wot-tokenize-plain-words () + (should (equal (wot-tokenize "a quick fox") '("a" "quick" "fox")))) + +(ert-deftest wot-tokenize-link-is-one-token () + (should (equal (wot-tokenize "see [[file:a.org][the doc]] now") + '("see" "[[file:a.org][the doc]]" "now")))) + +(ert-deftest wot-tokenize-empty-string () + (should (equal (wot-tokenize "") nil))) + +;;; --------------------------------------------------------------------------- +;;; cell wrap + +(ert-deftest wot-wrap-cell-fits-on-one-line () + (should (equal (wot-wrap-cell "short text" 20) '("short text")))) + +(ert-deftest wot-wrap-cell-wraps-at-word-boundary () + (should (equal (wot-wrap-cell "The quick brown fox jumps over the lazy dog" 28) + '("The quick brown fox jumps" "over the lazy dog")))) + +(ert-deftest wot-wrap-cell-empty-cell () + (should (equal (wot-wrap-cell "" 10) '("")))) + +(ert-deftest wot-wrap-cell-overlong-token-sits-alone-unsplit () + (should (equal (wot-wrap-cell "tiny incomprehensibilities end" 10) + '("tiny" "incomprehensibilities" "end")))) + +(ert-deftest wot-wrap-cell-link-never-split () + ;; label is 17 chars, wider than the 10 budget — the link stays whole + (should (equal (wot-wrap-cell "[[file:a.org][a very wide label x]]" 10) + '("[[file:a.org][a very wide label x]]")))) + +;;; --------------------------------------------------------------------------- +;;; column allocation + +(ert-deftest wot-allocate-widths-fits-naturally () + ;; cells total 1+1, overhead 3*2+1=7 → 9 ≤ 30: keep natural widths + (should (equal (wot-allocate-widths '(("A" "B") ("1" "2")) 30) '(1 1)))) + +(ert-deftest wot-allocate-widths-shrinks-widest-column () + ;; naturals 5 + 44, overhead 7 → 56 > 40; Notes shrinks to 28, floor 5 + (should (equal (wot-allocate-widths + '(("Name" "Notes") + ("alpha" "The quick brown fox jumps over the lazy dog")) + 40) + '(5 28)))) + +(ert-deftest wot-allocate-widths-respects-token-floor () + ;; the long token (21) floors column 2 even when the budget wants less + (should (equal (wot-allocate-widths + '(("A" "B") ("x" "incomprehensibilities yes")) + 20) + '(1 21)))) + +;;; --------------------------------------------------------------------------- +;;; table reformat — exact output + +(defconst wot-test--wide-input + "| Name | Notes | +|------+-------| +| alpha | The quick brown fox jumps over the lazy dog | +") + +(defconst wot-test--wide-expected + "| Name | Notes | +|-------+------------------------------| +| alpha | The quick brown fox jumps | +| | over the lazy dog | +|-------+------------------------------| +") + +(ert-deftest wot-reformat-wraps-wide-cell-with-continuation-rows () + (should (equal (wot-reformat-table-string wot-test--wide-input 40) + wot-test--wide-expected))) + +(ert-deftest wot-reformat-narrow-table-gains-rules-between-rows () + (should (equal (wot-reformat-table-string + "| A | B | +|---+---| +| 1 | 2 | +| 3 | 4 | +" 120) + "| A | B | +|---+---| +| 1 | 2 | +|---+---| +| 3 | 4 | +|---+---| +"))) + +(ert-deftest wot-reformat-is-idempotent () + (let ((once (wot-reformat-table-string wot-test--wide-input 40))) + (should (equal (wot-reformat-table-string once 40) once)))) + +(ert-deftest wot-reformat-preserves-link-source-verbatim () + (let ((out (wot-reformat-table-string + "| Doc | Notes | +|-----+-------| +| [[file:very-long-target-path.org][d1]] | ok | +" 120))) + (should (string-match-p + (regexp-quote "[[file:very-long-target-path.org][d1]]") out)))) + +(ert-deftest wot-reformat-headerless-table-rules-every-row () + (should (equal (wot-reformat-table-string + "| 1 | 2 | +| 3 | 4 | +" 120) + "| 1 | 2 | +|---+---| +| 3 | 4 | +|---+---| +"))) + +(ert-deftest wot-reformat-preserves-indentation () + (should (equal (wot-reformat-table-string + " | A | B | + |---+---| + | 1 | 2 | +" 120) + " | A | B | + |---+---| + | 1 | 2 | + |---+---| +"))) + +;;; --------------------------------------------------------------------------- +;;; file layer + +(ert-deftest wot-process-file-reformats-tables-in-place () + (let ((file (make-temp-file "wot-test" nil ".org"))) + (unwind-protect + (progn + (with-temp-file file + (insert "* Heading\n\nProse before.\n\n" + wot-test--wide-input + "\nProse after.\n")) + (wot-process-file file 40) + (let ((content (with-temp-buffer + (insert-file-contents file) + (buffer-string)))) + (should (string-match-p (regexp-quote wot-test--wide-expected) content)) + (should (string-match-p "Prose before\\." content)) + (should (string-match-p "Prose after\\." content)))) + (delete-file file)))) diff --git a/.ai/scripts/wrap-org-table.el b/.ai/scripts/wrap-org-table.el new file mode 100644 index 0000000..ddbea65 --- /dev/null +++ b/.ai/scripts/wrap-org-table.el @@ -0,0 +1,296 @@ +;;; wrap-org-table.el --- reflow org tables to the width standard -*- lexical-binding: t; -*- +;; +;; Reformats org tables to the org-table standard (claude-rules/org-tables.md): +;; +;; 1. Max 120 columns wide, measured at RENDER width — an org link counts as +;; its visible label, not its [[target][label]] source. Links are never +;; split to chase a source-width number. +;; 2. Cells that would push a row past the budget wrap onto continuation +;; rows (the other columns left blank). +;; 3. A horizontal rule under the header and under every logical data row, +;; closing rule included. +;; +;; Usage: +;; emacs --batch -q -l wrap-org-table.el [--width=120] FILE.org [FILE.org ...] +;; reformat every table in each file, in place. A backup of each file is +;; copied to /tmp/.before-table-wrap. first. +;; +;; As a library: (wot-reformat-table-string STRING &optional BUDGET) is the +;; pure core; (wot-process-file FILE &optional BUDGET) is the file layer. +;; +;; Column widths: each column starts at its natural width (the widest cell it +;; holds, render-measured). When the row total exceeds the budget, the widest +;; columns shrink first, never below the column's floor — its longest atomic +;; token (a word, or a whole link) — because going lower would force a +;; mid-word or mid-link split. A table whose floors alone exceed the budget is +;; reflowed to the floors (best effort): the source stays over budget and the +;; lint check keeps flagging it for a human to restructure (merge or drop +;; columns — a judgment call this helper doesn't make). + +(require 'cl-lib) +(require 'subr-x) + +(defconst wot-default-budget 120 + "Default table width budget in render columns, pipes included.") + +;;; --------------------------------------------------------------------------- +;;; pure core + +(defun wot-render-width (s) + "Render width of cell text S: org links count as their visible label. +A descriptive link [[target][label]] measures as its label; a bare [[target]] +measures as the target text. Everything else is literal." + (let ((rendered (replace-regexp-in-string + "\\[\\[\\([^][]*\\)\\]\\(?:\\[\\([^][]*\\)\\]\\)?\\]" + (lambda (m) + (save-match-data + (if (string-match + "\\[\\[\\([^][]*\\)\\]\\[\\([^][]*\\)\\]\\]" m) + (match-string 2 m) + (string-match "\\[\\[\\([^][]*\\)\\]\\]" m) + (match-string 1 m)))) + s t t))) + (length rendered))) + +(defun wot-tokenize (s) + "Split cell text S into tokens; org links are atomic tokens." + (let ((tokens nil) + (pos 0) + (link-re "\\[\\[[^][]*\\]\\(?:\\[[^][]*\\]\\)?\\]")) + (while (string-match link-re s pos) + ;; Capture the bounds first: split-string below runs its own matches + ;; and clobbers the global match data. + (let ((mb (match-beginning 0)) + (me (match-end 0))) + (dolist (w (split-string (substring s pos mb) nil t)) (push w tokens)) + (push (substring s mb me) tokens) + (setq pos me))) + (dolist (w (split-string (substring s pos) nil t)) (push w tokens)) + (nreverse tokens))) + +(defun wot-wrap-cell (s width) + "Greedy-wrap cell text S into lines of at most WIDTH render columns. +Tokens (words and whole links) are never split; a token wider than WIDTH sits +alone on its own over-width line." + (let ((tokens (wot-tokenize s)) + (lines nil) + (current "")) + (dolist (tok tokens) + (cond + ((string-empty-p current) + (setq current tok)) + ((<= (+ (wot-render-width current) 1 (wot-render-width tok)) width) + (setq current (concat current " " tok))) + (t + (push current lines) + (setq current tok)))) + (push current lines) + (nreverse lines))) + +(defun wot--column-floor (cells) + "Floor width for a column holding CELLS: its widest atomic token." + (let ((floor 1)) + (dolist (cell cells) + (dolist (tok (wot-tokenize cell)) + (setq floor (max floor (wot-render-width tok))))) + floor)) + +(defun wot-allocate-widths (rows budget) + "Column widths for ROWS (lists of cell strings) under BUDGET total width. +Row overhead is `| ' + ` | ' separators + ` |', i.e. 3*ncols + 1. Columns +start at natural width; the widest shrink first, never below their floor." + (let* ((ncols (apply #'max (mapcar #'length rows))) + (cols (cl-loop for i below ncols + collect (mapcar (lambda (r) (or (nth i r) "")) rows))) + (widths (mapcar (lambda (col) + (apply #'max 1 (mapcar #'wot-render-width col))) + cols)) + (floors (mapcar #'wot--column-floor cols)) + (cell-budget (- budget (+ (* 3 ncols) 1)))) + (cl-loop while (> (apply #'+ widths) cell-budget) + for idx = (cl-loop with best = nil with best-w = -1 + for i below ncols + when (and (> (nth i widths) (nth i floors)) + (> (nth i widths) best-w)) + do (setq best i best-w (nth i widths)) + finally return best) + while idx + do (setf (nth idx widths) (1- (nth idx widths)))) + widths)) + +(defun wot--pad (cell width) + "Pad CELL source text with spaces so its render width is at least WIDTH." + (concat cell (make-string (max 0 (- width (wot-render-width cell))) ?\s))) + +(defun wot--hline (widths indent) + (concat indent "|" + (mapconcat (lambda (w) (make-string (+ w 2) ?-)) widths "+") + "|")) + +(defun wot--emit-row (cells widths indent) + "Physical lines for one logical row: CELLS wrapped to WIDTHS, link-safe." + (let* ((wrapped (cl-loop for i below (length widths) + collect (wot-wrap-cell (or (nth i cells) "") + (nth i widths)))) + (height (apply #'max (mapcar #'length wrapped)))) + (cl-loop for line below height + collect (concat indent "| " + (mapconcat + (lambda (i) + (wot--pad (or (nth line (nth i wrapped)) "") + (nth i widths))) + (number-sequence 0 (1- (length widths))) + " | ") + " |")))) + +(defun wot--parse-row (line) + "Cell strings of table LINE, or the symbol `hline'." + (let ((trimmed (string-trim line))) + (if (string-prefix-p "|-" trimmed) + 'hline + (mapcar #'string-trim + (split-string (string-remove-suffix "|" + (string-remove-prefix "|" trimmed)) + "|"))))) + +(defun wot--merge-group (group) + "Merge GROUP (a list of cell-lists) into one logical row. +Each column's non-empty values join with a space — the inverse of the +continuation-row split `wot--emit-row' produces." + (let ((ncols (apply #'max (mapcar #'length group)))) + (cl-loop for i below ncols + collect (string-join + (cl-remove-if #'string-empty-p + (mapcar (lambda (r) (or (nth i r) "")) + group)) + " ")))) + +(defun wot--continuation-group-p (group) + "Non-nil when GROUP's lines after the first read as continuation rows. +A continuation row carries overflow text in some columns and leaves the rest +empty, so every line past the first must have at least one empty cell. A +group of fully-populated lines is distinct rows that merely share a rule." + (and (> (length group) 1) + (cl-every (lambda (r) (cl-some #'string-empty-p r)) + (cdr group)))) + +(defun wot--logical-rows (elems) + "Logical rows from ELEMS, a list of cell-lists and `hline' symbols. +With no hlines, every line is its own row. With hlines, lines group between +rules; a group whose trailing lines look like continuations (each has an +empty cell) merges into one logical row — that makes re-running on +already-conformant output a no-op — while fully-populated groups keep their +line-per-row meaning." + (if (not (memq 'hline elems)) + elems + (let ((groups nil) (current nil)) + (dolist (e elems) + (if (eq e 'hline) + (when current + (push (nreverse current) groups) + (setq current nil)) + (push e current))) + (when current (push (nreverse current) groups)) + (cl-loop for g in (nreverse groups) + if (wot--continuation-group-p g) + collect (wot--merge-group g) + else append g)))) + +(defun wot-reformat-table-string (table-string &optional budget) + "Reformat TABLE-STRING to the org-table standard at BUDGET width. +Wraps over-budget cells onto continuation rows, puts a rule under the header +and under every logical data row, and preserves the table's indentation. +Re-running on already-conformant output is a no-op: rule-delimited +continuation lines merge back into their logical row before re-wrapping." + (let* ((budget (or budget wot-default-budget)) + (lines (split-string (string-remove-suffix "\n" table-string) "\n")) + (indent (if (string-match "^[ \t]*" (car lines)) + (match-string 0 (car lines)) + "")) + (parsed (mapcar #'wot--parse-row lines)) + ;; Header = first row when the source separates it with an hline. + (header-p (and (listp (car parsed)) (eq (cadr parsed) 'hline))) + (header (and header-p (car parsed))) + (data-elems (if header-p (cddr parsed) parsed)) + (rows (wot--logical-rows data-elems)) + (widths (wot-allocate-widths (if header (cons header rows) rows) + budget)) + (out nil)) + (when header + (dolist (l (wot--emit-row header widths indent)) (push l out)) + (push (wot--hline widths indent) out)) + (dolist (row rows) + (dolist (l (wot--emit-row row widths indent)) (push l out)) + (push (wot--hline widths indent) out)) + (concat (string-join (nreverse out) "\n") "\n"))) + +;;; --------------------------------------------------------------------------- +;;; file layer + +(defun wot-process-file (file &optional budget) + "Reformat every org table in FILE in place to BUDGET width." + (with-temp-buffer + (insert-file-contents file) + (goto-char (point-min)) + (while (re-search-forward "^[ \t]*|" nil t) + (let ((start (line-beginning-position))) + (while (and (not (eobp)) + (save-excursion (beginning-of-line) + (looking-at "[ \t]*|"))) + (forward-line 1)) + (let* ((end (point)) + (table (buffer-substring-no-properties start end)) + (reformatted (wot-reformat-table-string table budget))) + (delete-region start end) + (goto-char start) + (insert reformatted)))) + (write-region (point-min) (point-max) file))) + +;;; --------------------------------------------------------------------------- +;;; CLI + +(defun wot--backup (file) + (copy-file file + (format "/tmp/%s.before-table-wrap.%s" + (file-name-nondirectory file) + (format-time-string "%Y%m%d-%H%M%S")) + t)) + +(defun wot-main () + (let ((budget wot-default-budget) + (width-arg (cl-find-if (lambda (a) (string-prefix-p "--width=" a)) + command-line-args-left))) + (when width-arg + (setq budget (string-to-number (substring width-arg (length "--width=")))) + (setq command-line-args-left (delete width-arg command-line-args-left))) + (if (null command-line-args-left) + (progn + (princ "Usage: emacs --batch -q -l wrap-org-table.el [--width=120] FILE.org ...\n") + (kill-emacs 1)) + (let ((files command-line-args-left)) + (setq command-line-args-left nil) + (dolist (file files) + (if (file-readable-p file) + (progn + (wot--backup file) + (wot-process-file file budget) + (princ (format ";; wrap-org-table: file=%s reformatted (budget %d)\n" + file budget))) + (princ (format ";; wrap-org-table: file=%s not readable — skipping\n" + file)))))))) + +(defun wot--cli-invocation-p () + "Non-nil when the trailing args look like a real invocation (flags + files), +so the ERT suite can `require' this file without firing the CLI dispatch." + (and command-line-args-left + (cl-every (lambda (a) + (cond ((string-prefix-p "--width=" a) t) + ((string-prefix-p "-" a) nil) + (t (file-readable-p a)))) + command-line-args-left))) + +(when (and noninteractive (wot--cli-invocation-p)) + (wot-main)) + +(provide 'wrap-org-table) +;;; wrap-org-table.el ends here diff --git a/claude-rules/org-tables.md b/claude-rules/org-tables.md new file mode 100644 index 0000000..1b70085 --- /dev/null +++ b/claude-rules/org-tables.md @@ -0,0 +1,60 @@ +# Org Table Standard + +Applies to: `**/*.org` + +Every org table in project docs follows one shape. Wide tables overflow the +page in exported PDF/docx and run off the edge of the org buffer; this is the +standing fix. Promoted from the work project's local convention 2026-06-11. + +## Three requirements + +1. **Max width 120 columns — measured at render width.** The whole table + line, leading/trailing pipes included, is ≤120 characters as the table + *renders* (exported output, or the org buffer). An org link counts as its + visible label, not its full `[[target][label]]` source, because export and + the live buffer show only the label. This is the one place source width + and render width diverge; **never split a link** to chase a source-width + number — the render is what overflows the page. Non-link cells have no + source/render gap. +2. **Multi-line cells.** When a cell's text would push the row past 120, wrap + it onto continuation rows: repeat the row with the overflow column's text + continued and the other columns left blank, as many continuation rows as + the content needs. Never truncate content to hit the width; wrap it. +3. **A rule under the header and under every logical row.** Put a horizontal + rule (`|---+---|`) after the header and after every data row, closing rule + included. Each logical row then reads as a bordered block, and the rules + are what mark where a logical row (with its continuation lines) ends. + +Example — the logical row "arch-00" wrapped across two physical rows, rules +between every row: + + | Document | Doc Status | Notes | + |----------+------------+----------------------------------| + | arch-00 | Current | Source-of-truth spec; references | + | | | arch-NN as authority | + |----------+------------+----------------------------------| + | arch-01 | Current | Linear introduction for | + | | | first-time readers | + |----------+------------+----------------------------------| + +## How to apply + +When authoring or editing any table, produce this shape from the start. When +a table already violates it, reformat in place — preserve every cell's +content and any links verbatim, only change the layout. + +Tooling (in every project's `.ai/scripts/` via the template sync): + +- `wrap-org-table.el` reflows tables to the standard mechanically: + `emacs --batch -q -l .ai/scripts/wrap-org-table.el [--width=120] FILE.org`. + It wraps over-budget cells onto continuation rows, adds the rules, measures + links at label width, and never splits a token or a link. Re-running on a + conformant table is a no-op. +- `lint-org.el` flags violating tables as judgment items (checker + `org-table-standard`) during its sweep — width overruns, missing rules, or + both — and names the helper in the message. + +The helper can't fix a table whose single narrowest-possible columns still +exceed the budget (some token or link label is just too wide). That table +needs restructuring — merge or drop columns, shorten labels — which is a +judgment call: the lint item stays until a human reshapes it. diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el index 85886af..5d47644 100644 --- a/claude-templates/.ai/scripts/lint-org.el +++ b/claude-templates/.ai/scripts/lint-org.el @@ -42,6 +42,7 @@ (require 'org-lint) (require 'cl-lib) (require 'subr-x) +(require 'wrap-org-table) ; render-width + table parsing for the table check (defvar lo-fixes 0 "Count of mechanical fixes applied (or would-apply in --check) on the last file.") @@ -284,6 +285,62 @@ Craig-specific annotation marker rather than Babel src-block syntax." (t (lo--emit-judgment name line msg))))) +;;; --------------------------------------------------------------------------- +;;; org-table-standard check (claude-rules/org-tables.md) +;; +;; Not an org-lint checker — a custom scan run alongside the org-lint pass. +;; Violations surface as judgment items (checker `org-table-standard'), never +;; auto-fixed: reflowing a table is a visible layout change that +;; wrap-org-table.el performs on request, not something a lint sweep does +;; silently. + +(defun lo--table-violations (lines) + "Standard violations for the table given as LINES, as message strings. +Width is render-measured (links count as their labels, per wot-render-width). +Rules: an hline must follow the header and every logical data row, closing +rule included; continuation lines inside a rule-delimited group are one +logical row, matching wrap-org-table.el's grouping." + (let ((violations nil) + (max-width (apply #'max (mapcar #'wot-render-width lines)))) + (when (> max-width wot-default-budget) + (push (format "renders %d wide (budget %d)" max-width wot-default-budget) + violations)) + (let* ((parsed (mapcar #'wot--parse-row lines)) + (header-p (and (listp (car parsed)) (eq (cadr parsed) 'hline))) + (data (if header-p (cddr parsed) parsed))) + (when (and (cl-some #'listp data) + (not (eq (car (last data)) 'hline))) + (push "no closing rule" violations)) + (let ((group nil)) + (cl-loop for e in data + if (eq e 'hline) do (setq group nil) + else do (push e group) + when (and (> (length group) 1) + (not (wot--continuation-group-p (reverse group)))) + return (push "missing rule between rows" violations)))) + (nreverse violations))) + +(defun lo--check-tables () + "Scan the current buffer for org tables violating the table standard. +Emits one judgment item per violating table." + (save-excursion + (goto-char (point-min)) + (while (re-search-forward "^[ \t]*|" nil t) + (let ((start-line (line-number-at-pos)) + (lines nil)) + (beginning-of-line) + (while (and (not (eobp)) (looking-at "[ \t]*|")) + (push (buffer-substring-no-properties (line-beginning-position) + (line-end-position)) + lines) + (forward-line 1)) + (let ((violations (lo--table-violations (nreverse lines)))) + (when violations + (lo--emit-judgment + 'org-table-standard start-line + (format "table violates the org-table standard: %s — wrap-org-table.el reflows it" + (string-join violations "; "))))))))) + ;;; --------------------------------------------------------------------------- ;;; File processing @@ -314,6 +371,9 @@ left unmodified and mechanical entries are recorded with :preview t." (lambda (a b) (> (lo--line a) (lo--line b)))))) (dolist (item sorted) (lo--handle-item item))) + ;; After org-lint items: the custom table-standard scan. Runs on the + ;; post-fix buffer; judgment-only, so order doesn't perturb fixes. + (lo--check-tables) (when (and (not lo-check-only) (buffer-modified-p)) (save-buffer))) (with-current-buffer buf (set-buffer-modified-p nil)) diff --git a/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el index 60deb8c..d4b3ba0 100644 --- a/claude-templates/.ai/scripts/tests/test-lint-org.el +++ b/claude-templates/.ai/scripts/tests/test-lint-org.el @@ -599,5 +599,47 @@ followups file on the next run." (lo-test--drop-buffer file) (when (file-exists-p file) (delete-file file))))) +;;; --------------------------------------------------------------------------- +;;; org-table-standard check (width budget + rules between rows) + +(ert-deftest lo-table-over-budget-emits-judgment () + "A table line rendering wider than 120 surfaces as an org-table-standard judgment." + (let* ((wide (make-string 130 ?x)) + (run (lo-test--run (format "* H\n\n| a |\n|---|\n| %s |\n|---|\n" wide))) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should (memq 'org-table-standard (lo-test--checkers judgments))) + (should (cl-some (lambda (i) (lo-test--has (plist-get i :msg) "120")) + judgments)))) + +(ert-deftest lo-table-compliant-not-flagged () + "A narrow table with rules under header and every row passes silently." + (let* ((run (lo-test--run "* H\n\n| a |\n|---|\n| ok |\n|---|\n")) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) + +(ert-deftest lo-table-missing-interrow-rules-emits-judgment () + "Data rows without a rule between them violate the standard." + (let* ((run (lo-test--run "* H\n\n| a | b |\n|---+---|\n| 1 | 2 |\n| 3 | 4 |\n")) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should (memq 'org-table-standard (lo-test--checkers judgments))) + (should (cl-some (lambda (i) (lo-test--has (plist-get i :msg) "rule")) + judgments)))) + +(ert-deftest lo-table-wide-link-source-measures-render-width () + "A long link target doesn't trip the budget — width is render-measured." + (let* ((target (concat "https://example.com/" (make-string 120 ?p))) + (run (lo-test--run + (format "* H\n\n| [[%s][ok]] |\n|---|\n| x |\n|---|\n" target))) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) + +(ert-deftest lo-table-conformant-wrapped-table-not-flagged () + "Continuation rows inside a rule-delimited group are one logical row, not a +missing-rules violation." + (let* ((run (lo-test--run + "* H\n\n| Name | Notes |\n|-------+-------------------|\n| alpha | wrapped text that |\n| | continues here |\n|-------+-------------------|\n")) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) + (provide 'test-lint-org) ;;; test-lint-org.el ends here diff --git a/claude-templates/.ai/scripts/tests/test-wrap-org-table.el b/claude-templates/.ai/scripts/tests/test-wrap-org-table.el new file mode 100644 index 0000000..8d1ecb6 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test-wrap-org-table.el @@ -0,0 +1,188 @@ +;;; test-wrap-org-table.el --- ERT tests for wrap-org-table.el -*- lexical-binding: t; -*- +;; +;; Run from the repo root: +;; emacs --batch -q -L .ai/scripts -l ert \ +;; -l .ai/scripts/tests/test-wrap-org-table.el \ +;; -f ert-run-tests-batch-and-exit +;; +;; Covers the pure core (render width, tokenizing, cell wrap, column +;; allocation), the table reformat (exact-output cases at small budgets), and +;; the file layer. + +(require 'ert) +(require 'cl-lib) + +(defconst wot-test--dir + (file-name-directory (or load-file-name buffer-file-name default-directory)) + "Directory of this test file, captured at load time.") + +(add-to-list 'load-path (expand-file-name ".." wot-test--dir)) +(require 'wrap-org-table) + +;;; --------------------------------------------------------------------------- +;;; render width — links measure at their visible label + +(ert-deftest wot-render-width-plain-text () + (should (= (wot-render-width "hello") 5))) + +(ert-deftest wot-render-width-empty () + (should (= (wot-render-width "") 0))) + +(ert-deftest wot-render-width-descriptive-link-counts-label-only () + (should (= (wot-render-width "[[file:long-target-path.org][label]]") 5))) + +(ert-deftest wot-render-width-bare-link-counts-target () + (should (= (wot-render-width "[[file:x.org]]") 10))) + +(ert-deftest wot-render-width-mixed-text-and-link () + ;; "see doc now" = 11 + (should (= (wot-render-width "see [[file:a.org][doc]] now") 11))) + +;;; --------------------------------------------------------------------------- +;;; tokenize — links are atomic tokens + +(ert-deftest wot-tokenize-plain-words () + (should (equal (wot-tokenize "a quick fox") '("a" "quick" "fox")))) + +(ert-deftest wot-tokenize-link-is-one-token () + (should (equal (wot-tokenize "see [[file:a.org][the doc]] now") + '("see" "[[file:a.org][the doc]]" "now")))) + +(ert-deftest wot-tokenize-empty-string () + (should (equal (wot-tokenize "") nil))) + +;;; --------------------------------------------------------------------------- +;;; cell wrap + +(ert-deftest wot-wrap-cell-fits-on-one-line () + (should (equal (wot-wrap-cell "short text" 20) '("short text")))) + +(ert-deftest wot-wrap-cell-wraps-at-word-boundary () + (should (equal (wot-wrap-cell "The quick brown fox jumps over the lazy dog" 28) + '("The quick brown fox jumps" "over the lazy dog")))) + +(ert-deftest wot-wrap-cell-empty-cell () + (should (equal (wot-wrap-cell "" 10) '("")))) + +(ert-deftest wot-wrap-cell-overlong-token-sits-alone-unsplit () + (should (equal (wot-wrap-cell "tiny incomprehensibilities end" 10) + '("tiny" "incomprehensibilities" "end")))) + +(ert-deftest wot-wrap-cell-link-never-split () + ;; label is 17 chars, wider than the 10 budget — the link stays whole + (should (equal (wot-wrap-cell "[[file:a.org][a very wide label x]]" 10) + '("[[file:a.org][a very wide label x]]")))) + +;;; --------------------------------------------------------------------------- +;;; column allocation + +(ert-deftest wot-allocate-widths-fits-naturally () + ;; cells total 1+1, overhead 3*2+1=7 → 9 ≤ 30: keep natural widths + (should (equal (wot-allocate-widths '(("A" "B") ("1" "2")) 30) '(1 1)))) + +(ert-deftest wot-allocate-widths-shrinks-widest-column () + ;; naturals 5 + 44, overhead 7 → 56 > 40; Notes shrinks to 28, floor 5 + (should (equal (wot-allocate-widths + '(("Name" "Notes") + ("alpha" "The quick brown fox jumps over the lazy dog")) + 40) + '(5 28)))) + +(ert-deftest wot-allocate-widths-respects-token-floor () + ;; the long token (21) floors column 2 even when the budget wants less + (should (equal (wot-allocate-widths + '(("A" "B") ("x" "incomprehensibilities yes")) + 20) + '(1 21)))) + +;;; --------------------------------------------------------------------------- +;;; table reformat — exact output + +(defconst wot-test--wide-input + "| Name | Notes | +|------+-------| +| alpha | The quick brown fox jumps over the lazy dog | +") + +(defconst wot-test--wide-expected + "| Name | Notes | +|-------+------------------------------| +| alpha | The quick brown fox jumps | +| | over the lazy dog | +|-------+------------------------------| +") + +(ert-deftest wot-reformat-wraps-wide-cell-with-continuation-rows () + (should (equal (wot-reformat-table-string wot-test--wide-input 40) + wot-test--wide-expected))) + +(ert-deftest wot-reformat-narrow-table-gains-rules-between-rows () + (should (equal (wot-reformat-table-string + "| A | B | +|---+---| +| 1 | 2 | +| 3 | 4 | +" 120) + "| A | B | +|---+---| +| 1 | 2 | +|---+---| +| 3 | 4 | +|---+---| +"))) + +(ert-deftest wot-reformat-is-idempotent () + (let ((once (wot-reformat-table-string wot-test--wide-input 40))) + (should (equal (wot-reformat-table-string once 40) once)))) + +(ert-deftest wot-reformat-preserves-link-source-verbatim () + (let ((out (wot-reformat-table-string + "| Doc | Notes | +|-----+-------| +| [[file:very-long-target-path.org][d1]] | ok | +" 120))) + (should (string-match-p + (regexp-quote "[[file:very-long-target-path.org][d1]]") out)))) + +(ert-deftest wot-reformat-headerless-table-rules-every-row () + (should (equal (wot-reformat-table-string + "| 1 | 2 | +| 3 | 4 | +" 120) + "| 1 | 2 | +|---+---| +| 3 | 4 | +|---+---| +"))) + +(ert-deftest wot-reformat-preserves-indentation () + (should (equal (wot-reformat-table-string + " | A | B | + |---+---| + | 1 | 2 | +" 120) + " | A | B | + |---+---| + | 1 | 2 | + |---+---| +"))) + +;;; --------------------------------------------------------------------------- +;;; file layer + +(ert-deftest wot-process-file-reformats-tables-in-place () + (let ((file (make-temp-file "wot-test" nil ".org"))) + (unwind-protect + (progn + (with-temp-file file + (insert "* Heading\n\nProse before.\n\n" + wot-test--wide-input + "\nProse after.\n")) + (wot-process-file file 40) + (let ((content (with-temp-buffer + (insert-file-contents file) + (buffer-string)))) + (should (string-match-p (regexp-quote wot-test--wide-expected) content)) + (should (string-match-p "Prose before\\." content)) + (should (string-match-p "Prose after\\." content)))) + (delete-file file)))) diff --git a/claude-templates/.ai/scripts/wrap-org-table.el b/claude-templates/.ai/scripts/wrap-org-table.el new file mode 100644 index 0000000..ddbea65 --- /dev/null +++ b/claude-templates/.ai/scripts/wrap-org-table.el @@ -0,0 +1,296 @@ +;;; wrap-org-table.el --- reflow org tables to the width standard -*- lexical-binding: t; -*- +;; +;; Reformats org tables to the org-table standard (claude-rules/org-tables.md): +;; +;; 1. Max 120 columns wide, measured at RENDER width — an org link counts as +;; its visible label, not its [[target][label]] source. Links are never +;; split to chase a source-width number. +;; 2. Cells that would push a row past the budget wrap onto continuation +;; rows (the other columns left blank). +;; 3. A horizontal rule under the header and under every logical data row, +;; closing rule included. +;; +;; Usage: +;; emacs --batch -q -l wrap-org-table.el [--width=120] FILE.org [FILE.org ...] +;; reformat every table in each file, in place. A backup of each file is +;; copied to /tmp/.before-table-wrap. first. +;; +;; As a library: (wot-reformat-table-string STRING &optional BUDGET) is the +;; pure core; (wot-process-file FILE &optional BUDGET) is the file layer. +;; +;; Column widths: each column starts at its natural width (the widest cell it +;; holds, render-measured). When the row total exceeds the budget, the widest +;; columns shrink first, never below the column's floor — its longest atomic +;; token (a word, or a whole link) — because going lower would force a +;; mid-word or mid-link split. A table whose floors alone exceed the budget is +;; reflowed to the floors (best effort): the source stays over budget and the +;; lint check keeps flagging it for a human to restructure (merge or drop +;; columns — a judgment call this helper doesn't make). + +(require 'cl-lib) +(require 'subr-x) + +(defconst wot-default-budget 120 + "Default table width budget in render columns, pipes included.") + +;;; --------------------------------------------------------------------------- +;;; pure core + +(defun wot-render-width (s) + "Render width of cell text S: org links count as their visible label. +A descriptive link [[target][label]] measures as its label; a bare [[target]] +measures as the target text. Everything else is literal." + (let ((rendered (replace-regexp-in-string + "\\[\\[\\([^][]*\\)\\]\\(?:\\[\\([^][]*\\)\\]\\)?\\]" + (lambda (m) + (save-match-data + (if (string-match + "\\[\\[\\([^][]*\\)\\]\\[\\([^][]*\\)\\]\\]" m) + (match-string 2 m) + (string-match "\\[\\[\\([^][]*\\)\\]\\]" m) + (match-string 1 m)))) + s t t))) + (length rendered))) + +(defun wot-tokenize (s) + "Split cell text S into tokens; org links are atomic tokens." + (let ((tokens nil) + (pos 0) + (link-re "\\[\\[[^][]*\\]\\(?:\\[[^][]*\\]\\)?\\]")) + (while (string-match link-re s pos) + ;; Capture the bounds first: split-string below runs its own matches + ;; and clobbers the global match data. + (let ((mb (match-beginning 0)) + (me (match-end 0))) + (dolist (w (split-string (substring s pos mb) nil t)) (push w tokens)) + (push (substring s mb me) tokens) + (setq pos me))) + (dolist (w (split-string (substring s pos) nil t)) (push w tokens)) + (nreverse tokens))) + +(defun wot-wrap-cell (s width) + "Greedy-wrap cell text S into lines of at most WIDTH render columns. +Tokens (words and whole links) are never split; a token wider than WIDTH sits +alone on its own over-width line." + (let ((tokens (wot-tokenize s)) + (lines nil) + (current "")) + (dolist (tok tokens) + (cond + ((string-empty-p current) + (setq current tok)) + ((<= (+ (wot-render-width current) 1 (wot-render-width tok)) width) + (setq current (concat current " " tok))) + (t + (push current lines) + (setq current tok)))) + (push current lines) + (nreverse lines))) + +(defun wot--column-floor (cells) + "Floor width for a column holding CELLS: its widest atomic token." + (let ((floor 1)) + (dolist (cell cells) + (dolist (tok (wot-tokenize cell)) + (setq floor (max floor (wot-render-width tok))))) + floor)) + +(defun wot-allocate-widths (rows budget) + "Column widths for ROWS (lists of cell strings) under BUDGET total width. +Row overhead is `| ' + ` | ' separators + ` |', i.e. 3*ncols + 1. Columns +start at natural width; the widest shrink first, never below their floor." + (let* ((ncols (apply #'max (mapcar #'length rows))) + (cols (cl-loop for i below ncols + collect (mapcar (lambda (r) (or (nth i r) "")) rows))) + (widths (mapcar (lambda (col) + (apply #'max 1 (mapcar #'wot-render-width col))) + cols)) + (floors (mapcar #'wot--column-floor cols)) + (cell-budget (- budget (+ (* 3 ncols) 1)))) + (cl-loop while (> (apply #'+ widths) cell-budget) + for idx = (cl-loop with best = nil with best-w = -1 + for i below ncols + when (and (> (nth i widths) (nth i floors)) + (> (nth i widths) best-w)) + do (setq best i best-w (nth i widths)) + finally return best) + while idx + do (setf (nth idx widths) (1- (nth idx widths)))) + widths)) + +(defun wot--pad (cell width) + "Pad CELL source text with spaces so its render width is at least WIDTH." + (concat cell (make-string (max 0 (- width (wot-render-width cell))) ?\s))) + +(defun wot--hline (widths indent) + (concat indent "|" + (mapconcat (lambda (w) (make-string (+ w 2) ?-)) widths "+") + "|")) + +(defun wot--emit-row (cells widths indent) + "Physical lines for one logical row: CELLS wrapped to WIDTHS, link-safe." + (let* ((wrapped (cl-loop for i below (length widths) + collect (wot-wrap-cell (or (nth i cells) "") + (nth i widths)))) + (height (apply #'max (mapcar #'length wrapped)))) + (cl-loop for line below height + collect (concat indent "| " + (mapconcat + (lambda (i) + (wot--pad (or (nth line (nth i wrapped)) "") + (nth i widths))) + (number-sequence 0 (1- (length widths))) + " | ") + " |")))) + +(defun wot--parse-row (line) + "Cell strings of table LINE, or the symbol `hline'." + (let ((trimmed (string-trim line))) + (if (string-prefix-p "|-" trimmed) + 'hline + (mapcar #'string-trim + (split-string (string-remove-suffix "|" + (string-remove-prefix "|" trimmed)) + "|"))))) + +(defun wot--merge-group (group) + "Merge GROUP (a list of cell-lists) into one logical row. +Each column's non-empty values join with a space — the inverse of the +continuation-row split `wot--emit-row' produces." + (let ((ncols (apply #'max (mapcar #'length group)))) + (cl-loop for i below ncols + collect (string-join + (cl-remove-if #'string-empty-p + (mapcar (lambda (r) (or (nth i r) "")) + group)) + " ")))) + +(defun wot--continuation-group-p (group) + "Non-nil when GROUP's lines after the first read as continuation rows. +A continuation row carries overflow text in some columns and leaves the rest +empty, so every line past the first must have at least one empty cell. A +group of fully-populated lines is distinct rows that merely share a rule." + (and (> (length group) 1) + (cl-every (lambda (r) (cl-some #'string-empty-p r)) + (cdr group)))) + +(defun wot--logical-rows (elems) + "Logical rows from ELEMS, a list of cell-lists and `hline' symbols. +With no hlines, every line is its own row. With hlines, lines group between +rules; a group whose trailing lines look like continuations (each has an +empty cell) merges into one logical row — that makes re-running on +already-conformant output a no-op — while fully-populated groups keep their +line-per-row meaning." + (if (not (memq 'hline elems)) + elems + (let ((groups nil) (current nil)) + (dolist (e elems) + (if (eq e 'hline) + (when current + (push (nreverse current) groups) + (setq current nil)) + (push e current))) + (when current (push (nreverse current) groups)) + (cl-loop for g in (nreverse groups) + if (wot--continuation-group-p g) + collect (wot--merge-group g) + else append g)))) + +(defun wot-reformat-table-string (table-string &optional budget) + "Reformat TABLE-STRING to the org-table standard at BUDGET width. +Wraps over-budget cells onto continuation rows, puts a rule under the header +and under every logical data row, and preserves the table's indentation. +Re-running on already-conformant output is a no-op: rule-delimited +continuation lines merge back into their logical row before re-wrapping." + (let* ((budget (or budget wot-default-budget)) + (lines (split-string (string-remove-suffix "\n" table-string) "\n")) + (indent (if (string-match "^[ \t]*" (car lines)) + (match-string 0 (car lines)) + "")) + (parsed (mapcar #'wot--parse-row lines)) + ;; Header = first row when the source separates it with an hline. + (header-p (and (listp (car parsed)) (eq (cadr parsed) 'hline))) + (header (and header-p (car parsed))) + (data-elems (if header-p (cddr parsed) parsed)) + (rows (wot--logical-rows data-elems)) + (widths (wot-allocate-widths (if header (cons header rows) rows) + budget)) + (out nil)) + (when header + (dolist (l (wot--emit-row header widths indent)) (push l out)) + (push (wot--hline widths indent) out)) + (dolist (row rows) + (dolist (l (wot--emit-row row widths indent)) (push l out)) + (push (wot--hline widths indent) out)) + (concat (string-join (nreverse out) "\n") "\n"))) + +;;; --------------------------------------------------------------------------- +;;; file layer + +(defun wot-process-file (file &optional budget) + "Reformat every org table in FILE in place to BUDGET width." + (with-temp-buffer + (insert-file-contents file) + (goto-char (point-min)) + (while (re-search-forward "^[ \t]*|" nil t) + (let ((start (line-beginning-position))) + (while (and (not (eobp)) + (save-excursion (beginning-of-line) + (looking-at "[ \t]*|"))) + (forward-line 1)) + (let* ((end (point)) + (table (buffer-substring-no-properties start end)) + (reformatted (wot-reformat-table-string table budget))) + (delete-region start end) + (goto-char start) + (insert reformatted)))) + (write-region (point-min) (point-max) file))) + +;;; --------------------------------------------------------------------------- +;;; CLI + +(defun wot--backup (file) + (copy-file file + (format "/tmp/%s.before-table-wrap.%s" + (file-name-nondirectory file) + (format-time-string "%Y%m%d-%H%M%S")) + t)) + +(defun wot-main () + (let ((budget wot-default-budget) + (width-arg (cl-find-if (lambda (a) (string-prefix-p "--width=" a)) + command-line-args-left))) + (when width-arg + (setq budget (string-to-number (substring width-arg (length "--width=")))) + (setq command-line-args-left (delete width-arg command-line-args-left))) + (if (null command-line-args-left) + (progn + (princ "Usage: emacs --batch -q -l wrap-org-table.el [--width=120] FILE.org ...\n") + (kill-emacs 1)) + (let ((files command-line-args-left)) + (setq command-line-args-left nil) + (dolist (file files) + (if (file-readable-p file) + (progn + (wot--backup file) + (wot-process-file file budget) + (princ (format ";; wrap-org-table: file=%s reformatted (budget %d)\n" + file budget))) + (princ (format ";; wrap-org-table: file=%s not readable — skipping\n" + file)))))))) + +(defun wot--cli-invocation-p () + "Non-nil when the trailing args look like a real invocation (flags + files), +so the ERT suite can `require' this file without firing the CLI dispatch." + (and command-line-args-left + (cl-every (lambda (a) + (cond ((string-prefix-p "--width=" a) t) + ((string-prefix-p "-" a) nil) + (t (file-readable-p a)))) + command-line-args-left))) + +(when (and noninteractive (wot--cli-invocation-p)) + (wot-main)) + +(provide 'wrap-org-table) +;;; wrap-org-table.el ends here diff --git a/todo.org b/todo.org index bae9d09..94d2f78 100644 --- a/todo.org +++ b/todo.org @@ -34,14 +34,17 @@ Tags are assigned and refreshed by =task-audit=; =task-review= keeps them honest * Rulesets Open Work -** TODO [#C] Wide org-table handling — helper/lint/standard :spec: +** DONE [#C] Wide org-table handling — helper/lint/standard :spec: +CLOSED: [2026-06-11 Thu] :PROPERTIES: -:LAST_REVIEWED: 2026-06-10 +:LAST_REVIEWED: 2026-06-11 :END: The org-table standard keeps project-doc tables <=120 cols with multi-line wrapped cells and a rule between rows, but nothing enforces it and hand-wrapping a wide cell into multi-row form is tedious and error-prone. Decide among: (a) a helper that auto-wraps a wide table into multi-row cells at a target width, (b) a lint check that flags tables over the width budget, (c) tighten the written standard with a worked before/after example. Likely some combination. A worked before/after example exists in a work-project prep doc (a 6-col table reformatted by hand to a 4-col multi-row-cell version), to be reproduced generically when this lands. Out of a work-project handoff 2026-06-09. +Resolution 2026-06-11: all three shipped. (c) The standard, generalized from the work project's notes.org local copy, is now claude-rules/org-tables.md (globally loaded; render-width semantics — links measure at their visible label, never split a link) with the worked wrapped-table example. (a) .ai/scripts/wrap-org-table.el reflows tables mechanically: render-width measurement, link-atomic tokenizing, column shrink-to-floor allocation, continuation rows, rules between logical rows; idempotent (rule-delimited continuation groups merge back before re-wrapping); 23 ERT tests. (b) lint-org.el gained an org-table-standard judgment check (width overruns, missing rules; conformant wrapped tables not false-flagged); 5 new ERT tests, 32 total. Verified end-to-end on a demo file: 150-col table reflowed to budget, idempotent second pass, lint clean on the result. + ** DONE [#C] SessionStart-on-clear hook for auto-resume :feature: CLOSED: [2026-06-11 Thu] :PROPERTIES: -- cgit v1.2.3