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"