From e05ef6fc328b8e1bb19e13474289d3a3eff57b32 Mon Sep 17 00:00:00 2001 From: Daniel Nouri Date: Mon, 16 Mar 2026 10:40:07 +0100 Subject: [PATCH] Help editor integrations rewrite Markdown tables without drifting or hanging Markdown table wrapping is most useful when editor commands can apply it repeatedly to source buffers. Until now that path was awkward and easy to get wrong. Callers had to rediscover table boundaries themselves, guess whether they were looking at source or already-wrapped output, preserve indentation and trailing newlines by hand, and hope they were not sitting at EOF or inside a fenced block. Even when helper code found the right block, wrapped-looking pipe tables with multi-line headers or continuation-style values could still lose structure on rewrite. The README mirrored that brittleness by spelling out the same low-level replacement dance in each recipe. Teach the library the editor-facing operations it was missing. Give it a way to find pipe-table blocks in buffers while skipping fenced code blocks, normalize same-width wrapped output back to source form, and format extracted table blocks so repeated rewrites preserve indentation, newline shape, and recoverable wrapped structure. Merge visual header rows before the separator instead of discarding all but the last one, so helper-driven rewrites keep wrapped-like header content intact. That gives wrapping at point, buffer-wide cleanup, and save-hook workflows one safe path instead of several fragile ones. Rewrite the integration documentation around that safer path and back it with coverage for EOF tables, region discovery, idempotent same-width rewrites, indentation-preserving reinsertions, README-driven editing flows, and pseudo-table blocks with multi-line headers or continuation rows. The result is a table wrapper that behaves predictably in real editing sessions instead of asking every caller to reconstruct its edge cases from scratch. --- README.org | 159 +++++++++--- markdown-table-wrap.el | 270 +++++++++++++++++++-- test/markdown-table-wrap-test.el | 401 +++++++++++++++++++++++++++++++ 3 files changed, 780 insertions(+), 50 deletions(-) diff --git a/README.org b/README.org index 8fe48c6..000d850 100644 --- a/README.org +++ b/README.org @@ -90,7 +90,7 @@ Optional arguments: ;; Cap cell height at 3 lines (truncated cells end with "…") (markdown-table-wrap table-text 60 3) -;; Measure widths from visible text only (for markdown-hide-markup) +;; Measure widths from visible text only (for modes that hide markup) (markdown-table-wrap table-text 60 nil t) ;; Suppress automatic empty rows between wrapped data rows @@ -100,13 +100,20 @@ Optional arguments: ** Unwrapping and re-wrapping Wrapped output is optimized for readable source, not for preserving -exact GFM table semantics in Markdown-to-HTML renderers. +exact GFM table semantics in Markdown-to-HTML renderers. For text +already produced by =markdown-table-wrap=, unwrap first and then +re-wrap: #+begin_src elisp (markdown-table-wrap (markdown-table-wrap-unwrap previously-wrapped) new-width) #+end_src +For same-width editor commands on extracted table text, prefer +=markdown-table-wrap-format-table-block= instead. If you are composing +lower-level pieces yourself, use =markdown-table-wrap-normalize-for-width= +before calling =markdown-table-wrap=. + ** Batch rendering Parse and measure once, render at each width: @@ -115,26 +122,89 @@ Parse and measure once, render at each width: (markdown-table-wrap-batch table-text '(40 60 80 120)) #+end_src -** Integration example +** Integration examples + +The buffer helpers =markdown-table-wrap-table-bounds= and +=markdown-table-wrap-table-regions= locate tables in the current +buffer. =markdown-table-wrap-format-table-block= handles the fiddly +string part: it preserves leading indentation and a trailing newline, +and it normalizes already-wrapped same-width output before rewrapping. +The snippets below work with =markdown-mode=, =md-ts-mode=, and Emacs +31's built-in =markdown-ts-mode=. They default to =fill-column= so +saved files stay stable across window sizes; if you prefer view-relative +wrapping, replace =fill-column= with =(window-body-width)=. + +When wrapping produces multi-line data rows, =markdown-table-wrap= +adds an empty pipe row between logical rows by default for readability; +pass non-nil as the fifth argument to +=markdown-table-wrap-format-table-block= if you prefer compact output. +If your mode hides inline markup, pass non-nil as the fourth argument. +If you rewrap tables across changing widths, prefer keeping the +original table text and wrapping that again. + +*** Shared helper + +#+begin_src elisp +(defun my-markdown-table-wrap--replace-table-region (beg end) + (let* ((text (buffer-substring-no-properties beg end)) + (final (markdown-table-wrap-format-table-block text fill-column))) + (unless (equal text final) + (let ((inhibit-read-only t)) + (goto-char beg) + (delete-region beg end) + (insert final))))) +#+end_src + +*** Wrap table at point + +This is the recommended everyday command: + +#+begin_src elisp +(defun my-markdown-table-wrap-at-point () + "Wrap the pipe table at point to `fill-column'." + (interactive) + (pcase-let ((`(,beg . ,end) + (or (markdown-table-wrap-table-bounds) + (user-error "Point is not in a pipe table")))) + (save-excursion + (my-markdown-table-wrap--replace-table-region beg end)))) + +(defun my-markdown-table-wrap-setup () + (local-set-key (kbd "C-c C-w") #'my-markdown-table-wrap-at-point)) + +(dolist (hook '(markdown-mode-hook md-ts-mode-hook markdown-ts-mode-hook)) + (add-hook hook #'my-markdown-table-wrap-setup)) +#+end_src + +*** Wrap all tables in buffer + +Useful as a cleanup pass before committing or exporting: #+begin_src elisp -(defun my-wrap-table-at-point () - "Wrap the pipe table at point to fit the window." +(defun my-markdown-table-wrap-buffer () + "Wrap all pipe tables in the current buffer." (interactive) (save-excursion - (let* ((beg (progn (re-search-backward "^|" nil t) - (line-beginning-position))) - (end (progn (re-search-forward "^[^|]" nil t) - (line-beginning-position))) - (text (buffer-substring-no-properties beg (1- end))) - (wrapped (markdown-table-wrap - text (window-width) - nil ; max cell height - markdown-hide-markup))) ; t when markup hidden - (unless (equal wrapped text) - (delete-region beg (1- end)) - (goto-char beg) - (insert wrapped))))) + (dolist (bounds (nreverse (markdown-table-wrap-table-regions + (point-min) (point-max)))) + (pcase-let ((`(,beg . ,end) bounds)) + (my-markdown-table-wrap--replace-table-region beg end))))) +#+end_src + +*** Wrap on save + +This is more aggressive, but convenient if you want modified Markdown +buffers normalized on disk. Using =fill-column= keeps the saved layout +deterministic. Emacs only runs =before-save-hook= when a save actually +happens, so a freshly opened clean buffer may stay unchanged until you +edit it or run =my-markdown-table-wrap-buffer= once. + +#+begin_src elisp +(defun my-markdown-table-wrap-enable-on-save () + (add-hook 'before-save-hook #'my-markdown-table-wrap-buffer nil t)) + +(dolist (hook '(markdown-mode-hook md-ts-mode-hook markdown-ts-mode-hook)) + (add-hook hook #'my-markdown-table-wrap-enable-on-save)) #+end_src * Features @@ -145,7 +215,9 @@ Parse and measure once, render at each width: - Alignment preservation (=:---:=, =---:=, =:---=) - Cell height cap with ellipsis - Unwrap/re-wrap for resizing; batch rendering -- Code fence awareness +- Buffer helpers that skip fenced code blocks +- Editor-formatting helper for indentation-preserving integrations +- Buffer-inspection helpers for editor integrations - Unicode-aware (CJK, combining marks, VS16 emoji) - Pure Elisp, no dependencies @@ -161,27 +233,58 @@ Parse and measure once, render at each width: fits. Wrapped headers are no longer valid GFM tables, and wrapped body continuation lines and automatic spacer rows are parsed as additional rows by Markdown renderers. STRIP-MARKUP measures - widths from visible text (for =markdown-hide-markup=). COMPACT + widths from visible text for modes that hide markup. COMPACT suppresses automatic empty rows between wrapped data rows. - ~(markdown-table-wrap-batch TEXT WIDTHS &optional MAX-CELL-HEIGHT STRIP-MARKUP COMPACT)~ Render at each width in WIDTHS. Parses once. +- ~(markdown-table-wrap-normalize-for-width TEXT WIDTH &optional MAX-CELL-HEIGHT STRIP-MARKUP COMPACT)~ + + Return TEXT unchanged, or its unwrapped source form when TEXT already + matches =markdown-table-wrap= output at WIDTH with the same options. + Preserve a trailing newline when TEXT has one. Useful for idempotent + same-width editor commands on source tables. + +- ~(markdown-table-wrap-format-table-block TEXT WIDTH &optional MAX-CELL-HEIGHT STRIP-MARKUP COMPACT)~ + + Wrap extracted buffer text for reinsertion. Preserves the leading + indentation of the first table line, preserves a trailing newline, + and normalizes already-wrapped same-width output before rendering. + - ~(markdown-table-wrap-unwrap TEXT)~ - Merge continuation rows back into logical rows when their - boundaries remain detectable. Best suited for text known to be - produced by =markdown-table-wrap=. + Merge continuation rows back into logical rows when their boundaries + remain detectable. Best suited for text known to be produced by + =markdown-table-wrap=. + +** Buffer helpers + +- ~(markdown-table-wrap-table-bounds &optional POS)~ + + Return the full bounds of the pipe table at POS in the current + buffer, or nil. Skips tables inside fenced code blocks and + pipe-like text without a separator row. + +- ~(markdown-table-wrap-table-regions BEG END)~ + + Return all pipe-table regions overlapping BEG and END, in buffer + order. Useful for buffer-wide commands and save hooks. + +- ~(markdown-table-wrap-inside-code-fence-p POS)~ + + Return non-nil when POS is inside a fenced code block. The package also exposes =markdown-table-wrap-parse=, =markdown-table-wrap-cell=, =markdown-table-wrap-compute-widths=, -=markdown-table-wrap-strip-markup=, =markdown-table-wrap-visible-width=, -and =markdown-table-wrap-inside-code-fence-p=. See their docstrings -for details. +=markdown-table-wrap-strip-markup=, and +=markdown-table-wrap-visible-width=. See their docstrings for +additional details. -All public functions are pure (except =inside-code-fence-p=). No -=defcustom= is defined; configuration is passed as arguments. +The string transformation functions are pure. The buffer helpers +inspect the current buffer but do not modify it. No =defcustom= +is defined; configuration is passed as arguments. * License diff --git a/markdown-table-wrap.el b/markdown-table-wrap.el index 3578b75..86b815c 100644 --- a/markdown-table-wrap.el +++ b/markdown-table-wrap.el @@ -56,6 +56,17 @@ ;; (markdown-table-wrap ;; (markdown-table-wrap-unwrap WRAPPED-TEXT) NEW-WIDTH) ;; +;; For idempotent same-width editor commands on extracted table text, +;; prefer `markdown-table-wrap-format-table-block'. If you are +;; composing the lower-level pieces yourself, use +;; `markdown-table-wrap-normalize-for-width' before wrapping. +;; +;; For editor integrations, `markdown-table-wrap-table-bounds' finds +;; the table at point, `markdown-table-wrap-table-regions' finds all +;; table regions overlapping a buffer range, and +;; `markdown-table-wrap-format-table-block' returns extracted table +;; text ready to reinsert. +;; ;; Features: ;; - Markup-aware wrapping: bold, italic, links, images, code ;; (single and double backtick), and strikethrough spans are @@ -68,8 +79,10 @@ ;; - Optional cell-height cap with ellipsis truncation. ;; - Automatic row separators for visual breathing room when wrapping ;; occurs (opt out with COMPACT). -;; - Code-fence awareness: tables inside ``` or ~~~ blocks are -;; left untouched. +;; - Buffer-helper code-fence awareness: table lookup skips ``` or +;; ~~~ blocks. +;; - Editor-block formatter: preserves extracted indentation and a +;; trailing newline when reinserting wrapped tables. ;; - Unicode-aware width: CJK, combining characters, and VS16 ;; emoji measured correctly for terminal alignment. ;; - Backtick parity guard: cells whose wrapping would produce @@ -736,16 +749,63 @@ in the output regardless of the strip-markup setting." ;;;; Table Parsing +(defconst markdown-table-wrap--table-line-re "^[[:blank:]]*|" + "Regexp matching a pipe-table line in a buffer.") + +(defconst markdown-table-wrap--separator-cell-re + "\\`[[:space:]]*:?-+:?[[:space:]]*\\'" + "Regexp matching one markdown pipe-table separator cell.") + +(defun markdown-table-wrap--table-line-p (&optional line) + "Return non-nil when LINE or the current line begins with a pipe table. +When LINE is non-nil, test that string. Otherwise test the current +buffer line at point." + (if line + (string-match-p markdown-table-wrap--table-line-re line) + (save-excursion + (beginning-of-line) + (looking-at-p markdown-table-wrap--table-line-re)))) + +(defun markdown-table-wrap--separator-line-p (line) + "Return non-nil when LINE is a pipe-table separator row." + (let* ((trimmed (string-trim line)) + (cells (and (string-prefix-p "|" trimmed) + (string-suffix-p "|" trimmed) + (markdown-table-wrap--split-table-row trimmed)))) + (and cells + (cl-every (lambda (cell) + (string-match-p markdown-table-wrap--separator-cell-re cell)) + cells)))) + +(defun markdown-table-wrap--merge-header-visual-rows (visual-rows) + "Merge VISUAL-ROWS into a single header row. +Each visual row is a list of cell strings. Non-empty cell fragments in +later rows are appended to the corresponding earlier cell with spaces." + (when visual-rows + (let* ((num-cols (apply #'max (mapcar #'length visual-rows))) + (merged (make-vector num-cols nil))) + (dolist (row visual-rows) + (cl-loop for cell in row for i from 0 do + (let ((trimmed (string-trim cell))) + (unless (string-empty-p trimmed) + (aset merged i + (if (aref merged i) + (concat (aref merged i) " " trimmed) + trimmed)))))) + (mapcar (lambda (cell) (or cell "")) + (append merged nil))))) + (defun markdown-table-wrap-parse (text) "Parse markdown pipe-table TEXT into structured data. Return (HEADERS ALIGNS ROWS) where: HEADERS is a list of header cell strings (trimmed) ALIGNS is a list of alignment symbols: `left', `right', `center', or nil ROWS is a list of rows, each a list of cell strings (trimmed) -Escaped pipes (`\\|') in cells are preserved and not treated as -column separators." +Multiple visual header lines before the separator are merged into one +header row. Escaped pipes (`\\|') in cells are preserved and not + treated as column separators." (let* ((lines (split-string text "\n" t)) - (headers nil) + (header-vrs nil) (aligns nil) (rows nil) (seen-separator nil)) @@ -753,7 +813,7 @@ column separators." (let ((trimmed (string-trim line))) (cond ;; Separator line: |---|:---:|---:| - ((string-match-p "^|[-:|[:space:]]+|$" trimmed) + ((markdown-table-wrap--separator-line-p trimmed) (setq seen-separator t) (let ((cells (markdown-table-wrap--split-table-row trimmed))) (setq aligns @@ -768,13 +828,15 @@ column separators." cells)))) ;; Header (before separator) ((not seen-separator) - (setq headers (markdown-table-wrap--split-table-row trimmed))) + (push (markdown-table-wrap--split-table-row trimmed) header-vrs)) ;; Data rows (after separator) (t (push (markdown-table-wrap--split-table-row trimmed) rows))))) - (list headers - (or aligns (make-list (length headers) nil)) - (nreverse rows)))) + (let ((headers (markdown-table-wrap--merge-header-visual-rows + (nreverse header-vrs)))) + (list headers + (or aligns (make-list (length headers) nil)) + (nreverse rows))))) ;;;; Column Width Computation @@ -926,6 +988,169 @@ properties, so it is safe to call before `font-lock-ensure'." (setq fence-count (1+ fence-count)))) (cl-oddp fence-count))) +;;;; Buffer Inspection Helpers + +(defun markdown-table-wrap--table-block-has-separator-p (beg end) + "Return non-nil when the table block between BEG and END has a separator row." + (save-excursion + (goto-char beg) + (catch 'found + (while (< (point) end) + (when (markdown-table-wrap--separator-line-p + (buffer-substring-no-properties + (line-beginning-position) (line-end-position))) + (throw 'found t)) + (forward-line 1)) + nil))) + +(defun markdown-table-wrap--table-block-end () + "Move point past the contiguous table block at point and return it. +Point must start on a table line. The return value is the beginning of +the first non-table line, or `point-max' when the block reaches EOF." + (while (and (markdown-table-wrap--table-line-p) + (not (eobp))) + (forward-line 1)) + (when (and (eobp) + (markdown-table-wrap--table-line-p)) + (goto-char (point-max))) + (point)) + +(defun markdown-table-wrap-table-bounds (&optional pos) + "Return bounds of the pipe table at POS, or nil. +The result is a cons (BEG . END) covering the full table block, where +END is the beginning of the first line after the table. Return nil +when POS is not on a pipe-table line, the contiguous block lacks a +separator row, or the block is inside a fenced code block." + (save-excursion + (goto-char (or pos (point))) + (beginning-of-line) + (when (markdown-table-wrap--table-line-p) + (while (and (not (bobp)) + (save-excursion + (forward-line -1) + (markdown-table-wrap--table-line-p))) + (forward-line -1)) + (let ((beg (line-beginning-position))) + (unless (markdown-table-wrap-inside-code-fence-p beg) + (let ((end (markdown-table-wrap--table-block-end))) + (when (markdown-table-wrap--table-block-has-separator-p beg end) + (cons beg end)))))))) + +(defun markdown-table-wrap-table-regions (beg end) + "Return pipe-table regions overlapping BEG and END. +Each element is a cons (TABLE-BEG . TABLE-END) as returned by +`markdown-table-wrap-table-bounds'. Tables are returned in buffer +order, expanded to full bounds even when the region starts or ends in +mid-table. Tables inside fenced code blocks are skipped." + (let ((start (min beg end)) + (limit (max beg end)) + (regions nil)) + (save-excursion + (goto-char start) + (beginning-of-line) + (while (< (point) limit) + (let ((bounds (markdown-table-wrap-table-bounds (point)))) + (if bounds + (progn + (when (> (cdr bounds) start) + (push bounds regions)) + (goto-char (cdr bounds))) + (forward-line 1))))) + (nreverse regions))) + +;;;; Editor Formatting Helpers + +(defun markdown-table-wrap-normalize-for-width (text width + &optional max-cell-height + strip-markup compact) + "Return TEXT or its unwrapped source form for rendering at WIDTH. +If TEXT already matches `markdown-table-wrap' output at WIDTH with the +same MAX-CELL-HEIGHT, STRIP-MARKUP, and COMPACT arguments, return +`markdown-table-wrap-unwrap' of TEXT so same-width rewrites are +idempotent. Preserve a trailing newline when TEXT has one. Otherwise +return TEXT unchanged. + +This helper is meant for editor integrations that operate on source +Markdown tables but may be invoked repeatedly on already-wrapped output. +For applications that rewrap views across changing widths, prefer +storing canonical raw table text instead of reusing rendered output." + (let* ((trailing-newline (string-suffix-p "\n" text)) + (bare-text (if trailing-newline + (substring text 0 -1) + text)) + (unwrapped (markdown-table-wrap-unwrap bare-text)) + (rewrapped (markdown-table-wrap unwrapped width + max-cell-height + strip-markup + compact))) + (if (equal bare-text rewrapped) + (if trailing-newline + (concat unwrapped "\n") + unwrapped) + text))) + +(defun markdown-table-wrap--table-block-indentation (text) + "Return the leading indentation of TEXT's first table line." + (if (string-match "\\`\\([[:blank:]]*\\)|" text) + (match-string 1 text) + "")) + +(defun markdown-table-wrap--deindent-table-block (text indentation) + "Remove INDENTATION from each table line in TEXT when present." + (if (or (string-empty-p text) + (string-empty-p indentation)) + text + (mapconcat (lambda (line) + (if (string-prefix-p indentation line) + (substring line (length indentation)) + line)) + (split-string text "\n" nil) + "\n"))) + +(defun markdown-table-wrap--indent-table-block (text indentation) + "Prefix INDENTATION to each rendered table line in TEXT." + (if (or (string-empty-p text) + (string-empty-p indentation)) + text + (mapconcat (lambda (line) + (concat indentation line)) + (split-string text "\n" nil) + "\n"))) + +(defun markdown-table-wrap-format-table-block (text width + &optional max-cell-height + strip-markup compact) + "Return TEXT wrapped to WIDTH for reinsertion into a buffer. +Preserve the leading indentation of the first table line and any +trailing newline, normalize already-wrapped same-width output via +`markdown-table-wrap-normalize-for-width', then render with +`markdown-table-wrap'. MAX-CELL-HEIGHT, STRIP-MARKUP, and COMPACT +match the optional arguments of `markdown-table-wrap'. + +This helper is meant for editor integrations that extract a table block +with `buffer-substring-no-properties' and then reinsert the result." + (let* ((trailing-newline (string-suffix-p "\n" text)) + (bare-text (if trailing-newline + (substring text 0 -1) + text)) + (indentation (markdown-table-wrap--table-block-indentation bare-text)) + (source (markdown-table-wrap-normalize-for-width + (markdown-table-wrap--deindent-table-block + bare-text indentation) + width + max-cell-height + strip-markup + compact)) + (wrapped (markdown-table-wrap source width + max-cell-height + strip-markup + compact)) + (final (markdown-table-wrap--indent-table-block + wrapped indentation))) + (if trailing-newline + (concat final "\n") + final))) + ;;;; Table Unwrapping (defun markdown-table-wrap--merge-visual-rows (visual-rows) @@ -1001,15 +1226,17 @@ The boundary-detection heuristic: within a logical row, the set of non-empty columns can only shrink. When a previously-empty column reappears with content, a new logical row has started. -Known limitation: when ALL columns wrap to the exact same height, -no column ever goes empty, and the heuristic cannot detect row -boundaries. The entire table is treated as one logical row. For -pixel-perfect fidelity, store the original table. - -This function is idempotent: unwrapping an already-unwrapped table -returns it unchanged. It is composable with `markdown-table-wrap': - (markdown-table-wrap (markdown-table-wrap-unwrap wrapped) new-width) -is the correct resize pipeline." +Known limitation: when row boundaries leave no signal in the visual +rows, the heuristic cannot recover them reliably. This includes some +evenly wrapped tables and some ordinary multi-row source tables whose +data rows keep every column non-empty. For pixel-perfect fidelity, +store the original table. + +This function is best suited for text known to be produced by +`markdown-table-wrap'. For same-width editor commands on source +Markdown tables, prefer `markdown-table-wrap-format-table-block' or, +when composing lower-level pieces yourself, +`markdown-table-wrap-normalize-for-width'." ;; Use the parser for alignment extraction (single source of truth ;; for separator detection and alignment parsing). For row data, ;; we scan lines directly because the parser strips all-empty rows @@ -1025,7 +1252,7 @@ is the correct resize pipeline." (cond ;; Separator line (same regex the parser uses) ((and (not found-sep) - (string-match-p "^|[-:|[:space:]]+|$" trimmed)) + (markdown-table-wrap--separator-line-p trimmed)) (setq found-sep t)) ;; Header rows (before separator) ((not found-sep) @@ -1037,8 +1264,7 @@ is the correct resize pipeline." (setq data-vrs (nreverse data-vrs)) ;; Phase 2: Merge visual rows into logical rows (let* ((merged-headers - (when header-vrs - (car (markdown-table-wrap--merge-visual-rows header-vrs)))) + (markdown-table-wrap--merge-header-visual-rows header-vrs)) (merged-data (when data-vrs (markdown-table-wrap--merge-visual-rows data-vrs))) diff --git a/test/markdown-table-wrap-test.el b/test/markdown-table-wrap-test.el index b8cd4fc..424538c 100644 --- a/test/markdown-table-wrap-test.el +++ b/test/markdown-table-wrap-test.el @@ -1254,6 +1254,261 @@ since the span cannot be broken without corrupting syntax." (insert "| real table |\n|---|\n| data |\n") (should-not (markdown-table-wrap-inside-code-fence-p (point-min))))) +;;;; Buffer Inspection Helpers + +(defun markdown-table-wrap-test--replace-readme-table-region (beg end) + "Apply the README region-replacement helper to BEG and END." + (let* ((text (buffer-substring-no-properties beg end)) + (final (markdown-table-wrap-format-table-block text fill-column))) + (unless (equal text final) + (let ((inhibit-read-only t)) + (goto-char beg) + (delete-region beg end) + (insert final))))) + +(defun markdown-table-wrap-test--readme-wrap-buffer () + "Apply the README buffer-wide wrapping example to the current buffer." + (save-excursion + (dolist (bounds (nreverse (markdown-table-wrap-table-regions + (point-min) (point-max)))) + (markdown-table-wrap-test--replace-readme-table-region + (car bounds) (cdr bounds))))) + +(defconst markdown-table-wrap-test--multiline-header-pseudo-table + (concat + "| Comman | Statu |\n" + "| d | s |\n" + "| ------ | ----- |\n" + "| short | done |\n") + "A wrapped-looking table block with multiple header lines.") + +(defconst markdown-table-wrap-test--continuation-value-pseudo-table + (concat + "| Cmd | Desc |\n" + "| -------- | --------------- |\n" + "| npm | Install all |\n" + "| install | project deps |\n" + "| | from pkg |\n" + "| npm test | Run tests |\n") + "A wrapped-looking table block with continuation-style data rows.") + +(ert-deftest markdown-table-wrap-test-table-bounds-basic () + "Return the full table region at point." + (with-temp-buffer + (insert "before\n| A | B |\n|---|---|\n| 1 | 2 |\nafter\n") + (goto-char (point-min)) + (search-forward "1") + (let ((bounds (markdown-table-wrap-table-bounds (point)))) + (should bounds) + (should (equal (buffer-substring-no-properties + (car bounds) (cdr bounds)) + "| A | B |\n|---|---|\n| 1 | 2 |\n"))))) + +(ert-deftest markdown-table-wrap-test-table-bounds-nil-without-separator () + "Return nil for pipe-like text that is not a table." + (with-temp-buffer + (insert "| just | text |\n") + (goto-char (point-min)) + (search-forward "just") + (should-not (markdown-table-wrap-table-bounds (point))))) + +(ert-deftest markdown-table-wrap-test-table-bounds-nil-with-invalid-separator () + "Return nil when the separator row has no dashes." + (with-temp-buffer + (insert "| A | B |\n| | |\n| 1 | 2 |\n") + (goto-char (point-min)) + (search-forward "1") + (should-not (markdown-table-wrap-table-bounds (point))))) + +(ert-deftest markdown-table-wrap-test-table-bounds-preserves-indentation () + "Return bounds that preserve leading indentation on table lines." + (with-temp-buffer + (insert " | A | B |\n |---|---|\n | 1 | 2 |\n") + (goto-char (point-min)) + (search-forward "1") + (let ((bounds (markdown-table-wrap-table-bounds (point)))) + (should bounds) + (should (equal (buffer-substring-no-properties + (car bounds) (cdr bounds)) + " | A | B |\n |---|---|\n | 1 | 2 |\n"))))) + +(ert-deftest markdown-table-wrap-test-table-bounds-nil-inside-code-fence () + "Return nil for table-like text inside fenced code blocks." + (with-temp-buffer + (insert "```markdown\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n") + (goto-char (point-min)) + (search-forward "1") + (should-not (markdown-table-wrap-table-bounds (point))))) + +(ert-deftest markdown-table-wrap-test-table-bounds-from-header-line () + "Return full bounds when point is on the header line." + (with-temp-buffer + (insert "| A | B |\n|---|---|\n| 1 | 2 |\n") + (goto-char (point-min)) + (search-forward "A") + (let ((bounds (markdown-table-wrap-table-bounds (point)))) + (should bounds) + (should (equal (buffer-substring-no-properties + (car bounds) (cdr bounds)) + "| A | B |\n|---|---|\n| 1 | 2 |\n"))))) + +(ert-deftest markdown-table-wrap-test-table-bounds-from-separator-line () + "Return full bounds when point is on the separator line." + (with-temp-buffer + (insert "| A | B |\n|---|---|\n| 1 | 2 |\n") + (goto-char (point-min)) + (search-forward "---") + (let ((bounds (markdown-table-wrap-table-bounds (point)))) + (should bounds) + (should (equal (buffer-substring-no-properties + (car bounds) (cdr bounds)) + "| A | B |\n|---|---|\n| 1 | 2 |\n"))))) + +(ert-deftest markdown-table-wrap-test-table-bounds-at-eof-without-trailing-newline () + "Return bounds for a table that ends at EOF without hanging." + (with-temp-buffer + (insert "| A | B |\n|---|---|\n| 1 | 2 |") + (goto-char (point-min)) + (search-forward "1") + (let ((bounds (markdown-table-wrap-table-bounds (point)))) + (should bounds) + (should (equal (buffer-substring-no-properties + (car bounds) (cdr bounds)) + "| A | B |\n|---|---|\n| 1 | 2 |"))))) + +(ert-deftest markdown-table-wrap-test-table-bounds-from-multiline-header-second-line () + "Return the full block when point is on a pseudo-table header continuation line." + (with-temp-buffer + (insert "before\n") + (insert markdown-table-wrap-test--multiline-header-pseudo-table) + (insert "after\n") + (goto-char (point-min)) + (search-forward "| d") + (let ((bounds (markdown-table-wrap-table-bounds (point)))) + (should bounds) + (should (equal (buffer-substring-no-properties + (car bounds) (cdr bounds)) + markdown-table-wrap-test--multiline-header-pseudo-table))))) + +(ert-deftest markdown-table-wrap-test-table-bounds-from-continuation-value-line () + "Return the full block when point is on a continuation-style value line." + (with-temp-buffer + (insert "before\n") + (insert markdown-table-wrap-test--continuation-value-pseudo-table) + (insert "after\n") + (goto-char (point-min)) + (search-forward "from pkg") + (let ((bounds (markdown-table-wrap-table-bounds (point)))) + (should bounds) + (should (equal (buffer-substring-no-properties + (car bounds) (cdr bounds)) + markdown-table-wrap-test--continuation-value-pseudo-table))))) + +(ert-deftest markdown-table-wrap-test-table-regions-finds-multiple-tables () + "Return all table regions in a buffer region, skipping code fences." + (with-temp-buffer + (insert (concat + "| A | B |\n|---|---|\n| 1 | 2 |\n\n" + "```markdown\n| X | Y |\n|---|---|\n| 3 | 4 |\n```\n\n" + "| C | D |\n|---|---|\n| 5 | 6 |\n")) + (let ((regions (markdown-table-wrap-table-regions (point-min) (point-max)))) + (should (= (length regions) 2)) + (should (equal (mapcar (lambda (bounds) + (buffer-substring-no-properties + (car bounds) (cdr bounds))) + regions) + '("| A | B |\n|---|---|\n| 1 | 2 |\n" + "| C | D |\n|---|---|\n| 5 | 6 |\n")))))) + +(ert-deftest markdown-table-wrap-test-table-regions-expand-overlap () + "Expand region matches to full table bounds when region starts inside a table." + (with-temp-buffer + (insert "| A | B |\n|---|---|\n| 1 | 2 |\n\n") + (goto-char (point-min)) + (search-forward "1") + (let* ((beg (point)) + (end (point-max)) + (regions (markdown-table-wrap-table-regions beg end))) + (should (= (length regions) 1)) + (should (equal (buffer-substring-no-properties + (caar regions) (cdar regions)) + "| A | B |\n|---|---|\n| 1 | 2 |\n"))))) + +(ert-deftest markdown-table-wrap-test-table-regions-expand-overlap-at-end () + "Expand region matches to full table bounds when region ends inside a table." + (with-temp-buffer + (insert "before\n| A | B |\n|---|---|\n| 1 | 2 |\nafter\n") + (goto-char (point-min)) + (search-forward "1") + (let* ((beg (point-min)) + (end (point)) + (regions (markdown-table-wrap-table-regions beg end))) + (should (= (length regions) 1)) + (should (equal (buffer-substring-no-properties + (caar regions) (cdar regions)) + "| A | B |\n|---|---|\n| 1 | 2 |\n"))))) + +(ert-deftest markdown-table-wrap-test-table-regions-find-normal-and-pseudo-tables-skip-fenced-pseudo-tables () + "Return normal and pseudo tables, but skip fenced pseudo-table blocks." + (with-temp-buffer + (insert "| A | B |\n|---|---|\n| 1 | 2 |\n\n") + (insert "Paragraph.\n\n") + (insert markdown-table-wrap-test--multiline-header-pseudo-table) + (insert "\n```markdown\n") + (insert markdown-table-wrap-test--multiline-header-pseudo-table) + (insert "```\n\n") + (insert markdown-table-wrap-test--continuation-value-pseudo-table) + (let ((regions (markdown-table-wrap-table-regions (point-min) (point-max)))) + (should (equal (mapcar (lambda (bounds) + (buffer-substring-no-properties + (car bounds) (cdr bounds))) + regions) + (list "| A | B |\n|---|---|\n| 1 | 2 |\n" + markdown-table-wrap-test--multiline-header-pseudo-table + markdown-table-wrap-test--continuation-value-pseudo-table)))))) + +(ert-deftest markdown-table-wrap-test-table-regions-expand-overlap-from-multiline-header-second-line () + "Expand region matches when the region starts inside a pseudo-header line." + (with-temp-buffer + (insert "before\n") + (insert markdown-table-wrap-test--multiline-header-pseudo-table) + (insert "after\n") + (goto-char (point-min)) + (search-forward "| d") + (let* ((beg (point)) + (end (point-max)) + (regions (markdown-table-wrap-table-regions beg end))) + (should (= (length regions) 1)) + (should (equal (buffer-substring-no-properties + (caar regions) (cdar regions)) + markdown-table-wrap-test--multiline-header-pseudo-table))))) + +(ert-deftest markdown-table-wrap-test-table-regions-expand-overlap-at-continuation-value-line () + "Expand region matches when the region ends inside a continuation-style value line." + (with-temp-buffer + (insert "before\n") + (insert markdown-table-wrap-test--continuation-value-pseudo-table) + (insert "after\n") + (goto-char (point-min)) + (search-forward "from pkg") + (let* ((beg (point-min)) + (end (point)) + (regions (markdown-table-wrap-table-regions beg end))) + (should (= (length regions) 1)) + (should (equal (buffer-substring-no-properties + (caar regions) (cdar regions)) + markdown-table-wrap-test--continuation-value-pseudo-table))))) + +(ert-deftest markdown-table-wrap-test-table-regions-at-eof-without-trailing-newline () + "Return the final table region at EOF without a trailing newline." + (with-temp-buffer + (insert "before\n\n| A | B |\n|---|---|\n| 1 | 2 |") + (let ((regions (markdown-table-wrap-table-regions (point-min) (point-max)))) + (should (= (length regions) 1)) + (should (equal (buffer-substring-no-properties + (caar regions) (cdar regions)) + "| A | B |\n|---|---|\n| 1 | 2 |"))))) + ;;;; Backtick Parity Helper (ert-deftest markdown-table-wrap-test-odd-backtick-line-p-detects-odd () @@ -1398,6 +1653,152 @@ reappears with content." (should (equal (nth 0 parsed1) (nth 0 parsed2))) (should (equal (nth 2 parsed1) (nth 2 parsed2))))) +(ert-deftest markdown-table-wrap-test-normalize-for-width-keeps-unwrapped-multi-row-table () + "Normalization keeps an ordinary multi-row source table unchanged." + (let ((input (concat + "| A | B | C |\n" + "|---|---|---|\n" + "| Authentication | In progress | OAuth2 refresh handling |\n" + "| Database | Planned | Backup rehearsal checklist |"))) + (should (equal (markdown-table-wrap-normalize-for-width input 60) + input)))) + +(ert-deftest markdown-table-wrap-test-normalize-for-width-unwraps-same-width-output () + "Normalization unwraps output already rendered at the target width." + (let* ((source (concat + "| A | B |\n" + "|---|---|\n" + "| Authentication | OAuth2 implementation still needs token refresh handling |")) + (wrapped (markdown-table-wrap source 40)) + (normalized (markdown-table-wrap-normalize-for-width wrapped 40))) + (should (equal (markdown-table-wrap normalized 40) wrapped)) + (should (equal normalized (markdown-table-wrap-unwrap wrapped))))) + +(ert-deftest markdown-table-wrap-test-normalize-for-width-preserves-trailing-newline () + "Normalization preserves a trailing newline on already-wrapped text." + (let* ((source (concat + "| A | B |\n" + "|---|---|\n" + "| Authentication | OAuth2 implementation still needs token refresh handling |")) + (wrapped (concat (markdown-table-wrap source 40) "\n")) + (normalized (markdown-table-wrap-normalize-for-width wrapped 40))) + (should (string-suffix-p "\n" normalized)) + (should (equal normalized + (concat (markdown-table-wrap-unwrap + (substring wrapped 0 -1)) + "\n"))))) + +(ert-deftest markdown-table-wrap-test-format-table-block-preserves-indentation () + "Formatting preserves the source indentation on every rendered line." + (let* ((source (concat + " | A | B |\n" + " |---|---|\n" + " | Authentication | OAuth2 implementation still needs token refresh handling |\n")) + (formatted (markdown-table-wrap-format-table-block source 40))) + (dolist (line (split-string (string-trim-right formatted "\n") "\n")) + (should (string-prefix-p " |" line))))) + +(ert-deftest markdown-table-wrap-test-format-table-block-preserves-trailing-newline () + "Formatting preserves a trailing newline from extracted buffer text." + (let* ((source (concat + "| A | B |\n" + "|---|---|\n" + "| Authentication | OAuth2 implementation still needs token refresh handling |\n")) + (formatted (markdown-table-wrap-format-table-block source 40))) + (should (string-suffix-p "\n" formatted)))) + +(ert-deftest markdown-table-wrap-test-format-table-block-normalizes-same-width-output () + "Formatting is idempotent for already-wrapped text at the same width." + (let* ((source (concat + " | A | B |\n" + " |---|---|\n" + " | Authentication | OAuth2 implementation still needs token refresh handling |\n")) + (once (markdown-table-wrap-format-table-block source 40)) + (twice (markdown-table-wrap-format-table-block once 40))) + (should (equal twice once)))) + +(ert-deftest markdown-table-wrap-test-format-table-block-preserves-logical-rows-for-continuation-values () + "Formatting preserves logical rows in recoverable continuation-style data." + (let* ((formatted (markdown-table-wrap-format-table-block + markdown-table-wrap-test--continuation-value-pseudo-table + 40)) + (parsed (markdown-table-wrap-parse + (markdown-table-wrap-unwrap formatted)))) + (should (equal (nth 0 parsed) '("Cmd" "Desc"))) + (should (equal (nth 2 parsed) + '(("npm install" "Install all project deps from pkg") + ("npm test" "Run tests")))))) + +(ert-deftest markdown-table-wrap-test-format-table-block-preserves-merged-multiline-header-content () + "Formatting preserves merged header content in wrapped-looking header blocks." + (let* ((formatted (markdown-table-wrap-format-table-block + markdown-table-wrap-test--multiline-header-pseudo-table + 40)) + (parsed (markdown-table-wrap-parse + (markdown-table-wrap-unwrap formatted)))) + (should (equal (nth 0 parsed) + '("Comman d" "Statu s"))))) + +(ert-deftest markdown-table-wrap-test-readme-buffer-example-preserves-row-boundaries () + "The README buffer example keeps separate logical rows separate." + (with-temp-buffer + (let ((fill-column 60)) + (insert (concat + "Intro text before the table.\n\n" + "| Feature | Status | Notes |\n" + "|---------|--------|-------|\n" + "| Auth | In progress | OAuth2 implementation still needs token refresh handling and a clearer audit logging story for support handoffs |\n" + "| Billing | Planned | Invoice export needs formatting review plus reconciliation notes that finance can approve before rollout |\n")) + (markdown-table-wrap-test--readme-wrap-buffer) + (goto-char (point-min)) + (search-forward "Feature") + (let* ((bounds (markdown-table-wrap-table-bounds (point))) + (table-text (buffer-substring-no-properties + (car bounds) (cdr bounds))) + (parsed (markdown-table-wrap-parse + (markdown-table-wrap-unwrap table-text)))) + (should (equal (nth 2 parsed) + '(("Auth" "In progress" "OAuth2 implementation still needs token refresh handling and a clearer audit logging story for support handoffs") + ("Billing" "Planned" "Invoice export needs formatting review plus reconciliation notes that finance can approve before rollout")))))))) + +(ert-deftest markdown-table-wrap-test-readme-buffer-example-preserves-indentation () + "The README buffer example keeps table indentation intact." + (with-temp-buffer + (let ((fill-column 40)) + (insert (concat + "- Nested list item\n\n" + " | Feature | Notes |\n" + " |---------|-------|\n" + " | Auth | OAuth2 implementation still needs token refresh handling |\n")) + (markdown-table-wrap-test--readme-wrap-buffer) + (goto-char (point-min)) + (search-forward "Feature") + (let* ((bounds (markdown-table-wrap-table-bounds (point))) + (table-text (buffer-substring-no-properties + (car bounds) (cdr bounds)))) + (dolist (line (split-string (string-trim-right table-text "\n") "\n")) + (should (string-prefix-p " |" line))))))) + +(ert-deftest markdown-table-wrap-test-readme-buffer-example-is-idempotent () + "The README buffer example can be applied twice without changing output." + (with-temp-buffer + (let ((fill-column 60)) + (insert (concat + "Intro text before the table.\n\n" + "| Feature | Status | Notes |\n" + "|---------|--------|-------|\n" + "| Auth | In progress | OAuth2 implementation still needs token refresh handling and a clearer audit logging story for support handoffs |\n" + "| Billing | Planned | Invoice export needs formatting review plus reconciliation notes that finance can approve before rollout |\n\n" + "```markdown\n" + "| fake | table |\n" + "|------|-------|\n" + "| inside | fence |\n" + "```\n")) + (markdown-table-wrap-test--readme-wrap-buffer) + (let ((once (buffer-string))) + (markdown-table-wrap-test--readme-wrap-buffer) + (should (equal (buffer-string) once)))))) + (ert-deftest markdown-table-wrap-test-unwrap-header-wrapping () "Wrapped header lines are merged back into a single header row." (let* ((wrapped (concat "| Comman | Statu |\n"