diff --git a/Makefile b/Makefile index 6bf5f71..a0d2c02 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,11 @@ SHELL := /bin/bash EMACS ?= emacs BATCH = $(EMACS) --batch -Q -L . +# Source files shipped as packages (each is its own Emacs package). +SOURCES = markdown-table-wrap.el markdown-table-wrap-pretty.el +# Test files (features, without the .el extension), each loaded with -l. +TEST_FILES = markdown-table-wrap-test markdown-table-wrap-pretty-test + # Test selector: run a subset of tests by ERT pattern # Example: make test SELECTOR=parse SELECTOR ?= @@ -33,7 +38,7 @@ test: @set -o pipefail; \ OUTPUT=$$(mktemp); \ $(BATCH) -L test \ - -l markdown-table-wrap-test \ + $(patsubst %,-l %,$(TEST_FILES)) \ $(if $(SELECTOR),--eval '(ert-run-tests-batch-and-exit "$(SELECTOR)")',-f ert-run-tests-batch-and-exit) \ >$$OUTPUT 2>&1; \ STATUS=$$?; \ @@ -50,18 +55,22 @@ compile: @echo "=== Byte-compile ===" @$(BATCH) \ --eval "(setq byte-compile-error-on-warn t)" \ - -f batch-byte-compile markdown-table-wrap.el + -f batch-byte-compile $(SOURCES) lint: lint-checkdoc lint-package lint-checkdoc: @echo "=== Checkdoc ===" - @OUTPUT=$$($(BATCH) \ - --eval "(require 'checkdoc)" \ - --eval "(setq sentence-end-double-space nil)" \ - --eval "(checkdoc-file \"markdown-table-wrap.el\")" 2>&1); \ - WARNINGS=$$(echo "$$OUTPUT" | grep -A1 "^Warning" | grep -v "^Warning\|^--$$"); \ - if [ -n "$$WARNINGS" ]; then echo "$$WARNINGS"; exit 1; else echo "OK"; fi + @STATUS=0; \ + for f in $(SOURCES); do \ + OUTPUT=$$($(BATCH) \ + --eval "(require 'checkdoc)" \ + --eval "(setq sentence-end-double-space nil)" \ + --eval "(checkdoc-file \"$$f\")" 2>&1); \ + WARNINGS=$$(echo "$$OUTPUT" | grep -A1 "^Warning" | grep -v "^Warning\|^--$$"); \ + if [ -n "$$WARNINGS" ]; then echo "$$WARNINGS"; STATUS=1; fi; \ + done; \ + if [ $$STATUS -eq 0 ]; then echo "OK"; else exit 1; fi lint-package: @echo "=== Package-lint ===" @@ -69,23 +78,28 @@ lint-package: --eval "(require 'package)" \ --eval "(push '(\"melpa\" . \"https://melpa.org/packages/\") package-archives)" \ --eval "(package-initialize)" \ + --eval "(package-refresh-contents)" \ --eval "(unless (package-installed-p 'package-lint) \ - (package-refresh-contents) \ (package-install 'package-lint))" \ --eval "(require 'package-lint)" \ --eval "(setq package-lint-main-file \"markdown-table-wrap.el\")" \ - -f package-lint-batch-and-exit markdown-table-wrap.el + -f package-lint-batch-and-exit $(SOURCES) check-parens: @echo "=== Check Parens ===" @OUTPUT=$$($(BATCH) \ - --eval '(condition-case err \ - (with-current-buffer (find-file-noselect "markdown-table-wrap.el") \ - (check-parens) \ - (message "markdown-table-wrap.el OK")) \ - (user-error \ - (message "FAIL: %s" (error-message-string err)) \ - (kill-emacs 1)))' 2>&1); \ + --eval '(let ((status 0)) \ + (mapc (lambda (f) \ + (condition-case err \ + (with-current-buffer (find-file-noselect f) \ + (check-parens) \ + (message "%s OK" f)) \ + (user-error \ + (message "FAIL: %s: %s" f (error-message-string err)) \ + (setq status 1)))) \ + command-line-args-left) \ + (kill-emacs status))' \ + $(SOURCES) 2>&1); \ echo "$$OUTPUT" | grep -E "OK$$|FAIL:"; \ echo "$$OUTPUT" | grep -q "FAIL:" && exit 1 || true diff --git a/README.org b/README.org index 8fe48c6..d2af37b 100644 --- a/README.org +++ b/README.org @@ -8,6 +8,13 @@ Word-wrap GFM (GitHub Flavored Markdown) pipe tables to fit a given character width. Inline markup survives wrapping. +This repository also ships an OPTIONAL display companion, +=markdown-table-wrap-pretty= (documented below), which renders tables +as a wrapped, box-drawing view on top of the canonical raw text (like +=org-latex-preview= for tables) and toggles between that view and the +editable source. It is a separate package and changes nothing unless +you load it. + #+begin_example | Feature | Status | Notes | |-----------|--------|-------------------------------| @@ -149,6 +156,82 @@ Parse and measure once, render at each width: - Unicode-aware (CJK, combining marks, VS16 emoji) - Pure Elisp, no dependencies +* Optional: pretty display of pipe tables + +=markdown-table-wrap-pretty= is a separate, opt-in package shipped in +this repository. It renders markdown and org pipe tables as a wrapped, +Unicode box-drawing "pretty" view ON TOP OF the canonical raw table +text, and toggles between that view and the raw source in place — the +table analogue of =org-latex-preview= or inline image previews. + +** Why display-only + +The buffer text is always the canonical raw pipe table; nothing is +rewritten. =markdown-table-wrap= wraps a long cell into multiple +=| ... |= rows, but pipe-table syntax has no row-spanning, so a real +renderer (cmark-gfm, pandoc, GitHub, org HTML export) would see those +as independent data rows. Mutating the buffer looks right in Emacs but +is semantically broken the moment the text leaves Emacs. +=markdown-table-wrap-pretty= keeps the buffer canonical — export, +copy, share, and push always see a real table — and makes the pretty +rendering a disposable display layer. + +** Commands + +| Command | Action | +|---|---| +| =markdown-table-wrap-pretty-toggle= | Point-aware toggle: on a table toggles it; off-table toggles all; a region toggles tables in it; a prefix arg forces pretty or raw. | +| =markdown-table-wrap-pretty-buffer= | Force pretty on every table in the buffer. | +| =markdown-table-wrap-pretty-region= | Force pretty on every table in the region. | +| =markdown-table-wrap-pretty-mode= | Buffer-local minor mode: wires the resize re-render hook and optional default-pretty per major mode. | + +Editing a pretty table auto-reveals the raw source (via +=modification-hooks=, the =org-latex-preview= pattern — not +=read-only=, which would break =undo=), and toggling back re-renders +fresh from the canonical source. + +** Customization + +| Option | Default | Meaning | +|---|---|---| +| =markdown-table-wrap-pretty-prettify= | =t= | Unicode box-drawing borders; =nil= uses plain ASCII pipes. | +| =markdown-table-wrap-pretty-auto-rewrap-on-resize= | =t= | Re-render pretty tables on window resize (lossless). | +| =markdown-table-wrap-pretty-rewrap-idle-delay= | =0.3= | Seconds to debounce resize re-render (=0= is immediate). | +| =markdown-table-wrap-pretty-default-on-major-modes= | =nil= | Modes where tables start pretty when the minor mode is on. | + +** Installation and keys + +Load the package (it depends on =markdown-table-wrap=): + +#+begin_src elisp +(require 'markdown-table-wrap-pretty) +#+end_src + +The package ships no default key. A consistent cross-mode choice is +=C-c C-x C-k=: + +#+begin_src elisp +(define-key markdown-mode-map (kbd "C-c C-x C-k") + #'markdown-table-wrap-pretty-toggle) +(define-key org-mode-map (kbd "C-c C-x C-k") + #'markdown-table-wrap-pretty-toggle) +#+end_src + +Or, with =use-package=, turn it on per major mode: + +#+begin_src elisp +(use-package markdown-table-wrap-pretty + :after (markdown-mode org) + :hook (markdown-mode . markdown-table-wrap-pretty-mode) + :hook (org-mode . markdown-table-wrap-pretty-mode)) +#+end_src + +Detection is universal and line-based, code-fence guarded, with no +tree-sitter dependency, so it works in =markdown-mode=, =gfm-mode=, +=md-ts-mode=, and =org-mode=. Org =|---+---|= separators and width +cookies are normalized for rendering only; the raw buffer keeps them. +=#+TBLFM:= formula lines stay visible and untouched while pretty. + * API ** Primary functions @@ -180,8 +263,11 @@ The package also exposes =markdown-table-wrap-parse=, and =markdown-table-wrap-inside-code-fence-p=. See their docstrings for details. -All public functions are pure (except =inside-code-fence-p=). No -=defcustom= is defined; configuration is passed as arguments. +For the =markdown-table-wrap= engine, all public functions are pure +(except =inside-code-fence-p=). No =defcustom= is defined; +configuration is passed as arguments. (The optional +=markdown-table-wrap-pretty= display layer is a separate package with +its own =defcustom=s; see above.) * License diff --git a/markdown-table-wrap-pretty.el b/markdown-table-wrap-pretty.el new file mode 100644 index 0000000..aa5b603 --- /dev/null +++ b/markdown-table-wrap-pretty.el @@ -0,0 +1,886 @@ +;;; markdown-table-wrap-pretty.el --- Display-only pretty rendering of pipe tables -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 SayreBlades + +;; Author: SayreBlades +;; URL: https://github.com/dnouri/markdown-table-wrap +;; Keywords: convenience, wp, markdown, org, tables + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;; This file is not part of GNU Emacs. + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. +;; +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. +;; +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . + +;;; Commentary: + +;; This is the OPTIONAL display companion to `markdown-table-wrap'. +;; It renders markdown and org pipe tables as a wrapped, box-drawing +;; "pretty" view ON TOP OF the canonical raw table text, and toggles +;; between that pretty view and the raw source in place. It is the +;; table analogue of inline images or `org-latex-preview': +;; +;; - The buffer text is always the CANONICAL raw pipe table. Nothing +;; is ever rewritten, so export / copy / share / push-to-GitHub / +;; send-to-LLM always see a valid, single-row-per-record table. +;; - A display overlay shows a pretty rendering (wrapped to the +;; window, Unicode box-drawing borders by default). +;; - One command flips the overlay off to reveal/edit the raw source, +;; and back on to re-render. +;; +;; Why display-only (not buffer mutation): `markdown-table-wrap' wraps a +;; long cell into multiple `| ... |' rows, but pipe-table syntax has no +;; row-spanning, so any real renderer (cmark-gfm, pandoc, GitHub, org +;; HTML export) sees N independent data rows, not one wrapped cell. +;; Mutating the buffer looks right in Emacs but is semantically broken +;; the moment the text leaves Emacs. This package keeps the buffer +;; canonical and makes the pretty rendering a disposable display layer. +;; +;; Model (mirrors `org-latex-preview', `C-c C-x C-l'): +;; - One display overlay per raw line (so `C-n' walks raw lines and big +;; tables scroll naturally; the toggle is still whole-table). +;; - Pretty = a view; editing reveals the raw source automatically via +;; `modification-hooks' that remove the overlays on edit (the same +;; pattern `org-latex-preview' uses, NOT `read-only', which would +;; break undo). Toggle back to re-render from canonical. +;; - State = overlay presence (no bookkeeping): "is this table +;; pretty?" = "does it have `markdown-table-wrap-pretty-display' +;; overlays?". +;; - Resize re-renders pretty tables losslessly (regenerate from +;; canonical; never unwrap). Tables toggled to raw stay raw. +;; +;; Toggle command (`markdown-table-wrap-pretty-toggle') is point-aware, +;; like `org-latex-preview': +;; - Point on a table -> toggle that table. +;; - Point off-table -> toggle all tables in the buffer. +;; - Active region -> toggle tables overlapping the region. +;; - `C-u' -> force pretty on all tables in buffer. +;; - `C-u C-u' -> force raw on all tables in buffer. +;; +;; Detection is universal line-based (`^[ \t]*|' runs, code-fence +;; guarded for markdown flavors, `org-in-src-block-p' for org) — no +;; tree-sitter dependency, so it works in `markdown-mode', `gfm-mode', +;; `md-ts-mode', and `org-mode'. +;; +;; Org: `|---+---|' hlines and width cookies `<[lrc]?[0-9]*>' are +;; stripped for RENDERING only; the raw buffer keeps them. A trailing +;; `#+TBLFM:' line is outside the table region and stays visible and +;; unchanged while pretty. This package does NOT refuse `#+TBLFM' +;; tables. +;; +;; Keybindings: this package ships NO default key (it owns no +;; major-mode map). A consistent cross-mode key is the user's to +;; choose; one free, non-surprising slot in both `markdown-mode' and +;; `org-mode' is `C-c C-x C-k'. Example: +;; +;; (require 'markdown-table-wrap-pretty) +;; (define-key markdown-mode-map (kbd "C-c C-x C-k") +;; #'markdown-table-wrap-pretty-toggle) +;; (define-key org-mode-map (kbd "C-c C-x C-k") +;; #'markdown-table-wrap-pretty-toggle) +;; +;; Depends only on `markdown-table-wrap' (public API: `markdown-table-wrap', +;; `markdown-table-wrap-cell', `markdown-table-wrap-compute-widths', +;; `markdown-table-wrap-parse', `markdown-table-wrap-visible-width', and +;; `markdown-table-wrap-inside-code-fence-p'). There is no `markdown-mode' +;; or `org' dependency at load time; those features are called lazily, so +;; the file loads even when they are absent. + +;;; Code: + +(require 'cl-lib) +(require 'subr-x) +(require 'markdown-table-wrap) + +(declare-function org-in-src-block-p "org" (&optional inside)) + +;;;; Customization + +(defgroup markdown-table-wrap-pretty nil + "Display-only pretty rendering of pipe tables." + :group 'text + :group 'convenience) + +(defcustom markdown-table-wrap-pretty-prettify t + "Non-nil means render pretty tables with Unicode box-drawing characters. +Nil means use plain markdown pipes (`|')." + :type 'boolean + :group 'markdown-table-wrap-pretty) + +(defcustom markdown-table-wrap-pretty-auto-rewrap-on-resize t + "Non-nil means re-render pretty tables when the window is resized. +Re-rendering is lossless (regenerate overlays from canonical source). +Tables toggled to raw stay raw. Nil disables the resize hook." + :type 'boolean + :group 'markdown-table-wrap-pretty) + +(defcustom markdown-table-wrap-pretty-default-on-major-modes nil + "Major modes where tables default to pretty when the minor mode is on. +A list of major-mode symbols. Default is nil, so tables start raw +everywhere and you toggle on demand. Add symbols such as +`markdown-mode' or `org-mode' to start tables pretty in those modes." + :type '(repeat symbol) + :group 'markdown-table-wrap-pretty) + +(defcustom markdown-table-wrap-pretty-rewrap-idle-delay 0.3 + "Seconds to wait after a resize before re-rendering pretty tables. +0 means immediate. Debounces window drag." + :type 'number + :group 'markdown-table-wrap-pretty) + +(defface markdown-table-wrap-pretty-code-face + '((t :inherit fixed-pitch)) + "Face used for inline code spans in pretty tables. +Defaults to `fixed-pitch'. Customize this face for a different look; +to match a surrounding `markdown-mode' buffer, inherit +`markdown-inline-code-face'. Applied by +`markdown-table-wrap-pretty-render-inline-spans'." + :group 'markdown-table-wrap-pretty) + +;;;; Buffer-Local State + +;; Forward declaration: `markdown-table-wrap-pretty-mode' is defined by +;; `define-minor-mode' below; it is referenced earlier by +;; `markdown-table-wrap-pretty--schedule-refresh' and +;; `markdown-table-wrap-pretty--maybe-default-pretty'. +(defvar markdown-table-wrap-pretty-mode nil) + +(defvar-local markdown-table-wrap-pretty--last-width nil + "Last window width used for table rendering in this buffer.") + +(defvar-local markdown-table-wrap-pretty--refresh-timer nil + "Idle timer for debounced resize re-render in this buffer.") + +;;;; Width + +(defun markdown-table-wrap-pretty--display-window () + "Return a visible window showing the current buffer, or nil." + (or (get-buffer-window (current-buffer) nil) + (get-buffer-window (current-buffer) 'visible))) + +(defun markdown-table-wrap-pretty--window-width () + "Return usable character columns for the current buffer's window. +Fall back to 80 when the buffer has no visible window. NOTE: this is +the raw window capacity and does NOT account for per-line visual +prefixes such as `line-prefix'/`wrap-prefix' (set by `org-indent-mode'). +Use `markdown-table-wrap-pretty--effective-width-at' for the width +actually available at a given buffer position." + (if-let* ((win (markdown-table-wrap-pretty--display-window))) + (or (window-max-chars-per-line win) 80) + 80)) + +(defun markdown-table-wrap-pretty--prefix-display-width (spec) + "Return the display width of a `line-prefix'/`wrap-prefix' value SPEC. +SPEC may be nil, a string, or a display spec; only the string case is +measured (what `org-indent-mode' sets). Return 0 otherwise." + (cond + ((stringp spec) + (if (= (length spec) (string-bytes spec)) + (length spec) ; ASCII fast path (spaces) + (let ((w 0) (i 0) (n (length spec))) + (while (< i n) (setq w (+ w (char-width (aref spec i))) i (1+ i))) + w))) + (t 0))) + +(defun markdown-table-wrap-pretty--indent-width-at (pos) + "Return the visual indent width consumed by text properties at POS. +Account for `line-prefix' and `wrap-prefix' (set by `org-indent-mode' +and other indentation minor modes), which eat columns that +`window-max-chars-per-line' does not see." + (let ((line-pfx (get-text-property pos 'line-prefix)) + (wrap-pfx (get-text-property pos 'wrap-prefix))) + (+ (markdown-table-wrap-pretty--prefix-display-width line-pfx) + (markdown-table-wrap-pretty--prefix-display-width wrap-pfx)))) + +(defun markdown-table-wrap-pretty--effective-width-at (&optional pos) + "Return the usable table width at POS (default: point). +Window capacity minus any visual indent prefix at POS (e.g. org-indent). +Never return less than 1." + (max 1 (- (markdown-table-wrap-pretty--window-width) + (markdown-table-wrap-pretty--indent-width-at (or pos (point)))))) + +;;;; Detection + +(defconst markdown-table-wrap-pretty--table-line-re "\\`[[:blank:]]*|" + "Regexp matching the start of a pipe-table line. +The leading backtick anchors to the start of a line STRING (used via +`string-match-p'), not to `point-min'. See +`markdown-table-wrap-pretty--table-line-p'.") + +(defun markdown-table-wrap-pretty--table-line-p (&optional line) + "Return non-nil when LINE (or the current line) is a pipe-table line." + (if line + (string-match-p markdown-table-wrap-pretty--table-line-re line) + ;; Operate on the line STRING so the regex backtick anchors to the + ;; line start, not `point-min' (which `looking-at-p' would do). + (save-excursion + (beginning-of-line) + (string-match-p + markdown-table-wrap-pretty--table-line-re + (buffer-substring-no-properties + (point) (line-end-position)))))) + +(defun markdown-table-wrap-pretty--separator-line-p (line) + "Return non-nil when LINE is a pipe-table separator row. +Accept both GFM (`|---|---|') and org (`|---+---|') separators." + (let ((trimmed (string-trim line))) + (and (string-prefix-p "|" trimmed) + (or (string-suffix-p "|" trimmed) + (string-suffix-p "|" (substring trimmed 0 -1))) + (string-match-p "\\`|[-: +]*[-: +|]*|?\\'" + (replace-regexp-in-string + "[[:blank:]]" "" trimmed))))) + +(defun markdown-table-wrap-pretty--inside-code-fence-p (pos) + "Return non-nil when POS is inside a code block (fence or org src)." + (cond + ((derived-mode-p 'org-mode) + (and (fboundp 'org-in-src-block-p) + (save-excursion (goto-char pos) (org-in-src-block-p)))) + ((derived-mode-p 'markdown-mode 'md-ts-mode) + (markdown-table-wrap-inside-code-fence-p pos)) + (t nil))) + +(defun markdown-table-wrap-pretty--block-has-separator-p (beg end) + "Return non-nil when the line block BEG..END has a separator row." + (save-excursion + (goto-char beg) + (while (and (< (point) end) + (not (markdown-table-wrap-pretty--separator-line-p + (buffer-substring-no-properties + (line-beginning-position) + (min end (line-end-position)))))) + (forward-line 1)) + (< (point) end))) + +(defun markdown-table-wrap-pretty--table-bounds (&optional pos) + "Return (BEG . END) for the pipe table at POS, or nil. +END is the start of the first line after the table. Return nil when +POS is not on a table line, the block has no separator row, or the +block is inside a code fence." + (save-excursion + (goto-char (or pos (point))) + (beginning-of-line) + (when (markdown-table-wrap-pretty--table-line-p) + (while (and (not (bobp)) + (save-excursion + (forward-line -1) + (markdown-table-wrap-pretty--table-line-p))) + (forward-line -1)) + (let ((beg (line-beginning-position))) + (while (and (not (eobp)) + (markdown-table-wrap-pretty--table-line-p)) + (forward-line 1)) + (let ((end (point))) + (and (markdown-table-wrap-pretty--block-has-separator-p beg end) + (not (markdown-table-wrap-pretty--inside-code-fence-p beg)) + (cons beg end))))))) + +(defun markdown-table-wrap-pretty--table-regions (beg end) + "Return pipe-table regions overlapping BEG..END, in buffer order. +Each element is (TABLE-BEG . TABLE-END) as from +`markdown-table-wrap-pretty--table-bounds'." + (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-pretty--table-bounds (point)))) + (if bounds + (progn + (when (> (cdr bounds) start) + (push bounds regions)) + (goto-char (cdr bounds))) + (forward-line 1))))) + (nreverse regions))) + +;;;; Parsing + +(defun markdown-table-wrap-pretty--normalize-org-line (line) + "Normalize org table LINE for GFM-style parsing. +Strip org width cookies `<[lrc]?[0-9]*>'. Convert org hline +separators `|---+---|' to GFM form `|---|---|'." + (let ((stripped (replace-regexp-in-string "<[lrc]?[0-9]*>" "" line))) + (if (and (derived-mode-p 'org-mode) + (markdown-table-wrap-pretty--separator-line-p stripped)) + (replace-regexp-in-string "+" "|" stripped) + stripped))) + +(defun markdown-table-wrap-pretty--parse-table (raw-lines) + "Parse RAW-LINES into (HEADERS ALIGNS ROWS), or nil. +RAW-LINES is a list of pipe-table line strings (no trailing newlines). +Org separators and width cookies are normalized for parsing only. +Return nil when there is no separator row or no header before it. +Cell splitting and alignment are delegated to the public +`markdown-table-wrap-parse'." + (let* ((norm (mapcar #'markdown-table-wrap-pretty--normalize-org-line raw-lines)) + (sep-idx (cl-position-if + #'markdown-table-wrap-pretty--separator-line-p norm))) + (when (and sep-idx (> sep-idx 0)) + (markdown-table-wrap-parse (mapconcat #'identity norm "\n"))))) + +;;;; Rendering + +(defun markdown-table-wrap-pretty-render-inline-spans (cell) + "Render markdown inline spans in CELL for display: drop delimiters, add faces. +A display-only transform (the canonical CELL is not mutated). + +Spans (longer delimiters folded first): +- Escaped pipe `\\|' -> `|' (GFM table-level unescape) +- Image `![alt](url)' -> `alt' (`link' face + url help-echo) +- Link `[text](url)' -> `text' (`link' face + url help-echo) +- Bold-italic `***x***' -> `x' (`bold-italic' face) +- Bold `**x**' -> `x' (`bold' face) +- Strikethrough `~~x~~' -> `x' (strike-through attribute) +- Inline code (`x` or `` x ``) -> `x' (code face, see below) +- Italic `*x*' -> `x' (`italic' face) + +Escaped pipes unescape FIRST (GFM table syntax; a `\\|' inside a code +span resolves to `|' like cmark-gfm). Code spans are then extracted to +placeholders so their content is literal — `` `**not bold**` `` renders +as `**not bold**` in code face, not folded. Text properties survive +`markdown-table-wrap-cell', so faces stay attached across wrapped +continuation lines; width measurement ignores text properties, so +columns size to the rendered text, not the raw markup." + (let ((result (replace-regexp-in-string "\\\\|" "|" cell)) ; \| -> | (GFM, first) + (code-spans nil) + (placeholder-idx 0)) + ;; Phase 1: extract code spans (double-backtick then single) so their + ;; content is literal. Placeholders \x00CODE\x00 cannot appear in + ;; markdown. Double-backtick: CommonMark trims one leading/trailing space. + (setq result + (replace-regexp-in-string + "``\\([^`]\\|`[^`]\\)+``" + (lambda (match) + (let* ((inner (substring match 2 -2)) + (trimmed (if (and (string-prefix-p " " inner) + (string-suffix-p " " inner) + (> (length inner) 1)) + (substring inner 1 -1) + inner)) + (ph (format "\x00CODE%d\x00" placeholder-idx))) + (push (cons ph trimmed) code-spans) + (setq placeholder-idx (1+ placeholder-idx)) + ph)) + result t t)) + (setq result + (replace-regexp-in-string + "`\\([^`]+\\)`" + (lambda (match) + (let* ((inner (substring match 1 -1)) + (ph (format "\x00CODE%d\x00" placeholder-idx))) + (push (cons ph inner) code-spans) + (setq placeholder-idx (1+ placeholder-idx)) + ph)) + result t t)) + ;; Phase 2: fold non-code spans with faces. Image before link (both + ;; use brackets); bold-italic before bold before italic (longer first). + (setq result + (replace-regexp-in-string ; image ![alt](url) + "!\\[\\([^]]*\\)\\](\\([^)]*\\))" + (lambda (m) + (propertize (or (match-string 1 m) "") + 'face 'link 'mouse-face 'highlight + 'help-echo (or (match-string 2 m) ""))) + result t t)) + (setq result + (replace-regexp-in-string ; link [text](url) + "\\[\\([^]]*\\)\\](\\([^)]*\\))" + (lambda (m) + (propertize (or (match-string 1 m) "") + 'face 'link 'mouse-face 'highlight + 'help-echo (or (match-string 2 m) ""))) + result t t)) + (setq result + (replace-regexp-in-string ; bold-italic ***x*** + "\\*\\*\\*\\([^*]+\\)\\*\\*\\*" + (lambda (m) + (propertize (match-string 1 m) 'face 'bold-italic)) + result t t)) + (setq result + (replace-regexp-in-string ; bold **x** + "\\*\\*\\([^*]+\\)\\*\\*" + (lambda (m) + (propertize (match-string 1 m) 'face 'bold)) + result t t)) + (setq result + (replace-regexp-in-string ; strikethrough ~~x~~ + "~~\\([^~]+\\)~~" + (lambda (m) + (propertize (match-string 1 m) 'face '(:strike-through t))) + result t t)) + (setq result + (replace-regexp-in-string ; italic *x* + "\\*\\([^*]+\\)\\*" + (lambda (m) + (propertize (match-string 1 m) 'face 'italic)) + result t t)) + ;; Phase 3: restore code spans with the code face. + (dolist (pair code-spans) + (setq result + (replace-regexp-in-string + (regexp-quote (car pair)) + (propertize (cdr pair) 'face 'markdown-table-wrap-pretty-code-face) + result t t))) + result)) + +(defun markdown-table-wrap-pretty--render-inline-spans-in-cell (cell) + "Pretty-view wrapper around `markdown-table-wrap-pretty-render-inline-spans'. +Return CELL unchanged when `markdown-table-wrap-pretty-prettify' is nil, +so the raw pipe view shows the canonical markdown verbatim (valid source)." + (if markdown-table-wrap-pretty-prettify + (markdown-table-wrap-pretty-render-inline-spans cell) + cell)) + +(defun markdown-table-wrap-pretty--render-row-lines (cells col-widths aligns) + "Render table CELLS into display lines using COL-WIDTHS and ALIGNS. +When `markdown-table-wrap-pretty-prettify' is non-nil, emit Unicode +box-drawing verticals instead of markdown pipes. Return a list of +display-line strings (one per wrapped visual row)." + (let* ((num-cols (length col-widths)) + (padded (append cells + (make-list (max 0 (- num-cols (length cells))) ""))) + (wrapped-cells + (cl-mapcar (lambda (cell column-width) + (markdown-table-wrap-cell (or cell "") column-width)) + padded col-widths)) + (max-height (apply #'max (mapcar #'length wrapped-cells))) + (pretty markdown-table-wrap-pretty-prettify) + (delim-open (if pretty "│ " "| ")) + (delim-mid (if pretty " │ " " | ")) + (delim-close (if pretty " │" " |"))) + (cl-loop for line-index below max-height + collect + (let ((acc (list delim-open))) + (cl-loop for cell-lines in wrapped-cells + for column-width in col-widths + for align in aligns + for first = t then nil + do + (unless first (push delim-mid acc)) + (let* ((cell (or (nth line-index cell-lines) "")) + (empty (string-empty-p cell)) + (pad (if empty + column-width + (max 0 (- column-width + (markdown-table-wrap-visible-width cell)))))) + (cond + (empty + (push (make-string column-width ?\s) acc)) + ((eq align 'right) + (push (make-string pad ?\s) acc) + (push cell acc)) + ((eq align 'center) + (let ((left-pad (/ pad 2))) + (push (make-string left-pad ?\s) acc) + (push cell acc) + (push (make-string (- pad left-pad) ?\s) acc))) + (t + (push cell acc) + (push (make-string pad ?\s) acc))))) + (push delim-close acc) + (apply #'concat (nreverse acc)))))) + +(defun markdown-table-wrap-pretty--render-separator-line (col-widths aligns) + "Render the separator line for COL-WIDTHS and ALIGNS. +When `markdown-table-wrap-pretty-prettify' is non-nil, emit a +box-drawing rule; otherwise emit standard markdown separator syntax." + (if markdown-table-wrap-pretty-prettify + (concat "├─" (mapconcat (lambda (w) (make-string (max 1 w) ?─)) + col-widths "─┼─") + "─┤") + (let ((parts + (cl-mapcar + (lambda (column-width align) + (let ((dashes (make-string (max 1 column-width) ?-))) + (pcase align + ('left + (if (>= column-width 2) + (concat ":" (substring dashes 1)) + ":")) + ('right + (if (>= column-width 2) + (concat (substring dashes 1) ":") + ":")) + ('center + (if (>= column-width 3) + (concat ":" (substring dashes 2) ":") + (if (>= column-width 2) "::" ":"))) + (_ dashes)))) + col-widths aligns))) + (concat "| " (mapconcat #'identity parts " | ") " |")))) + +;;;; Display Groups (per raw line) + +(defun markdown-table-wrap-pretty--split-line-prefix (line) + "Split table LINE into (PREFIX . BARE). +PREFIX is everything before the first `|'; BARE starts at the first `|'." + (if-let* ((pipe-index (string-match-p "|" line))) + (cons (substring line 0 pipe-index) + (substring line pipe-index)) + (cons "" line))) + +(defun markdown-table-wrap-pretty--table-display-groups (raw-lines width) + "Return prefix-aware display groups for RAW-LINES at WIDTH. +Each result element corresponds to one raw source line and is a list +of the display lines for that logical table row. Container prefixes +such as blockquotes and indentation are preserved on every visual +continuation line. Plain tables take a fast path." + (let* ((no-prefix (cl-every (lambda (l) (and (> (length l) 0) + (= (aref l 0) ?|))) + raw-lines)) + (parts (unless no-prefix + (mapcar #'markdown-table-wrap-pretty--split-line-prefix raw-lines))) + (prefixes (unless no-prefix (mapcar #'car parts))) + (bare-lines (if no-prefix raw-lines (mapcar #'cdr parts))) + (prefix-width (if no-prefix 0 + (apply #'max 0 (mapcar #'string-width prefixes))))) + (when (and (>= (length bare-lines) 2) + (markdown-table-wrap-pretty--parse-table bare-lines)) + (pcase-let* ((`(,headers ,aligns ,rows) + (markdown-table-wrap-pretty--parse-table bare-lines)) + ;; Fold inline spans (escaped pipes, links/images, + ;; bold/italic/strike/code) via + ;; `markdown-table-wrap-pretty--render-inline-spans-in-cell' + ;; so columns size and render to the visible text rather + ;; than the raw markup. No-op when + ;; `markdown-table-wrap-pretty-prettify' is nil. + (disp-headers (mapcar #'markdown-table-wrap-pretty--render-inline-spans-in-cell + headers)) + (disp-rows (mapcar (lambda (r) + (mapcar #'markdown-table-wrap-pretty--render-inline-spans-in-cell r)) + rows)) + (num-cols (max (length disp-headers) + (length aligns) + (if disp-rows + (apply #'max (mapcar #'length disp-rows)) + 0))) + (content-width (max 1 (- width prefix-width))) + (col-widths + (markdown-table-wrap-compute-widths + disp-headers disp-rows content-width num-cols)) + (header-lines + (markdown-table-wrap-pretty--render-row-lines disp-headers col-widths aligns)) + (separator-line + (markdown-table-wrap-pretty--render-separator-line col-widths aligns)) + (row-groups + (mapcar (lambda (row) + (markdown-table-wrap-pretty--render-row-lines + row col-widths aligns)) + disp-rows))) + (if no-prefix + (append (list header-lines) + (list (list separator-line)) + row-groups) + (append + (list (mapcar (lambda (l) (concat (car prefixes) l)) header-lines)) + (list (list (concat (nth 1 prefixes) separator-line))) + (cl-mapcar (lambda (prefix row-lines) + (mapcar (lambda (l) (concat prefix l)) row-lines)) + (nthcdr 2 prefixes) + row-groups))))))) + +;;;; Overlay Management + +(defun markdown-table-wrap-pretty--overlays-in (beg end) + "Return the `markdown-table-wrap-pretty-display' overlays in BEG..END." + (cl-remove-if-not + (lambda (ov) (overlay-get ov 'markdown-table-wrap-pretty-display)) + (overlays-in beg end))) + +(defun markdown-table-wrap-pretty--undecorate-table (beg end) + "Remove all markdown-table-wrap-pretty display overlays in BEG..END." + (dolist (ov (markdown-table-wrap-pretty--overlays-in beg end)) + (delete-overlay ov))) + +(defun markdown-table-wrap-pretty--table-pretty-p (beg end) + "Return non-nil when the table at BEG..END is currently pretty." + (cl-some (lambda (ov) (overlay-get ov 'markdown-table-wrap-pretty-display)) + (overlays-in beg end))) + +(defun markdown-table-wrap-pretty--on-modify (ov _flag _beg _end &optional _length) + "Modification hook: reveal the whole table when OV's text is edited. +This auto-removes the pretty display on any edit (org-latex-preview +style), so the user sees and edits the raw source directly. There is no +`read-only' (which would break undo) and no blind editing. The other +arguments are the standard modification-hook arguments and are ignored." + (let ((inhibit-modification-hooks t)) + (when-let* ((markers (overlay-get ov 'markdown-table-wrap-pretty-markers)) + (mb (marker-position (car markers))) + (me (marker-position (cdr markers)))) + ;; Drop the markers first so a re-entrant call is a no-op. + (overlay-put ov 'markdown-table-wrap-pretty-markers nil) + (markdown-table-wrap-pretty--undecorate-table mb me)))) + +(defun markdown-table-wrap-pretty--decorate-table (beg end &optional width) + "Create per-line display overlays for the raw table BEG..END. +WIDTH defaults to the effective width at BEG (window capacity minus any +visual indent prefix from `org-indent-mode' and friends). The raw +buffer text is preserved; each raw line gets its own overlay whose +`display' shows the wrapped/pretty output. Return t when overlays were +created, nil when the table needs no change (fits, no prettify)." + (let* ((table-beg (save-excursion (goto-char beg) (line-beginning-position))) + (width (or width (markdown-table-wrap-pretty--effective-width-at table-beg))) + (raw (buffer-substring-no-properties table-beg end)) + (trimmed (string-trim-right raw "\n+")) + (trailing (substring raw (length trimmed)))) + (when-let* ((raw-lines (split-string trimmed "\n")) + (groups (markdown-table-wrap-pretty--table-display-groups raw-lines width))) + ;; Skip when the display is identical to the raw (fits, no prettify). + (unless (and (= (length groups) (length raw-lines)) + (cl-every (lambda (raw-line group) + (and (= (length group) 1) + (equal raw-line (car group)))) + raw-lines groups)) + (let ((n (length raw-lines)) + (beg-marker (make-marker)) + (end-marker (make-marker))) + (set-marker beg-marker table-beg) + (set-marker end-marker end) + (save-excursion + (goto-char table-beg) + (dotimes (i n) + (let* ((line-beg (line-beginning-position)) + (line-end (if (= i (1- n)) + end + (min (1+ (line-end-position)) end))) + (group (nth i groups)) + (display-str (concat (mapconcat #'identity group "\n") + (if (= i (1- n)) trailing "\n"))) + (ov (make-overlay line-beg line-end nil nil nil))) + (overlay-put ov 'display display-str) + (overlay-put ov 'face 'default) + (overlay-put ov 'markdown-table-wrap-pretty-display t) + (overlay-put ov 'markdown-table-wrap-pretty-markers + (cons beg-marker end-marker)) + (overlay-put ov 'evaporate t) + (overlay-put ov 'modification-hooks + (list #'markdown-table-wrap-pretty--on-modify)) + (overlay-put ov 'insert-in-front-hooks + (list #'markdown-table-wrap-pretty--on-modify)) + (overlay-put ov 'insert-behind-hooks + (list #'markdown-table-wrap-pretty--on-modify))) + (forward-line 1))) + t))))) + +(defun markdown-table-wrap-pretty--decorate-tables-in-region (beg end &optional width) + "Decorate all tables in BEG..END. +WIDTH, when non-nil, overrides the per-table effective width; otherwise +each table uses the effective width at its own position (window capacity +minus any visual indent prefix). Idempotent: existing table overlays +are removed first." + (let ((gc-cons-threshold (max gc-cons-threshold (* 8 1024 1024)))) + (setq markdown-table-wrap-pretty--last-width + (markdown-table-wrap-pretty--window-width)) + (dolist (region (markdown-table-wrap-pretty--table-regions beg end)) + (markdown-table-wrap-pretty--undecorate-table (car region) (cdr region)) + (let* ((rb (car region)) + (re (cdr region)) + (raw (buffer-substring-no-properties rb re))) + (when (markdown-table-wrap-pretty--parse-table + (split-string (string-trim-right raw "\n+") "\n")) + (pcase-let* ((`(,_headers ,_aligns ,rows) + (markdown-table-wrap-pretty--parse-table + (split-string (string-trim-right raw "\n+") "\n")))) + (when rows ; skip header-only tables + (markdown-table-wrap-pretty--decorate-table rb re width)))))))) + +;;;; Resize + +(defun markdown-table-wrap-pretty--refresh-visible () + "Re-render all currently-pretty tables at the current window width. +Tables toggled to raw (no overlays) are left alone. Lossless: +regenerate from canonical source." + (let ((width (markdown-table-wrap-pretty--window-width))) + (unless (equal width markdown-table-wrap-pretty--last-width) + (setq markdown-table-wrap-pretty--last-width width) + (let ((gc-cons-threshold (max gc-cons-threshold (* 8 1024 1024)))) + (save-excursion + (goto-char (point-min)) + (while (not (eobp)) + (let ((bounds (markdown-table-wrap-pretty--table-bounds (point)))) + (if bounds + (progn + (when (markdown-table-wrap-pretty--table-pretty-p + (car bounds) (cdr bounds)) + (markdown-table-wrap-pretty--undecorate-table + (car bounds) (cdr bounds)) + ;; Let decorate compute the effective width at this + ;; table's position (accounts for org-indent prefix). + (markdown-table-wrap-pretty--decorate-table + (car bounds) (cdr bounds))) + (goto-char (cdr bounds))) + (forward-line 1))))))))) + +(defun markdown-table-wrap-pretty--schedule-refresh () + "Schedule a debounced `markdown-table-wrap-pretty--refresh-visible'." + (when (and markdown-table-wrap-pretty-mode + markdown-table-wrap-pretty-auto-rewrap-on-resize) + (when markdown-table-wrap-pretty--refresh-timer + (cancel-timer markdown-table-wrap-pretty--refresh-timer)) + (setq markdown-table-wrap-pretty--refresh-timer + (run-with-idle-timer markdown-table-wrap-pretty-rewrap-idle-delay nil + (lambda () + (when (buffer-live-p (current-buffer)) + (with-current-buffer (current-buffer) + (markdown-table-wrap-pretty--refresh-visible)))))))) + +;;;; Minor Mode + +(defun markdown-table-wrap-pretty--maybe-default-pretty () + "Decorate all tables if the current major mode defaults to pretty." + (when (and markdown-table-wrap-pretty-mode + (memq major-mode markdown-table-wrap-pretty-default-on-major-modes)) + (markdown-table-wrap-pretty--decorate-tables-in-region (point-min) (point-max)))) + +;;;###autoload +(define-minor-mode markdown-table-wrap-pretty-mode + "Toggle display-only pretty rendering of pipe tables in this buffer. +When on, tables can be toggled between a pretty view and the canonical +raw pipe text with `markdown-table-wrap-pretty-toggle'. Resize +re-renders pretty tables losslessly when +`markdown-table-wrap-pretty-auto-rewrap-on-resize' is on. Tables +start raw unless the major mode is in +`markdown-table-wrap-pretty-default-on-major-modes'. Editing a pretty +table auto-reveals the raw source via `modification-hooks' (the +`org-latex-preview' pattern; not `read-only', which would break undo)." + :lighter " TblPretty" + :group 'markdown-table-wrap-pretty + (if markdown-table-wrap-pretty-mode + (progn + ;; `window-configuration-change-hook' is a normal (no-arg) hook + ;; and is buffer-local-correct; it fires on window size changes + ;; as well as layout changes, which is what we need for re-wrap. + ;; NOTE: do NOT use `window-size-change-functions' here: it is an + ;; abnormal hook called with one arg (the frame), so a no-arg + ;; function would error (silently, via `safe-run-hooks'); and it + ;; is global, so a buffer-local registration is unreliable. + (add-hook 'window-configuration-change-hook + #'markdown-table-wrap-pretty--schedule-refresh nil t) + (markdown-table-wrap-pretty--maybe-default-pretty)) + (remove-hook 'window-configuration-change-hook + #'markdown-table-wrap-pretty--schedule-refresh t) + (when markdown-table-wrap-pretty--refresh-timer + (cancel-timer markdown-table-wrap-pretty--refresh-timer) + (setq markdown-table-wrap-pretty--refresh-timer nil)) + (markdown-table-wrap-pretty--undecorate-table (point-min) (point-max)))) + +;;;; Public Commands + +(defun markdown-table-wrap-pretty--force (state regions) + "Apply STATE (`pretty' or `raw') to REGIONS. +Return the number of tables actually rendered (for `pretty') or +processed (for `raw'). A `pretty' table that already fits without +prettification (`markdown-table-wrap-pretty-prettify' is nil and the +table is narrower than the window) is not rendered and is not counted, +so the returned count reflects what the user actually sees change." + (let ((count 0)) + (dolist (r regions) + (let ((beg (car r)) (end (cdr r))) + (if (eq state 'pretty) + (progn + (markdown-table-wrap-pretty--undecorate-table beg end) + ;; Let decorate compute the effective width at beg. + (when (markdown-table-wrap-pretty--decorate-table beg end) + (setq count (1+ count)))) + (markdown-table-wrap-pretty--undecorate-table beg end) + (setq count (1+ count))))) + count)) + +;;;###autoload +(defun markdown-table-wrap-pretty-toggle (&optional arg) + "Toggle the pretty rendering of the pipe table at point, or all tables. +Mirrors `org-latex-preview'. ARG selects the force behavior: a single +prefix arg forces pretty on every table, a double prefix arg forces +raw; otherwise the location rules below decide: + - Point on a table -> toggle that table. + - Point off-table -> toggle all tables in the buffer (if any are + pretty, make all raw; otherwise make all pretty). + - Active region -> toggle tables overlapping the region." + (interactive "P") + (cond + ;; C-u C-u -> force raw on all. + ((equal arg '(16)) + (let ((all (markdown-table-wrap-pretty--table-regions + (point-min) (point-max)))) + (markdown-table-wrap-pretty--force 'raw all) + (message "Tables raw (%d)" (length all)))) + ;; C-u -> force pretty on all. + ((equal arg '(4)) + (let* ((all (markdown-table-wrap-pretty--table-regions + (point-min) (point-max))) + (n (markdown-table-wrap-pretty--force 'pretty all))) + (message "Tables pretty (%d of %d)" n (length all)))) + ;; Active region -> toggle tables overlapping the region. + ((use-region-p) + (let* ((rb (region-beginning)) + (re (region-end)) + (regs (markdown-table-wrap-pretty--table-regions rb re)) + (any-pretty (cl-some + (lambda (r) + (markdown-table-wrap-pretty--table-pretty-p + (car r) (cdr r))) + regs)) + (state (if any-pretty 'raw 'pretty)) + (n (markdown-table-wrap-pretty--force state regs))) + (message "Region tables %s (%d%s)" + (if (eq state 'raw) "raw" "pretty") + n + (if (eq state 'raw) "" (format " of %d" (length regs)))))) + ;; No region: point on a table -> toggle it; else toggle all. + (t + (let ((bounds (markdown-table-wrap-pretty--table-bounds (point)))) + (cond + ((null bounds) + (let* ((all (markdown-table-wrap-pretty--table-regions + (point-min) (point-max))) + (any-pretty (cl-some + (lambda (r) + (markdown-table-wrap-pretty--table-pretty-p + (car r) (cdr r))) + all)) + (state (if any-pretty 'raw 'pretty)) + (n (markdown-table-wrap-pretty--force state all))) + (message "Tables %s (%d%s)" + (if (eq state 'raw) "raw" "pretty") + n + (if (eq state 'raw) "" (format " of %d" (length all)))))) + ((markdown-table-wrap-pretty--table-pretty-p (car bounds) (cdr bounds)) + (markdown-table-wrap-pretty--undecorate-table (car bounds) (cdr bounds)) + (message "Table raw")) + (t + (markdown-table-wrap-pretty--decorate-table (car bounds) (cdr bounds)) + (message "Table pretty"))))))) + +;;;###autoload +(defun markdown-table-wrap-pretty-buffer () + "Force pretty rendering on all tables in the current buffer." + (interactive) + (let* ((all (markdown-table-wrap-pretty--table-regions + (point-min) (point-max))) + (n (markdown-table-wrap-pretty--force 'pretty all))) + (message "Tables pretty (%d of %d)" n (length all)))) + +;;;###autoload +(defun markdown-table-wrap-pretty-region (beg end) + "Force pretty rendering on all tables in BEG..END." + (interactive "r") + (let* ((regs (markdown-table-wrap-pretty--table-regions beg end)) + (n (markdown-table-wrap-pretty--force 'pretty regs))) + (message "Region tables pretty (%d of %d)" n (length regs)))) + +(provide 'markdown-table-wrap-pretty) +;;; markdown-table-wrap-pretty.el ends here diff --git a/test/markdown-table-wrap-pretty-test.el b/test/markdown-table-wrap-pretty-test.el new file mode 100644 index 0000000..f7a240a --- /dev/null +++ b/test/markdown-table-wrap-pretty-test.el @@ -0,0 +1,256 @@ +;;; markdown-table-wrap-pretty-test.el --- Tests for markdown-table-wrap-pretty -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 SayreBlades + +;; Author: SayreBlades +;; SPDX-License-Identifier: GPL-3.0-or-later + +;;; Commentary: + +;; ERT tests for the display companion package `markdown-table-wrap-pretty'. +;; +;; These tests cover the PURE helpers that the display layer is built from: +;; separator detection, org normalization, table parsing, row/separator +;; rendering (plain and Unicode-pretty), inline-span styling, and +;; prefix-aware display-group construction. The overlay/toggle behavior +;; itself is not exercised here because it needs a live window/display; +;; the pure helpers are the high-value, deterministic surface. + +;;; Code: + +(require 'cl-lib) +(require 'ert) +(require 'org) +(require 'markdown-table-wrap) +(require 'markdown-table-wrap-pretty) + +(defmacro markdown-table-wrap-pretty-test--with-prettify (prettify &rest body) + "Evaluate BODY with `markdown-table-wrap-pretty-prettify' bound to PRETTIFY. +Bodies that call the renderers depend on this defcustom; bind it +locally so tests are deterministic and order-independent." + (declare (indent 1)) + `(let ((markdown-table-wrap-pretty-prettify ,prettify)) + ,@body)) + +;;;; Separator detection + +(ert-deftest markdown-table-wrap-pretty-test-separator-gfm () + "A plain GFM separator row is recognized." + (should (markdown-table-wrap-pretty--separator-line-p "|---|---|"))) + +(ert-deftest markdown-table-wrap-pretty-test-separator-gfm-aligned () + "A GFM separator with alignment colons is recognized." + (should (markdown-table-wrap-pretty--separator-line-p "|:--|:-:|--:|"))) + +(ert-deftest markdown-table-wrap-pretty-test-separator-org () + "An org separator with `+' joins is recognized." + (should (markdown-table-wrap-pretty--separator-line-p "|---+---|"))) + +(ert-deftest markdown-table-wrap-pretty-test-separator-not-a-row () + "A data row is not a separator." + (should-not (markdown-table-wrap-pretty--separator-line-p "| a | b |"))) + +;;;; Org normalization + +(ert-deftest markdown-table-wrap-pretty-test-normalize-strips-cookies () + "Org width cookies are stripped regardless of major mode." + (should (equal "| | |" + (markdown-table-wrap-pretty--normalize-org-line + "| | <5> |")))) + +(ert-deftest markdown-table-wrap-pretty-test-normalize-org-hline-in-org () + "In `org-mode' the `+' join in a separator becomes GFM `|'." + (with-temp-buffer + (org-mode) + (should (equal "|---|---|" + (markdown-table-wrap-pretty--normalize-org-line + "|---+---|"))))) + +(ert-deftest markdown-table-wrap-pretty-test-normalize-gfm-sep-unchanged () + "An already-GFM separator is left untouched in `org-mode'." + (with-temp-buffer + (org-mode) + (should (equal "|---|---|" + (markdown-table-wrap-pretty--normalize-org-line + "|---|---|"))))) + +;;;; Parsing + +(ert-deftest markdown-table-wrap-pretty-test-parse-gfm () + "Parse a GFM table into headers, aligns, and rows." + (should (equal '(("A" "B") (nil nil) (("1" "2"))) + (markdown-table-wrap-pretty--parse-table + (split-string "| A | B |\n|---|---|\n| 1 | 2 |" "\n"))))) + +(ert-deftest markdown-table-wrap-pretty-test-parse-org-hline-cookie () + "Parse an org table, normalizing its hline and stripping cookies." + (with-temp-buffer + (org-mode) + (should (equal '(("A" "B") (nil nil) (("1" "2"))) + (markdown-table-wrap-pretty--parse-table + (split-string "| A | B |\n|---+---|\n| 1 | 2 |" "\n")))))) + +(ert-deftest markdown-table-wrap-pretty-test-parse-no-separator () + "A block without a separator row parses to nil." + (should-not (markdown-table-wrap-pretty--parse-table + (split-string "| a | b |\n| c | d |" "\n")))) + +(ert-deftest markdown-table-wrap-pretty-test-parse-separator-first () + "A block whose first line is the separator (no header) parses to nil." + (should-not (markdown-table-wrap-pretty--parse-table + (split-string "|---|---|\n| 1 | 2 |" "\n")))) + +;;;; Separator-line rendering + +(ert-deftest markdown-table-wrap-pretty-test-render-separator-plain () + "Plain separator renders standard markdown dashes." + (markdown-table-wrap-pretty-test--with-prettify nil + (should (equal "| - | - |" + (markdown-table-wrap-pretty--render-separator-line + '(1 1) '(nil nil)))))) + +(ert-deftest markdown-table-wrap-pretty-test-render-separator-aligned () + "Plain separator preserves GFM alignment colons." + (markdown-table-wrap-pretty-test--with-prettify nil + (should (equal "| :-- | --: | :-: |" + (markdown-table-wrap-pretty--render-separator-line + '(3 3 3) '(left right center)))))) + +(ert-deftest markdown-table-wrap-pretty-test-render-separator-pretty () + "Pretty separator renders a Unicode box-drawing rule." + (markdown-table-wrap-pretty-test--with-prettify t + (should (equal "├───┼───┤" + (markdown-table-wrap-pretty--render-separator-line + '(1 1) '(nil nil)))))) + +;;;; Row rendering + +(ert-deftest markdown-table-wrap-pretty-test-render-row-plain () + "A single short row renders as one plain markdown line." + (markdown-table-wrap-pretty-test--with-prettify nil + (should (equal '("| A | B |") + (markdown-table-wrap-pretty--render-row-lines + '("A" "B") '(1 1) '(nil nil)))))) + +(ert-deftest markdown-table-wrap-pretty-test-render-row-pretty () + "A single short row renders with Unicode verticals when prettify is on." + (markdown-table-wrap-pretty-test--with-prettify t + (should (equal '("│ A │ B │") + (markdown-table-wrap-pretty--render-row-lines + '("A" "B") '(1 1) '(nil nil)))))) + +(ert-deftest markdown-table-wrap-pretty-test-render-row-right-align () + "Right alignment pads on the left." + (markdown-table-wrap-pretty-test--with-prettify nil + (should (equal '("| A |") + (markdown-table-wrap-pretty--render-row-lines + '("A") '(3) '(right)))))) + +(ert-deftest markdown-table-wrap-pretty-test-render-row-center-align () + "Center alignment splits padding, favoring the left on odd widths." + (markdown-table-wrap-pretty-test--with-prettify nil + (should (equal '("| A |") + (markdown-table-wrap-pretty--render-row-lines + '("A") '(3) '(center)))))) + +(ert-deftest markdown-table-wrap-pretty-test-render-row-wraps () + "A cell wider than its column wraps across multiple visual rows." + (markdown-table-wrap-pretty-test--with-prettify nil + (should (equal '("| hello |" "| world |") + (markdown-table-wrap-pretty--render-row-lines + '("hello world") '(5) '(nil nil)))))) + +;;;; Inline-span styling + +(ert-deftest markdown-table-wrap-pretty-test-span-bold () + "`**x**' folds to the text in the `bold' face." + (let ((r (markdown-table-wrap-pretty-render-inline-spans "**bold**"))) + (should (equal "bold" (substring-no-properties r))) + (should (eq 'bold (get-text-property 0 'face r))))) + +(ert-deftest markdown-table-wrap-pretty-test-span-italic () + "`*x*' folds to the text in the `italic' face." + (let ((r (markdown-table-wrap-pretty-render-inline-spans "*it*"))) + (should (equal "it" (substring-no-properties r))) + (should (eq 'italic (get-text-property 0 'face r))))) + +(ert-deftest markdown-table-wrap-pretty-test-span-bold-italic () + "`***x***' folds before `**x**' and uses `bold-italic'." + (let ((r (markdown-table-wrap-pretty-render-inline-spans "***bi***"))) + (should (equal "bi" (substring-no-properties r))) + (should (eq 'bold-italic (get-text-property 0 'face r))))) + +(ert-deftest markdown-table-wrap-pretty-test-span-strikethrough () + "`~~x~~' folds to the text with a strike-through face." + (let ((r (markdown-table-wrap-pretty-render-inline-spans "~~st~~"))) + (should (equal "st" (substring-no-properties r))) + (should (equal '(:strike-through t) (get-text-property 0 'face r))))) + +(ert-deftest markdown-table-wrap-pretty-test-span-code-single () + "A single-backtick code span uses the code face." + (let ((r (markdown-table-wrap-pretty-render-inline-spans "`code`"))) + (should (equal "code" (substring-no-properties r))) + (should (eq 'markdown-table-wrap-pretty-code-face + (get-text-property 0 'face r))))) + +(ert-deftest markdown-table-wrap-pretty-test-span-code-double () + "A double-backtick code span trims one surrounding space (CommonMark)." + (let ((r (markdown-table-wrap-pretty-render-inline-spans "`` c d ``"))) + (should (equal "c d" (substring-no-properties r))) + (should (eq 'markdown-table-wrap-pretty-code-face + (get-text-property 0 'face r))))) + +(ert-deftest markdown-table-wrap-pretty-test-span-link () + "`[text](url)' folds to the text in the `link' face." + (let ((r (markdown-table-wrap-pretty-render-inline-spans "[t](u)"))) + (should (equal "t" (substring-no-properties r))) + (should (eq 'link (get-text-property 0 'face r))))) + +(ert-deftest markdown-table-wrap-pretty-test-span-image () + "`![alt](url)' folds to the alt text in the `link' face." + (let ((r (markdown-table-wrap-pretty-render-inline-spans "![a](u)"))) + (should (equal "a" (substring-no-properties r))) + (should (eq 'link (get-text-property 0 'face r))))) + +(ert-deftest markdown-table-wrap-pretty-test-span-escaped-pipe () + "An escaped pipe `\\|' unescapes to a literal pipe (GFM table level)." + (let ((r (markdown-table-wrap-pretty-render-inline-spans "a \\| b"))) + (should (equal "a | b" (substring-no-properties r))))) + +(ert-deftest markdown-table-wrap-pretty-test-span-code-literal-markup () + "Markup inside a code span is literal, not folded." + (let ((r (markdown-table-wrap-pretty-render-inline-spans "`**x**`"))) + (should (equal "**x**" (substring-no-properties r))) + (should (eq 'markdown-table-wrap-pretty-code-face + (get-text-property 0 'face r))))) + +(ert-deftest markdown-table-wrap-pretty-test-span-in-cell-passthrough () + "With `prettify' nil, cells pass through unchanged (valid source)." + (markdown-table-wrap-pretty-test--with-prettify nil + (should (equal "**bold**" + (markdown-table-wrap-pretty--render-inline-spans-in-cell + "**bold**"))))) + +;;;; Display groups (prefix-aware) + +(ert-deftest markdown-table-wrap-pretty-test-display-groups-plain-fits () + "A plain table that fits yields one group per source line." + (markdown-table-wrap-pretty-test--with-prettify nil + (should (= 3 (length + (markdown-table-wrap-pretty--table-display-groups + (split-string "| A | B |\n|---|---|\n| 1 | 2 |" "\n") + 80)))))) + +(ert-deftest markdown-table-wrap-pretty-test-display-groups-blockquote-prefix () + "A blockquote prefix is repeated on every wrapped continuation line." + (markdown-table-wrap-pretty-test--with-prettify nil + (let* ((lines (split-string + "> | Name | Age |\n> |------|-----|\n> | Alexander | 30 |" + "\n")) + (groups (markdown-table-wrap-pretty--table-display-groups lines 14)) + (all-lines (apply #'append groups))) + (should all-lines) + (should (cl-every (lambda (l) (string-prefix-p "> " l)) all-lines))))) + +(provide 'markdown-table-wrap-pretty-test) +;;; markdown-table-wrap-pretty-test.el ends here