;;; klammertext-align.el --- Table alignment for Klammertext -*- lexical-binding: t; -*-

;; EXPERIMENTAL.  Aligns the columns of a table klammer: with point anywhere
;; inside a @table span, `klammertext-align-table' (bound C-c C-a) pads the
;; cells of its rows so the | separators line up vertically:
;;
;;   @table
;;     First item | Second | A third item that's longer ||
;;     Row 2      | Text   | Not as long                ||
;;   @
;;
;; This file is a separate unit, loaded from the init file:
;;
;;   (require 'klammertext-align)
;;
;; Comment that line out to disable alignment entirely.  The Sublime Text
;; port doc/edit/sublime/Klammertext_align.py implements the same algorithm —
;; keep the two in step.
;;
;; Alignment is for SMALL data items (2026-07-27):
;;
;;   * A row is one line ending with the row delimiter || (the customary
;;     trailing delimiter; the parser strips one trailing top-level
;;     delimiter, and it keeps every row uniform).  The last row may omit
;;     the ||.
;;   * A row is LEFT UNTOUCHED when any of its cells is longer than
;;     `klammertext-align-cell-max' (30) characters, or when the row spans
;;     lines (a cell with a newline).  Untouched rows do not contribute to
;;     the column widths.
;;   * If the aligned rows would exceed `klammertext-align-row-max' (100)
;;     columns, nothing is changed and the reason is reported — the general
;;     case of long rows has no good answer, so the command declines rather
;;     than guessing.
;;
;; Cell padding is semantically free: the SKS strips cell content, and no
;; whitespace is ever inserted inside a bar run (that would turn a || row
;; separator into an empty | | cell — the load-bearing-whitespace trap).
;; Bars inside a nested klammer (e.g. @frac 1 | 2 @ in a cell) belong to
;; that klammer, not the table: only bars at nesting depth 0 within the
;; table span count, the same depth rule the Klammermachine itself applies
;; to @cond.  Aligned rows adopt the leading whitespace of the first
;; aligned row; run TAB / `indent-region' first if the rows disagree.
;;
;; SYNC: `klammertext-align-klammers' / `-cell-max' / `-row-max' are
;; mirrored as ALIGN_KLAMMERS / CELL_MAX / ROW_MAX in the shared Python
;; core doc/edit/shared/klammertext_edit.py (the single implementation
;; behind the Sublime, Vim, and VS Code integrations and the language
;; server; this elisp unit stays independent, held equal by
;; tst/editor_test.sh's byte-equality checks).

;;; Code:

;; string-trim, string-blank-p, and string-empty-p live in subr-x, which is
;; not loaded by default in a batch Emacs before 28.  Without this require
;; they are void on Emacs 27 (as shipped by Pop!_OS 22.04) while working on
;; newer Emacs, so the alignment command fails on the older editor only.
(require 'subr-x)
(require 'klammertext-mode)

(defgroup klammertext-align nil
  "Table alignment for Klammertext files."
  :group 'klammertext)

(defcustom klammertext-align-klammers '("table")
  "Klammers whose rows `klammertext-align-table' aligns."
  :type '(repeat string)
  :group 'klammertext-align)

(defcustom klammertext-align-cell-max 30
  "A row with a cell longer than this many characters is left untouched.
Alignment is for small data items."
  :type 'integer
  :group 'klammertext-align)

(defcustom klammertext-align-row-max 100
  "Refuse to align when the aligned rows would exceed this many columns.
nil means no limit."
  :type '(choice integer (const nil))
  :group 'klammertext-align)

;; --- string helpers (the mode's helpers are buffer-based) ----------------

(defun klammertext-align--escaped-p (s pos)
  "Non-nil if the char at POS in string S is escaped by an odd run of ^."
  (let ((n 0) (i (1- pos)))
    (while (and (>= i 0) (eq (aref s i) ?^))
      (setq n (1+ n) i (1- i)))
    (= (mod n 2) 1)))

(defun klammertext-align--block-end (s frm)
  "Index just after the ]# closing a #[ block opened at FRM in string S.
Counts nested #[ ... ]#; (length S) if unclosed."
  (let ((depth 1) (i frm) (n (length s)))
    (while (> depth 0)
      (let ((a (string-search "#[" s i))
            (b (string-search "]#" s i)))
        (cond
         ((and (null a) (null b))
          (setq i n depth 0))
         ((or (null b) (and a (< a b)))
          (setq depth (1+ depth) i (+ a 2)))
         (t
          (setq depth (1- depth) i (+ b 2))))))
    i))

(defun klammertext-align--at-run-end (s pos)
  "Index just after the run of @ that begins at POS in string S."
  (let ((p pos) (n (length s)))
    (while (and (< p n) (eq (aref s p) ?@))
      (setq p (1+ p)))
    p))

(defun klammertext-align--name-end (s pos)
  "Index just past the run of name chars starting at POS in string S."
  (let ((k pos) (n (length s)))
    (while (and (< k n) (klammertext--name-char-p (aref s k)))
      (setq k (1+ k)))
    k))

;; --- finding the enclosing table span (buffer-based) ---------------------

(defun klammertext-align--enclosing-span (pos names)
  "Innermost span of a klammer named in NAMES that contains POS.
Return (NAME CONTENT-START CONTENT-END) with CONTENT-START just after the
opening @NAME token and CONTENT-END at the start of the closing delimiter
token, or nil.  Scans the buffer from the top with a position stack,
stepping over removed text, literal spans, escaped characters, and the
abbreviated @name-arg form."
  (save-excursion
    (goto-char (point-min))
    (let ((stack nil) (found nil) (go t))
      (while (and go (not found)
                  (re-search-forward "[@#]" nil t))
        (let ((hit (1- (point))))
          (cond
           ((klammertext--escaped-p hit))
           ((eq (char-after hit) ?#)
            (let ((next (char-after (1+ hit))))
              (cond
               ((eq next ?#) (setq go nil))
               ((eq next ?\[) (goto-char (klammertext--block-end (+ hit 2))))
               ((memq next '(?+ ?/ ?-)))
               (t (goto-char (line-end-position))))))
           (t
            (let* ((run-end (klammertext--at-run-end hit))
                   (len (- run-end hit)))
              (if (klammertext--name-char-p (char-after run-end))
                  (progn
                    (goto-char run-end)
                    (skip-chars-forward "A-Za-z0-9_")
                    (let ((name (buffer-substring-no-properties run-end (point))))
                      (cond
                       ((and (= len 1)
                             (member name klammertext-literal-klammers))
                        (unless (re-search-forward
                                 (concat (regexp-quote name) "@") nil t)
                          (setq go nil)))
                       ((and (= len 1) (eq (char-after) ?-)))
                       (t (push (list name hit (point)) stack)))))
                ;; a close: token starts at the preceding name run, if any
                (let* ((ns (save-excursion
                             (goto-char hit)
                             (skip-chars-backward "A-Za-z0-9_")
                             (point)))
                       (tok-start (if (and (< ns hit)
                                           (or (= ns (point-min))
                                               (not (eq (char-before ns) ?@))))
                                      ns
                                    hit))
                       (entry (pop stack)))
                  (when (and entry
                             (member (nth 0 entry) names)
                             (<= (nth 1 entry) pos)
                             (<= pos run-end))
                    (setq found (list (nth 0 entry) (nth 2 entry) tok-start)))
                  (goto-char run-end))))))))
      found)))

;; --- scanning the span content, line by line -----------------------------
;; A line record is a vector:
;;   [START END START-DEPTH END-DEPTH BARS BLOCKED COMMENT]
;; START/END are offsets into the content string (END excludes the newline);
;; BARS is a list of (POS . RUNLEN) for unescaped depth-0 bar runs, in order.

(defun klammertext-align--line-index (starts p)
  "Index of the line (in the STARTS vector) containing offset P."
  (let ((lo 0) (hi (1- (length starts))))
    (while (< lo hi)
      (let ((mid (/ (+ lo hi 1) 2)))
        (if (<= (aref starts mid) p)
            (setq lo mid)
          (setq hi (1- mid)))))
    lo))

(defun klammertext-align--block-lines (lines starts n a b)
  "Mark every line record touched by [A, B) as blocked."
  (let* ((last (max a (1- b)))
         (k0 (klammertext-align--line-index starts a))
         (k1 (klammertext-align--line-index starts (min last (max 0 (1- n))))))
    (let ((k k0))
      (while (<= k k1)
        (aset (aref lines k) 5 t)
        (setq k (1+ k))))))

(defun klammertext-align--scan-lines (content)
  "Scan CONTENT (a table span's text).  Return the vector of line records."
  (let* ((n (length content))
         (starts (let ((acc (list 0)))
                   (dotimes (idx n)
                     (when (eq (aref content idx) ?\n)
                       (push (1+ idx) acc)))
                   (vconcat (nreverse acc))))
         (nlines (length starts))
         (lines (make-vector nlines nil)))
    (dotimes (k nlines)
      (aset lines k (vector (aref starts k)
                            (if (< (1+ k) nlines)
                                (1- (aref starts (1+ k)))
                              n)
                            nil nil nil nil nil)))
    (aset (aref lines 0) 2 0)
    (let ((depth 0) (i 0) (go t))
      (while (and go (< i n))
        (let ((j i))
          (while (and (< j n)
                      (not (memq (aref content j) '(?@ ?# ?| ?\n))))
            (setq j (1+ j)))
          (if (>= j n)
              (setq go nil)
            (let ((hit j) (c (aref content j)))
              (setq i (1+ hit))
              (cond
               ((eq c ?\n)
                (let ((k (klammertext-align--line-index starts hit)))
                  (aset (aref lines k) 3 depth)
                  (when (< (1+ k) nlines)
                    (aset (aref lines (1+ k)) 2 depth))))
               ((klammertext-align--escaped-p content hit))
               ((eq c ?#)
                (let ((next (and (< (1+ hit) n) (aref content (1+ hit)))))
                  (cond
                   ((eq next ?#)
                    (klammertext-align--block-lines lines starts n hit n)
                    (setq go nil))
                   ((eq next ?\[)
                    (let ((e (klammertext-align--block-end content (+ hit 2))))
                      (unless (= (klammertext-align--line-index starts (max hit (1- e)))
                                 (klammertext-align--line-index starts hit))
                        (klammertext-align--block-lines lines starts n hit e))
                      (setq i e)))
                   ((memq next '(?+ ?/ ?-)))
                   (t
                    (aset (aref lines (klammertext-align--line-index starts hit)) 6 t)
                    (let ((eol (string-search "\n" content hit)))
                      (setq i (or eol n)))))))
               ((eq c ?|)
                (unless (and (> hit 0) (eq (aref content (1- hit)) ?|))
                  (let ((k hit))
                    (while (and (< k n) (eq (aref content k) ?|))
                      (setq k (1+ k)))
                    (when (= depth 0)
                      (let ((rec (aref lines (klammertext-align--line-index starts hit))))
                        (aset rec 4 (append (aref rec 4)
                                            (list (cons hit (- k hit)))))))
                    (setq i k))))
               (t                        ; ?@
                (let* ((run-end (klammertext-align--at-run-end content hit))
                       (run-len (- run-end hit))
                       (after (and (< run-end n) (aref content run-end))))
                  (if (klammertext--name-char-p after)
                      (let* ((k (klammertext-align--name-end content run-end))
                             (name (substring content run-end k)))
                        (setq i k)
                        (cond
                         ((and (= run-len 1)
                               (member name klammertext-literal-klammers))
                          (let* ((idx (string-search (concat name "@") content k))
                                 (e (if idx (+ idx (length name) 1) n)))
                            (unless (= (klammertext-align--line-index
                                        starts (max hit (1- e)))
                                       (klammertext-align--line-index starts hit))
                              (klammertext-align--block-lines lines starts n hit e))
                            (setq i e)))
                         ((and (= run-len 1) (< k n) (eq (aref content k) ?-)))
                         (t (setq depth (1+ depth)))))
                    (setq depth (max 0 (1- depth)))
                    (setq i run-end)))))))))
      (dotimes (k nlines)
        (let ((rec (aref lines k)))
          (unless (aref rec 2) (aset rec 5 t))
          (unless (aref rec 3) (aset rec 3 depth)))))
    lines))

;; --- the alignment -------------------------------------------------------

(defun klammertext-align--indent-width (content rec)
  "Width of the leading whitespace of line REC in CONTENT."
  (let ((i (aref rec 0)) (end (aref rec 1)))
    (while (and (< i end) (memq (aref content i) '(?\s ?\t)))
      (setq i (1+ i)))
    (- i (aref rec 0))))

(defun klammertext-align--trailing-rowsep-p (content rec)
  "Non-nil when line REC's last depth-0 bar run is a || at the end of the
line (only whitespace after it)."
  (let ((bars (aref rec 4)))
    (and bars
         (let* ((run (car (last bars)))
                (pos (car run)))
           (and (= (cdr run) 2)
                (string-blank-p (substring content (+ pos 2) (aref rec 1))))))))

(defun klammertext-align--parse-row (content rec text chain-ok last-content-p)
  "If line REC is an alignable row, return (REC CELLS TRAILING-P); else nil.
TEXT is the line's text; see the file header for the rules."
  (catch 'no
    (when (or (aref rec 5) (aref rec 6) (not chain-ok))
      (throw 'no nil))
    (unless (and (eql (aref rec 2) 0) (eql (aref rec 3) 0))
      (throw 'no nil))
    (unless (aref rec 4)
      (throw 'no nil))
    (let* ((trailing (klammertext-align--trailing-rowsep-p content rec))
           (bars (aref rec 4))
           (singles (if trailing (butlast bars) bars)))
      (dolist (run singles)
        (unless (= (cdr run) 1)
          (throw 'no nil)))              ; a mid-line || or ||| : not one row
      (unless (or trailing last-content-p)
        (throw 'no nil))                 ; row continues onto the next line
      (ignore text)
      (let* ((cell-start (+ (aref rec 0)
                            (klammertext-align--indent-width content rec)))
             (cell-end (if trailing (car (car (last bars))) (aref rec 1)))
             (bounds (append (list cell-start)
                             (mapcar #'car singles)
                             (list cell-end)))
             (cells nil)
             (b-idx 0))
        (while (< b-idx (1- (length bounds)))
          (let* ((a (+ (nth b-idx bounds) (if (> b-idx 0) 1 0)))
                 (cell (string-trim (substring content a (nth (1+ b-idx) bounds)))))
            (when (> (length cell) klammertext-align-cell-max)
              (throw 'no nil))
            (push cell cells)
            (setq b-idx (1+ b-idx))))
        (list rec (nreverse cells) trailing)))))

(defun klammertext-align--pad (cell width)
  (concat cell (make-string (max 0 (- width (length cell))) ?\s)))

(defun klammertext-align--edits (content)
  "Compute the alignment edits for CONTENT (a table span's text).
Return (EDITS . MESSAGE): EDITS is a list of (START END NEW) triples
relative to CONTENT, ascending; MESSAGE is a status string (a reason when
EDITS is nil)."
  (let* ((lines (klammertext-align--scan-lines content))
         (nlines (length lines))
         (last-content nil))
    (let ((k (1- nlines)))
      (while (and (>= k 1) (null last-content))
        (let ((rec (aref lines k)))
          (unless (string-blank-p (substring content (aref rec 0) (aref rec 1)))
            (setq last-content k)))
        (setq k (1- k))))
    (let ((rows nil) (chain-ok t))
      (let ((k 1))
        (while (< k nlines)
          (let* ((rec (aref lines k))
                 (text (substring content (aref rec 0) (aref rec 1)))
                 (stripped (string-trim text)))
            (cond
             ((string-empty-p stripped))          ; blank: chain unchanged
             ((and (string-prefix-p ":" stripped) ; option line: unchanged
                   (null (aref rec 4))))
             (t
              (let ((row (klammertext-align--parse-row
                          content rec text chain-ok (eql k last-content))))
                (setq chain-ok (klammertext-align--trailing-rowsep-p content rec))
                (when row (push row rows))))))
          (setq k (1+ k))))
      (setq rows (nreverse rows))
      (if (null rows)
          (cons nil "no alignable rows found")
        (let* ((ncols (apply #'max (mapcar (lambda (r) (length (nth 1 r))) rows)))
               (widths (make-vector ncols 0)))
          (dolist (r rows)
            (let ((c-idx 0))
              (dolist (cell (nth 1 r))
                (aset widths c-idx (max (aref widths c-idx) (length cell)))
                (setq c-idx (1+ c-idx)))))
          (let* ((indent (make-string (klammertext-align--indent-width
                                       content (nth 0 (car rows)))
                                      ?\s))
                 (longest 0))
            (dolist (r rows)
              (let* ((m (length (nth 1 r)))
                     (w (+ (length indent)
                           (let ((sum 0) (c 0))
                             (while (< c m)
                               (setq sum (+ sum (aref widths c)) c (1+ c)))
                             sum)
                           (* 3 (1- m))
                           (if (nth 2 r) 3 0))))
                (setq longest (max longest w))))
            (if (and klammertext-align-row-max
                     (> longest klammertext-align-row-max))
                (cons nil (format "aligned rows would be %d characters (limit %d); not aligning"
                                  longest klammertext-align-row-max))
              (let ((edits nil))
                (dolist (r rows)
                  (let* ((rec (nth 0 r))
                         (cells (nth 1 r))
                         (trailing (nth 2 r))
                         (m (length cells))
                         (parts nil)
                         (c-idx 0))
                    (dolist (cell cells)
                      (push (if (and (= c-idx (1- m)) (not trailing))
                                cell
                              (klammertext-align--pad cell (aref widths c-idx)))
                            parts)
                      (setq c-idx (1+ c-idx)))
                    (let ((new (concat indent
                                       (mapconcat #'identity (nreverse parts) " | ")
                                       (if trailing " ||" ""))))
                      (unless (string= new (substring content
                                                      (aref rec 0) (aref rec 1)))
                        (push (list (aref rec 0) (aref rec 1) new) edits)))))
                (setq edits (nreverse edits))
                (cons edits
                      (if edits
                          (format "aligned %d rows" (length rows))
                        "already aligned"))))))))))

;; --- the command ---------------------------------------------------------

(defun klammertext-align-table ()
  "Align the columns of the table klammer containing point.
Rows with a cell longer than `klammertext-align-cell-max' characters, or
spanning lines, are left untouched; see the file header for the full rules."
  (interactive)
  (let ((span (klammertext-align--enclosing-span (point)
                                                 klammertext-align-klammers)))
    (unless span
      (user-error "Point is not inside a table klammer (%s)"
                  (mapconcat (lambda (name) (concat "@" name))
                             klammertext-align-klammers ", ")))
    (let* ((beg (nth 1 span))
           (content (buffer-substring-no-properties beg (nth 2 span)))
           (result (klammertext-align--edits content)))
      (save-excursion
        (dolist (e (sort (copy-sequence (car result))
                         (lambda (a b) (> (nth 0 a) (nth 0 b)))))
          (goto-char (+ beg (nth 0 e)))
          (delete-region (+ beg (nth 0 e)) (+ beg (nth 1 e)))
          (insert (nth 2 e))))
      (message "Klammertext: %s" (cdr result)))))

(define-key klammertext-mode-map (kbd "C-c C-a") #'klammertext-align-table)

(provide 'klammertext-align)
;;; klammertext-align.el ends here
