ebox/ebox-selector.el

382 lines
16 KiB
EmacsLisp

;;; ebox-selector.el --- CSS-like runtime selectors for Ebox -*- lexical-binding: t; -*-
;;; Commentary:
;; Delegates CSS-like lookup syntax and matching to ECSS, and adapts Ebox
;; trees/runtime indexes to ECSS subjects. Stable identity remains node ids,
;; region ids, and keys.
;;; Code:
(require 'cl-lib)
(require 'subr-x)
(require 'ecss-selector)
(require 'ebox-buffer-backend)
(require 'ebox-tree)
(require 'tp-surface)
(declare-function ebox--ensure-node-id "ebox" (node))
(declare-function ebox--ensure-region-id "ebox" (box))
(declare-function ebox--buffer-root-node "ebox-incremental" (buffer))
(declare-function ebox--buffer-render-state "ebox-incremental" (buffer))
(declare-function ebox--buffer-source-index "ebox-incremental" (buffer))
(declare-function ebox--buffer-selector-id-table "ebox-incremental" (buffer))
(declare-function ebox--buffer-selector-class-table
"ebox-incremental" (buffer))
(declare-function ebox--buffer-selector-type-table
"ebox-incremental" (buffer))
(declare-function ebox-incremental-begin-batch "ebox-incremental" (buffer))
(declare-function ebox-incremental-flush "ebox-incremental" (buffer))
(declare-function ebox-region-update "ebox" (handle &rest props))
(declare-function ebox-surface-call-with-observation
"ebox-surface" (buffer stage function))
(cl-defstruct
(ebox-region-handle
(:constructor ebox-selector--make-region-handle)
(:conc-name ebox-selector--region-handle-))
"Opaque surface-scoped identity for one editable Ebox region."
buffer surface object logical-id)
(defun ebox-selector--live-region-handle
(buffer object &optional logical-id)
"Return a live region handle for OBJECT in BUFFER, or nil."
(let* ((state (ebox--buffer-render-state buffer))
(surface (plist-get state :surface))
(region-id
(and (tp-object-live-p object)
(gethash object
(plist-get state :surface-object-region-table)))))
(when (and (tp-surface-live-p surface) region-id)
(ebox-selector--make-region-handle
:buffer buffer :surface surface :object object :logical-id logical-id))))
;;;###autoload
(defun ebox-region-resolve (buffer-or-name logical-id)
"Resolve LOGICAL-ID in BUFFER-OR-NAME to an opaque editable region handle."
(let ((buffer (get-buffer buffer-or-name)))
(unless (buffer-live-p buffer)
(user-error "Ebox region target is not a live buffer: %S"
buffer-or-name))
(let* ((state (ebox--buffer-render-state buffer))
(normalized (ebox-tree-metadata-string logical-id))
(entries
(and state
(gethash normalized
(plist-get state :logical-id-region-table))))
(handles
(delq nil
(mapcar
(lambda (entry)
(ebox-selector--live-region-handle
buffer (car entry) normalized))
entries))))
(pcase (length handles)
(0 (user-error "Ebox logical region does not exist: %S" logical-id))
(1 (car handles))
(_ (user-error "Ebox logical region is ambiguous: %S" logical-id))))))
(defun ebox-selector--region-target (handle)
"Return HANDLE's current internal `(BUFFER . REGION-ID)' target."
(unless (ebox-region-handle-p handle)
(signal 'wrong-type-argument (list 'ebox-region-handle-p handle)))
(let* ((buffer (ebox-selector--region-handle-buffer handle))
(surface (ebox-selector--region-handle-surface handle))
(object (ebox-selector--region-handle-object handle))
(logical-id (ebox-selector--region-handle-logical-id handle))
(state (and (buffer-live-p buffer)
(ebox--buffer-render-state buffer)))
(region-id
(and state
(eq surface (plist-get state :surface))
(tp-surface-live-p surface)
(tp-object-live-p object)
;; A retained positional object can be reused for a different
;; source node. A named handle must still name this object.
(or (null logical-id)
(assq object
(gethash (ebox-tree-metadata-string logical-id)
(plist-get state :logical-id-region-table))))
(gethash object
(plist-get state :surface-object-region-table)))))
(unless region-id
(user-error "Ebox region handle is stale"))
(cons buffer region-id)))
;;;###autoload
(defun ebox-selector-parse (selector)
"Compile CSS-like SELECTOR through the ECSS parser."
(condition-case error-data
(ecss-selector-parse selector)
(ecss-invalid-selector
(user-error "ebox-selector: invalid selector %S: %S"
selector (cdr error-data)))))
(defun ebox-selector--node-region-id (node)
"Return NODE's editable box region id, or nil."
(pcase (and (listp node) (plist-get node :ebox-type))
('box (ebox--ensure-region-id node))
('flex
(when-let* ((box (plist-get node :box)))
(ebox--ensure-region-id box)))
('grid
(when-let* ((box (plist-get node :box)))
(ebox--ensure-region-id box)))
(_ nil)))
(defun ebox-selector--root-and-index (value context)
"Return `(ROOT . SOURCE-INDEX)' from canonical input VALUE for CONTEXT."
(if (ebox-canonical-input-p value)
(cons (ebox-canonical-input--single-root value context)
(ebox-canonical-input--source-index value))
(error "%s requires one canonical Ebox input" context)))
;;;###autoload
(defun ebox-selector-match-node-p (node selector)
"Return non-nil when NODE matches structured ECSS SELECTOR."
(pcase-let* ((`(,root . ,source-index)
(ebox-selector--root-and-index
node "ebox-selector-match-node-p"))
(source-index
(ebox-tree-source-index root nil nil source-index)))
(ecss-selector-match-p
selector (ebox-tree-node-subject source-index root))))
(defun ebox-selector--match-handle (node path selector)
"Return a selector match handle for NODE on PATH."
(list :node node
:node-id (ebox--ensure-node-id node)
:region-id (ebox-selector--node-region-id node)
:path path
:selector selector))
(defun ebox-selector--matching-subject-entries (root ast &optional source-index)
"Return semantic ROOT entries in SOURCE-INDEX whose subjects match AST."
(cl-remove-if-not
(lambda (entry)
(ecss-selector-match-p ast (plist-get entry :subject)))
(plist-get (ebox-tree-subject-index root source-index) :entries)))
(defun ebox-selector--subject-entry-handle (entry selector)
"Return a match handle for semantic subject ENTRY and SELECTOR."
(ebox-selector--match-handle
(plist-get entry :node) (plist-get entry :path) selector))
;;;###autoload
(defun ebox-selector-query-all (root selector)
"Return document-ordered selector match handles under ROOT."
(pcase-let* ((`(,root . ,source-index)
(ebox-selector--root-and-index
root "ebox-selector-query-all"))
(source-index
(ebox-tree-source-index root nil nil source-index))
(ast (ebox-selector-parse selector)))
(mapcar (lambda (entry)
(ebox-selector--subject-entry-handle entry selector))
(ebox-selector--matching-subject-entries
root ast source-index))))
(defun ebox-selector--resolve-buffer (buffer)
"Return live buffer object for BUFFER, or signal a user error."
(let ((resolved (get-buffer buffer)))
(unless (and resolved (buffer-live-p resolved))
(user-error "ebox-selector: buffer is not live: %S" buffer))
resolved))
(defun ebox-selector--handle-with-buffer (handle buffer)
"Return selector HANDLE annotated with BUFFER ownership."
(let* ((state (ebox--buffer-render-state buffer))
(region-id (plist-get handle :region-id))
(object
(and region-id
(gethash region-id
(plist-get state :region-surface-object-table))))
(source-index (plist-get state :source-index))
(region-handle
(and object
(ebox-selector--live-region-handle
buffer object
(ebox-tree-node-id
source-index (plist-get handle :node))))))
(append handle
(list :buffer buffer :region-handle region-handle))))
(defun ebox-selector--relational-p (ast)
"Return non-nil when AST contains a selector relationship."
(pcase (car-safe ast)
((or :descendant :child :adjacent :sibling) t)
(:has t)
((or :and :list :is :where :not)
(cl-some #'ebox-selector--relational-p (cdr ast)))
(_ nil)))
(defun ebox-selector--descendant-indexable-p (ast)
"Return non-nil when AST uses no relationship except descendants."
(pcase (car-safe ast)
(:descendant
(and (ebox-selector--descendant-indexable-p (nth 1 ast))
(ebox-selector--descendant-indexable-p (nth 2 ast))))
((or :child :adjacent :sibling :has) nil)
((or :and :list :is :where :not)
(cl-every #'ebox-selector--descendant-indexable-p (cdr ast)))
(_ t)))
(defun ebox-selector--target-selector (ast)
"Return the rightmost target selector from relational AST."
(if (memq (car-safe ast) '(:descendant :child :adjacent :sibling))
(ebox-selector--target-selector (nth 2 ast))
ast))
(defun ebox-selector--index-hints (ast)
"Return indexable id, class, and type hints directly contained by AST."
(let (id classes type)
(cl-labels ((visit (selector)
(pcase (car-safe selector)
(:id (setq id (nth 1 selector)))
(:class (push (nth 1 selector) classes))
(:type (setq type (nth 1 selector)))
(:and (mapc #'visit (cdr selector))))))
(visit ast))
(list :id id :classes (nreverse classes) :type type)))
(defun ebox-selector--shortest-candidates (candidate-lists)
"Return the shortest non-empty list from CANDIDATE-LISTS."
(car (sort (delq nil candidate-lists)
(lambda (left right) (< (length left) (length right))))))
(defun ebox-selector--indexed-candidates (buffer ast)
"Return `(SUPPORTED . ENTRIES)' for AST's indexed target in BUFFER."
(let* ((hints (ebox-selector--index-hints
(ebox-selector--target-selector ast)))
(id (plist-get hints :id))
(classes (plist-get hints :classes))
(type (plist-get hints :type))
candidates)
(when id
(push (gethash id (ebox--buffer-selector-id-table buffer)) candidates))
(dolist (class classes)
(push (gethash class (ebox--buffer-selector-class-table buffer))
candidates))
(when type
(push (gethash (if (stringp type) (intern type) type)
(ebox--buffer-selector-type-table buffer))
candidates))
(when (or id classes type)
(cons t (ebox-selector--shortest-candidates candidates)))))
(defun ebox-selector--indexed-entry-matches-p (source-index entry ast)
"Return non-nil when SOURCE-INDEX ENTRY matches ECSS selector AST."
(let ((subject
(if (ebox-selector--relational-p ast)
(ebox-tree-subject-for-path source-index (cdr entry))
(ebox-tree-node-subject source-index (car entry)))))
(ecss-selector-match-p ast subject)))
(defun ebox-selector--entries-to-buffer-handles (entries selector buffer)
"Return selector match handles for ENTRIES annotated with BUFFER."
(mapcar (lambda (entry)
(ebox-selector--handle-with-buffer
(ebox-selector--match-handle
(car entry) (cdr entry) selector)
buffer))
entries))
(defun ebox-selector--query-buffer-index (buffer selector ast)
"Return `(SUPPORTED . HANDLES)' for an indexed AST query in BUFFER."
(when (ebox-selector--descendant-indexable-p ast)
(when-let* ((indexed (ebox-selector--indexed-candidates buffer ast)))
(let ((source-index (ebox--buffer-source-index buffer)))
(cons
t
(ebox-selector--entries-to-buffer-handles
(cl-remove-if-not
(lambda (entry)
(ebox-selector--indexed-entry-matches-p
source-index entry ast))
(cdr indexed))
selector buffer))))))
(defun ebox-selector--query-buffer-tree
(root source-index selector ast buffer)
"Return semantic ROOT matches from SOURCE-INDEX for SELECTOR in BUFFER."
(mapcar
(lambda (entry)
(ebox-selector--handle-with-buffer
(ebox-selector--subject-entry-handle entry selector) buffer))
(ebox-selector--matching-subject-entries root ast source-index)))
;;;###autoload
(defun ebox-selector-query-buffer (buffer selector)
"Return document-ordered selector match handles from BUFFER runtime state.
Selector lookup reads the stored runtime root, not rendered buffer text
properties. Each returned handle includes `:buffer' in addition to the
tree-query handle fields."
(let* ((resolved-buffer (ebox-selector--resolve-buffer buffer))
(root (ebox--buffer-root-node resolved-buffer))
(source-index (ebox--buffer-source-index resolved-buffer)))
(unless root
(user-error "ebox-selector: buffer has no Ebox runtime state: %S"
resolved-buffer))
(let* ((ast (ebox-selector-parse selector))
(indexed (ebox-selector--query-buffer-index
resolved-buffer selector ast)))
(if indexed
(cdr indexed)
(ebox-selector--query-buffer-tree
root source-index selector ast resolved-buffer)))))
(defun ebox-selector--skip-handle (handle reason)
"Return a structured skip entry for HANDLE and REASON."
(list :node-id (plist-get handle :node-id)
:reason reason))
(defun ebox-selector--update-region (handle props)
"Apply PROPS to HANDLE's region through `ebox-region-update'."
(apply #'ebox-region-update (plist-get handle :region-handle) props))
;;;###autoload
(defun ebox-selector-update-buffer (buffer selector &rest props)
"Apply PROPS to editable selector matches in BUFFER.
SELECTOR locates runtime nodes, then every editable match is updated through
`ebox-region-update'. Return a summary plist with match counts, structured
skips, and update reports."
(unless props
(user-error "ebox-selector: update requires at least one property"))
(let ((resolved-buffer (ebox-selector--resolve-buffer buffer)))
(ebox-surface-call-with-observation
resolved-buffer 'selector
(lambda ()
(ebox--with-render-gc
(let ((matches
(ebox-selector-query-buffer resolved-buffer selector))
editable skipped reports)
(unless matches
(user-error "ebox-selector: no matches for %S" selector))
(dolist (match matches)
(if (plist-get match :region-handle)
(push match editable)
(push (ebox-selector--skip-handle match 'no-region) skipped)))
(setq editable (nreverse editable)
skipped (nreverse skipped))
(cond
((null editable)
(setq reports nil))
((= (length editable) 1)
(setq reports
(list (ebox-selector--update-region
(car editable) props))))
(t
(ebox-incremental-begin-batch resolved-buffer)
(dolist (match editable)
(ebox-selector--update-region match props))
(setq reports
(list (ebox-incremental-flush resolved-buffer)))))
(list :selector selector
:matched (length matches)
:updated (length editable)
:skipped skipped
:reports reports)))))))
(provide 'ebox-selector)
;;; ebox-selector.el ends here