;;; klammertext-indent.el --- TAB indentation for Klammertext -*- lexical-binding: t; -*-

;; EXPERIMENTAL.  This file is a separate unit, loaded from the init file:
;;
;;   (require 'klammertext-indent)
;;
;; Comment that line out to disable indentation entirely; klammertext-mode
;; itself is untouched by this file.
;;
;; The convention (2026-07-26):
;;
;;   @ol                      <- opener at its context's content column
;;     Item one               <- content: opener column + 2
;;   | Item two               <- bar run at the OPENER's column ("| " is two
;;     @ol                       characters, so item text aligns with "Item one")
;;       Embedded item one
;;     | Embedded item two
;;     @                      <- close at its opener's column
;;   | Item four
;;   @
;;
;; Formal rule: a line indents to  offset x (effective depth);  a line that
;; BEGINS with a bar run (|, ||, ...) or a closing delimiter (@ or name@)
;; indents one level less, i.e. to its owner's opening column.  The bar-run
;; rule is dimension-independent: | (list items), || (table rows) and any
;; longer run all drop to the opener's column.  Effective depth counts every
;; enclosing span uniformly -- applications (@), definitions (@@), and
;; system commands (@@@) -- with these exceptions:
;;
;;   * Klammers in `klammertext-transparent-klammers' (seeded with
;;     "document") contribute no level, so ordinary paragraphs of a document
;;     sit at the left margin.
;;   * Lines inside a literal klammer's verbatim content (@code ... code@)
;;     and inside the argument span of a klammer in
;;     `klammertext-code-klammers' (seeded with "eval" -- inline Python is
;;     indentation-sensitive!) are NEVER touched: TAB returns `noindent'.
;;     Removed regions (#[ ... ]#, everything after ##) are likewise left
;;     alone.
;;
;; Indentation happens only on explicit TAB / indent-region; nothing
;; reformats automatically, because whitespace is content in Klammertext.
;; The convention is nevertheless semantically free where it applies: list
;; and table cell content is stripped by the SKS, leading whitespace
;; collapses in the html/tex targets, and a blank line that acquires
;; indentation spaces still separates paragraphs (the SKS paragraph
;; separator is  \n *\n ).
;;
;; Known limitation: a raw @ inside a ^'...'^ literal region would confuse
;; the depth scan (the same limitation as the font-lock scanner).
;;
;; SYNC: 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 — carries this file's policy as INDENT_OFFSET /
;; TRANSPARENT_KLAMMERS / CODE_KLAMMERS (an elisp defcustom cannot be read
;; from Python, so this unit remains an independent implementation, held
;; equal by tst/editor_test.sh's byte-equality checks).  When you change
;; `klammertext-indent-offset', `klammertext-transparent-klammers' or
;; `klammertext-code-klammers', mirror the change there.

;;; Code:

(require 'klammertext-mode)

(defgroup klammertext-indent nil
  "Indentation for Klammertext files."
  :group 'klammertext)

(defcustom klammertext-indent-offset 2
  "Number of columns per klammer nesting level."
  :type 'integer
  :group 'klammertext-indent)

(defcustom klammertext-transparent-klammers '("document")
  "Klammers whose span contributes no indentation level.
The @document klammer is transparent so that the ordinary paragraphs of a
document sit at the left margin; a future top-level peer (e.g. @jupyter)
would be registered here too."
  :type '(repeat string)
  :group 'klammertext-indent)

(defcustom klammertext-code-klammers '("eval")
  "Klammers whose argument span holds code, never reindented.
Lines inside such a span answer TAB with `noindent'.  @eval is seeded
because its content is Python, C++ or shell source -- Python in particular
is indentation-sensitive.  This mirrors the Klammermachine's own rule that
@eval argument spans hold code, not writer text."
  :type '(repeat string)
  :group 'klammertext-indent)

;; --- Depth scanner ------------------------------------------------------

(defun klammertext-indent--state-at (pos)
  "Scan from `point-min' to POS (a line beginning).
Return (STACK . OPAQUE): STACK is the list of names of the klammer
applications, @@ definitions and @@@ commands open at POS, innermost
first; OPAQUE is
non-nil when POS lies inside content that indentation must not touch
\(removed text, a literal klammer's verbatim span, or a code klammer's
argument span).  Reuses the mode's classification helpers, so escaped ^@ /
^#, the abbreviated @name-arg form, and #+ #/ #- are all stepped over the
same way the font-lock scanner steps over them."
  (save-excursion
    (goto-char (point-min))
    (let ((stack nil) (opaque nil) (done nil))
      (while (and (not done)
                  (re-search-forward "[@#]" pos t))
        (let ((hit (1- (point))))
          (cond
           ((klammertext--escaped-p hit))       ; ^@ / ^# : plain text
           ((eq (char-after hit) ?#)
            (let ((next (char-after (1+ hit))))
              (cond
               ((eq next ?#)                    ; ## removes to end of buffer
                (setq opaque t done t))
               ((eq next ?\[)                   ; #[ ... ]# (nestable)
                (let ((end (klammertext--block-end (+ hit 2))))
                  (if (< pos end)
                      (setq opaque t done t)
                    (goto-char end))))
               ((memq next '(?+ ?/ ?-)))        ; whitespace operators
               (t (goto-char (line-end-position)))))) ; # to end of line
           (t                                   ; an @-run
            (let* ((run-end (klammertext--at-run-end hit))
                   (len (- run-end hit)))
              (cond
               ;; @name / @@name / @@@name : an opener (or, for a literal
               ;; klammer, a verbatim span to step over).
               ((klammertext--name-char-p (char-after run-end))
                (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))
                    ;; Verbatim interior: find the closing NAME@ by name.
                    (if (klammertext--search-literal-close name)
                        (when (< pos (point))
                          (setq opaque t done t))
                      (setq opaque t done t)))   ; never closed
                   ((and (= len 1) (eq (char-after) ?-)))  ; @name-arg : no span
                   (t (push name stack)))))
               ;; a bare @-run, or the run of a named close: a close.
               (t
                (pop stack)
                (goto-char run-end))))))))
      ;; Inside the argument span of a code klammer (e.g. a multi-line @eval)?
      (unless opaque
        (let ((s stack))
          (while s
            (when (member (car s) klammertext-code-klammers)
              (setq opaque t s nil))
            (setq s (cdr s)))))
      (cons stack opaque))))

(defun klammertext-indent--depth (stack)
  "Number of indentation levels STACK contributes.
Transparent klammers contribute none."
  (let ((d 0))
    (dolist (name stack d)
      (unless (member name klammertext-transparent-klammers)
        (setq d (1+ d))))))

;; --- Line classification ------------------------------------------------

(defun klammertext-indent--dedent-line-p ()
  "Non-nil when the current line begins with a token that sits at its
owner's opening column: a bar run (|, ||, ...), a bare close run (@, @@,
@@@), or a named close (name@, name@@, name@@@).  A line beginning with an
opener (@name, @@name, @@@name) is content-level."
  (save-excursion
    (back-to-indentation)
    (let ((c (char-after)))
      (cond
       ((null c) nil)
       ((eq c ?|) t)
       ((eq c ?@)
        (not (klammertext--name-char-p
              (char-after (klammertext--at-run-end (point))))))
       ((klammertext--name-char-p c)
        ;; A named close: name chars followed by an @-run (an unescaped @
        ;; can only be a delimiter).
        (skip-chars-forward "A-Za-z0-9_")
        (eq (char-after) ?@))
       (t nil)))))

;; --- The indent function ------------------------------------------------

(defun klammertext-indent--target-column ()
  "Column for the current line, or the symbol `noindent'."
  (let* ((state (klammertext-indent--state-at (line-beginning-position)))
         (stack (car state)))
    (if (cdr state)
        'noindent
      (* klammertext-indent-offset
         (klammertext-indent--depth
          (if (klammertext-indent--dedent-line-p) (cdr stack) stack))))))

(defun klammertext-indent-line ()
  "Indent the current line per the Klammertext convention.
Content indents to `klammertext-indent-offset' x depth; a line beginning
with a bar run or a closing delimiter aligns with its owner's opening
column.  Lines inside verbatim, code, or removed content are left alone."
  (interactive)
  (let ((target (klammertext-indent--target-column)))
    (if (eq target 'noindent)
        'noindent
      (if (> (current-column) (current-indentation))
          (save-excursion (indent-line-to target))
        (indent-line-to target)))))

;; --- Wiring -------------------------------------------------------------

(defun klammertext-indent-setup ()
  "Enable Klammertext indentation in the current buffer."
  (setq-local indent-line-function #'klammertext-indent-line)
  ;; Klammer indentation columns are small and semantic; never use tabs.
  (setq-local indent-tabs-mode nil))

(add-hook 'klammertext-mode-hook #'klammertext-indent-setup)

;; Also enable in klammertext-mode buffers already open when this loads.
(dolist (buf (buffer-list))
  (with-current-buffer buf
    (when (derived-mode-p 'klammertext-mode)
      (klammertext-indent-setup))))

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