diff --git a/ebox-flex.el b/ebox-flex.el index e342090..a425c35 100644 --- a/ebox-flex.el +++ b/ebox-flex.el @@ -40,6 +40,8 @@ (cache-key signature rendered &optional side-effects)) (declare-function ebox--render-with-cache "ebox-incremental" (node &optional force cache-probe)) +(declare-function ebox-tree--register-legacy-flex-item-adapter + "ebox-tree" (adapter token source content)) (declare-function ebox-create "ebox" (&rest plist)) (declare-function ebox-get "ebox" (box property)) (declare-function ebox--ensure-node-id "ebox" (node)) @@ -80,6 +82,14 @@ '(:order :flex :flex-grow :flex-shrink :flex-basis :align-self) "Properties that belong to flex item metadata.") +(defconst ebox--flex-item-direct-adapter-token + (make-symbol "ebox-flex-item-direct-adapter") + "Opaque provenance token for a legacy Flex item without a Box wrapper.") + +(defconst ebox--flex-item-wrapper-adapter-token + (make-symbol "ebox-flex-item-wrapper-adapter") + "Opaque provenance token for a legacy Flex item with one Box wrapper.") + (defconst ebox--flex-container-layout-prop-keys '(:flex-direction :flex-wrap :flex-flow :justify-content :align-items :align-content @@ -519,11 +529,15 @@ programmatic port and are therefore rejected here." (not (eq (plist-get source :ebox-type) 'box)))) (defun ebox--flex-bind-source-viewport (source viewport thunk) - "Call THUNK with VIEWPORT bound for composite flex item SOURCE." - (if (and viewport (ebox--flex-composite-source-p source)) - (let ((ebox-viewport-width viewport)) - (funcall thunk)) - (funcall thunk))) + "Call THUNK in SOURCE's detached Flex-item render context. +VIEWPORT is bound only for composite SOURCE nodes; the Flex parent context is +always bound because SOURCE remains a direct Flex participant while rendered +as a detached geometry root." + (let ((ebox--render-root-parent-kind 'flex)) + (if (and viewport (ebox--flex-composite-source-p source)) + (let ((ebox-viewport-width viewport)) + (funcall thunk)) + (funcall thunk)))) (defvar ebox--flex-sized-render-observation-table (make-hash-table :test 'eq :weakness 'key) @@ -575,13 +589,18 @@ this avoids signature and retained-cost work for one-off responsive sizes." Direct box flex items are measured intrinsically. Composite items receive VIEWPORT for descendants so auto and max-width layouts can resolve against their containing block, while stack/column containers do not stretch their own -outer measurement width to VIEWPORT." + outer measurement width to VIEWPORT." (if (ebox--flex-composite-source-p source) - (let ((ebox-viewport-width viewport) - (ebox--intrinsic-layout-measurement t)) - (ebox--render-with-cache source)) - (let ((ebox-viewport-width nil)) - (ebox--render-with-cache source)))) + (ebox--flex-bind-source-viewport + source viewport + (lambda () + (let ((ebox--intrinsic-layout-measurement t)) + (ebox--render-with-cache source)))) + (ebox--flex-bind-source-viewport + source nil + (lambda () + (let ((ebox-viewport-width nil)) + (ebox--render-with-cache source)))))) (defun ebox--flex-measured-render-context-compatible-p (source measured-viewport final-viewport) @@ -1115,12 +1134,14 @@ Return a sized render entry containing the rendered string and dimensions." (ebox--flex-sized-entry-rendered fragment-entry)) ((and fragment-key (fboundp 'ebox--render-cache-render-with-scroll-actions)) - (let ((ebox-viewport-width item-viewport)) - (pcase-let ((`(,captured-rendered ,captured-side-effects) - (ebox--render-cache-render-with-scroll-actions - (or node source)))) - (setq fragment-side-effects captured-side-effects) - captured-rendered))) + (ebox--flex-bind-source-viewport + source item-viewport + (lambda () + (pcase-let ((`(,captured-rendered ,captured-side-effects) + (ebox--render-cache-render-with-scroll-actions + (or node source)))) + (setq fragment-side-effects captured-side-effects) + captured-rendered)))) (t (ebox--flex-bind-source-viewport source item-viewport @@ -1629,12 +1650,18 @@ metadata and rendering decisions." (source (copy-sequence source)) (declarations (append (plist-get source :ebox-style-declarations) - (ebox-style-compile-declarations item-props)))) + (ebox-style-compile-declarations item-props))) + (adapter (list :ebox-type 'flex-item + :display '(block flow) + :node source + :props item-props)) + (token (if box-props + ebox--flex-item-wrapper-adapter-token + ebox--flex-item-direct-adapter-token))) (plist-put source :ebox-style-declarations declarations) - (list :ebox-type 'flex-item - :display '(block flow) - :node source - :props item-props)))) + (ebox-tree--register-legacy-flex-item-adapter + adapter token source + (and box-props (plist-get source :ebox-content-node)))))) ;;;###autoload (defun ebox-flex (&rest items) diff --git a/ebox-incremental.el b/ebox-incremental.el index ec8b1f2..42b2edf 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -26,7 +26,11 @@ (declare-function ebox--replay-scroll-cache-actions "ebox" (actions &optional portable)) (declare-function ebox-tree-validate-declarative-root - "ebox-tree" (root)) + "ebox-tree" + (root &optional validate-participation-p root-parent-kind)) +(declare-function ebox-tree-validate-indexed-participation + "ebox-tree" + (node-table parent-table node-ids &optional source)) (declare-function ebox-tree-clear-runtime-identities "ebox-tree" (root)) (declare-function ebox-tree-copy-node-structure @@ -5815,9 +5819,32 @@ complete preorder first-owner and postorder last-box semantics." :dirty-set (ebox-incremental--candidate-dirty-set-from-touched old-state candidate-root touched) + :participation-node-ids + (ebox-incremental--candidate-participation-node-ids touched) :touched-count (length touched) :removed-count (length removed)))) +(defun ebox-incremental--candidate-participation-node-ids (touched) + "Return final nodes whose participation context changed in TOUCHED." + (let ((seen (make-hash-table :test 'equal)) result) + (cl-labels ((record + (node) + (when-let* ((node-id (and node (plist-get node :node-id)))) + (unless (gethash node-id seen) + (puthash node-id t seen) + (push node-id result))))) + (dolist (entry touched) + (pcase-let ((`(,old-node ,new-node ,_parent-id ,_children-changed) + entry)) + (record new-node) + ;; Added/replaced children already occur in TOUCHED. Revisit every + ;; direct child only when the parent's layout role itself changed. + (when (or (null old-node) + (not (eq (ebox-tree--child-layout-kind old-node 'parent) + (ebox-tree--child-layout-kind new-node 'parent)))) + (ebox-tree-for-each-direct-child new-node #'record)))) + (nreverse result)))) + (defun ebox-incremental--candidate-local-index-delta (old-state candidate-root &optional path-copy-trace range-index-deltas) "Return local indexes and dirty entries for CANDIDATE-ROOT. @@ -6138,6 +6165,8 @@ replace those O(n) table copies without changing this delta contract." :native-node-postorder native-node-postorder))) (list :index index :dirty-set dirty-set + :participation-node-ids + (ebox-incremental--candidate-participation-node-ids touched) :touched-node-ids (mapcar (lambda (entry) @@ -6538,7 +6567,7 @@ must be recomputed in the next publication." (unless (and (listp next-root) (not (stringp next-root))) (error "Ebox declarative root must be an Ebox node")) ;; Validate before copying so shared references and cycles remain visible. - (ebox-tree-validate-declarative-root next-root) + (ebox-tree-validate-declarative-root next-root t) (let* ((candidate-root (ebox-tree-clear-runtime-identities (ebox-tree-copy-node-structure next-root))) @@ -6572,6 +6601,12 @@ must be recomputed in the next publication." ;; published, and path copying introduces no new sibling or ref ;; relations outside the replaced anchors. Re-walking the whole ;; tree here made every commit pay one full-page validation. + (let ((index (plist-get local-preparation :index))) + (ebox-tree-validate-indexed-participation + (plist-get index :node-table) + (plist-get index :parent-table) + (plist-get local-preparation :participation-node-ids) + 'author)) (setq local-preparation (plist-put local-preparation :detached-identity-history detached-history)) diff --git a/ebox-layout.el b/ebox-layout.el index 920995b..65f12b4 100644 --- a/ebox-layout.el +++ b/ebox-layout.el @@ -1624,6 +1624,9 @@ Internal implementation of `ebox-render' for box nodes." (defvar ebox--surface-materialization-active nil "Non-nil while an Ebox TP producer is running raw layout.") +(defvar ebox--render-root-parent-kind nil + "Formatting context of a detached root rendered for its owning parent.") + (defun ebox--render-layout (node) "Render layout NODE directly to a multi-line propertized string. NODE can be: diff --git a/ebox-surface.el b/ebox-surface.el index 646daa3..8d93ce1 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -33,6 +33,7 @@ (defvar ebox--viewport-dependent-subtree-cache) (defvar ebox--viewport-height-dependent-subtree-cache) (defvar ebox--render-runtime-revision) +(defvar ebox--render-root-parent-kind) (defvar ebox--render-cache-table) (defvar ebox--render-cache-signature-cache) (defvar ebox--layout-fragments-table) @@ -960,7 +961,8 @@ PREVIOUS-STATE's node-object table." (and subjects (plist-get subjects :node-subject-table)) bindings-by-subject states-by-subject style-state-table stylesheet-signature - selector-tree-token stylesheet-subject-local-p)))) + selector-tree-token stylesheet-subject-local-p + ebox--render-root-parent-kind)))) (list :surface-root surface-root :node-root node-root :objects-by-node objects-by-node @@ -1056,7 +1058,10 @@ PREVIOUS-STATE's node-object table." (puthash node object objects-by-node) (puthash node-id object objects-by-id)))) (unless (gethash (plist-get root :node-id) objects-by-id) - (error "Ebox native projection lost its root object"))) + (error "Ebox native projection lost its root object")) + (when subject-table + (ebox-tree-validate-indexed-participation + nodes parents touched-ids 'computed))) objects-by-node)) (defun ebox-surface--complete-projection @@ -1089,7 +1094,8 @@ the projection roots a second time." (and subjects (plist-get subjects :node-subject-table)) bindings-by-subject states-by-subject style-state-table stylesheet-signature - selector-tree-token stylesheet-subject-local-p) + selector-tree-token stylesheet-subject-local-p + ebox--render-root-parent-kind) projection)) (defun ebox-surface--projection-result @@ -1369,7 +1375,8 @@ When PRESERVE-IDENTITIES-P is non-nil, retain existing Ebox node and region identities while always discarding TP handles and render-cache attachments." (unless (and (listp source) (not (stringp source))) (error "Ebox surface source must be an Ebox node")) - (ebox-tree-validate-declarative-root source) + (ebox-tree-validate-declarative-root + source t ebox--render-root-parent-kind) (let ((candidate (ebox-tree-copy-node-structure source))) (if preserve-identities-p (ebox-surface--clear-runtime-attachments candidate) @@ -1697,7 +1704,8 @@ dependencies, or any malformed index conservatively preserves root planning." (defun ebox-surface--ensure-node-tree (context parent node table subject-table bindings-by-subject states-by-subject style-state-table stylesheet-signature - selector-tree-token stylesheet-subject-local-p) + selector-tree-token stylesheet-subject-local-p + &optional parent-kind parent-node grandparent-node) "Ensure styled NODE descendants below PARENT in CONTEXT and fill TABLE." (let ((object (tp-object-ensure context parent @@ -1712,13 +1720,19 @@ dependencies, or any malformed index conservatively preserves root planning." style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p)) - (dolist (child (ebox-tree--children-raw node)) - (ebox-surface--ensure-node-tree - context object child table subject-table bindings-by-subject - states-by-subject style-state-table stylesheet-signature - selector-tree-token stylesheet-subject-local-p)) + (let ((child-kind (ebox-tree--child-layout-kind node parent-kind))) + (ebox-tree-for-each-direct-child + node + (lambda (child) + (ebox-surface--ensure-node-tree + context object child table subject-table bindings-by-subject + states-by-subject style-state-table stylesheet-signature + selector-tree-token stylesheet-subject-local-p + child-kind node parent-node)))) (when subject-table - (ebox-style-sync-flex-item node)) + (ebox-style-sync-flex-item node) + (ebox-tree--validate-node-parent-participation + node parent-kind parent-node grandparent-node 'computed)) object)) (defun ebox-surface--node-object-table (objects-by-node) @@ -1951,6 +1965,9 @@ valid because their declarative layout and viewport did not change." (binding (plist-get style-state :binding))) (ebox-style-apply-computed node (tp-binding-read binding)) (ebox-style-sync-flex-item node))) + (when owner-ids + (ebox-tree-validate-indexed-participation + nodes (plist-get state :parent-table) owner-ids 'computed)) state)) (defun ebox-surface--rendered-role-topology-signature diff --git a/ebox-tree.el b/ebox-tree.el index c9d8f87..275a0b4 100644 --- a/ebox-tree.el +++ b/ebox-tree.el @@ -2,7 +2,7 @@ ;;; Commentary: ;; Owns runtime identity, display accessors, child traversal, parent paths, and -;; flex participation metadata. It does not own layout measurement or buffer +;; parent participation metadata. It does not own layout measurement or buffer ;; editing. ;;; Code: @@ -13,6 +13,7 @@ (require 'ecss-selector) (require 'ebox-style) (require 'ebox-child-range) +(require 'ebox-layout-config) (declare-function ebox-get "ebox" (box property)) (declare-function ebox--ensure-node-id "ebox" (node)) @@ -28,6 +29,11 @@ "Dynamic snapshot-detail-local node -> region ids cache. This is an optimization for snapshot capture, not buffer runtime state.") (defvar ebox--flex-item-prop-keys) +(defvar ebox--flex-item-direct-adapter-token) +(defvar ebox--flex-item-wrapper-adapter-token) +(defvar ebox-tree--legacy-flex-item-adapter-proofs + (make-hash-table :test #'eq :weakness 'key) + "Weak adapter -> construction-shape proofs for legacy Flex items.") (defun ebox-tree-computed-display (node) "Return NODE's canonical CSS-like display pair." @@ -164,6 +170,44 @@ The returned nodes remain owned by their tree. Callers may inspect them but must not modify a published runtime tree through this accessor." (ebox-tree--children-raw node)) +(defun ebox-tree-for-each-direct-child (node function) + "Call FUNCTION for each direct child of NODE without materializing Ranges." + (let ((type (and (listp node) (plist-get node :ebox-type))) + (sequence (and (listp node) + (plist-get node :ebox-child-sequence)))) + (when (memq type '(flex grid)) + (when-let* ((box (plist-get node :box))) + (funcall function box))) + (if sequence + (ebox-child-range--fold + sequence + (lambda (ignored child) + (funcall function child) + ignored) + nil) + (pcase type + ('box + (if (plist-member node :children) + (dolist (child (plist-get node :children)) + (funcall function child)) + (when-let* ((child (plist-get node :ebox-content-node))) + (funcall function child)))) + ((or 'concat 'stack 'flex 'grid) + (dolist (child + (or (plist-get node :children) + (pcase type + ('concat + (delq nil (list (plist-get node :left) + (plist-get node :right)))) + ('stack + (delq nil (list (plist-get node :top) + (plist-get node :bottom))))))) + (funcall function child))) + ('flex-item + (when-let* ((child (plist-get node :node))) + (funcall function child)))))) + node) + (defun ebox-tree-layout-children (node) "Return layout container children for NODE in render order. New row/column containers store flat `:children'. Legacy `ebox-concat' and @@ -360,10 +404,17 @@ original object identity." (setq copy (without (without copy :top) :bottom)))) (plist-put copy :ebox-child-sequence (ebox-child-range--build (nreverse segments)))) - (ebox-tree-copy-with-direct-child-replacements - node - (mapcar (lambda (child) (cons child (copy-node child))) - (ebox-tree--children-raw node)))))))) + (let ((copy + (ebox-tree-copy-with-direct-child-replacements + node + (mapcar (lambda (child) + (cons child (copy-node child))) + (ebox-tree--children-raw node))))) + ;; Only this exact deep-copy traversal has proven every copied + ;; child derives from the authentic source tree. Generic + ;; path-copy replacements must not launder adapter provenance. + (ebox-tree--remint-legacy-flex-item-adapter-proof + node copy))))))) (copy-node root))) (defun ebox-tree--runtime-identity-node-shell @@ -488,7 +539,225 @@ Host references are root-global opaque metadata and are compared with `equal'." (visit root)) root)) -(defun ebox-tree-validate-declarative-root (root) +(defconst ebox-tree--common-participation-keys + '(:order :align-self) + "Participation properties shared by Flex and Grid children.") + +(defconst ebox-tree--flex-participation-keys + '(:flex :flex-grow :flex-shrink :flex-basis) + "Properties accepted only on a direct Flex child Box.") + +(defconst ebox-tree--grid-participation-keys + '(:grid-column :grid-row :grid-column-span :grid-row-span :justify-self) + "Properties accepted only on a direct Grid child Box.") + +(defconst ebox-tree--participation-keys + (append ebox-tree--common-participation-keys + ebox-tree--flex-participation-keys + ebox-tree--grid-participation-keys) + "Complete canonical parent-participation property domain.") + +(defun ebox-tree--style-specifies-participation-p (style key) + "Return non-nil when computed STYLE has a declaration winner for KEY." + (and style + (ebox-style--specified-property-p style (ebox-style-schema-id key)))) + +(defun ebox-tree--declarations-specify-participation-p (node key) + "Return non-nil when NODE author declarations specify participation KEY." + (and (listp node) + (plist-member (ebox-style-node-declarations node) + (ebox-style-schema-id key)))) + +(defun ebox-tree--author-specifies-participation-p (node key) + "Return non-nil when NODE's author source specifies participation KEY." + (if (eq (plist-get node :ebox-type) 'flex-item) + (let* ((adapter-source (plist-get node :node)) + (content (and (listp adapter-source) + (plist-get adapter-source :ebox-content-node))) + (projected-p + (or (and (listp adapter-source) + (plist-get adapter-source :ebox-computed-style)) + (and (listp content) + (plist-get content :ebox-computed-style))))) + (or (ebox-tree--declarations-specify-participation-p + adapter-source key) + (ebox-tree--declarations-specify-participation-p content key) + (and (not projected-p) + (plist-member (plist-get node :props) key)))) + (or (ebox-tree--declarations-specify-participation-p node key) + (and (null (plist-get node :ebox-computed-style)) + (plist-member node key))))) + +(defun ebox-tree--node-participation-keys (node source) + "Return NODE participation keys from AUTHOR or COMPUTED SOURCE." + (let ((flex-item-p (eq (plist-get node :ebox-type) 'flex-item)) + (style (plist-get node :ebox-computed-style)) + keys) + (dolist (key ebox-tree--participation-keys) + (when + (pcase source + ('author + (ebox-tree--author-specifies-participation-p node key)) + ('computed + (if flex-item-p + (let* ((adapter-source (plist-get node :node)) + (adapter-style + (and (listp adapter-source) + (plist-get adapter-source + :ebox-computed-style))) + (content (and (listp adapter-source) + (plist-get adapter-source + :ebox-content-node))) + (content-style + (and (listp content) + (plist-get content :ebox-computed-style)))) + (and (plist-member (plist-get node :props) key) + (or (ebox-tree--style-specifies-participation-p + adapter-style key) + (ebox-tree--style-specifies-participation-p + content-style key)))) + (and (plist-member node key) + (ebox-tree--style-specifies-participation-p style key)))) + (_ (error "Unknown Ebox participation source: %S" source))) + (push key keys))) + (nreverse keys))) + +(defun ebox-tree--child-layout-kind (node parent-kind) + "Return layout kind governing NODE children, preserving transparent adapters." + (pcase (plist-get node :ebox-type) + ('flex-item parent-kind) + ('flex 'flex) + ('grid 'grid) + (_ (pcase (if (and (eq (plist-get node :ebox-kind) 'box) + (plist-get node :ebox-layout-config)) + (ebox-layout-config-kind + (plist-get node :ebox-layout-config)) + (ebox-tree-display-inner node)) + ('flow 'normal) + (kind kind))))) + +(defun ebox-tree--legacy-flex-item-adapter-p (node token) + "Return non-nil when NODE has the exact legacy Flex adapter TOKEN shape." + (let ((proof (gethash node ebox-tree--legacy-flex-item-adapter-proofs)) + (source (plist-get node :node))) + (and token + (eq (plist-get node :ebox-type) 'flex-item) + (vectorp proof) + (= (length proof) 3) + (eq (aref proof 0) token) + (eq (aref proof 1) source) + (listp source) + (not (stringp source)) + (let ((children (ebox-tree--children-raw node))) + (and (eq (car children) source) + (null (cdr children)))) + (if (eq token ebox--flex-item-wrapper-adapter-token) + (let ((content (plist-get source :ebox-content-node))) + (and (eq (plist-get source :ebox-type) 'box) + (listp content) + (not (stringp content)) + (eq (aref proof 2) content))) + (null (aref proof 2)))))) + +(defun ebox-tree--register-legacy-flex-item-adapter + (adapter token source content) + "Register ADAPTER's exact TOKEN, SOURCE, and optional CONTENT identities." + (puthash adapter (vector token source content) + ebox-tree--legacy-flex-item-adapter-proofs) + adapter) + +(defun ebox-tree--remint-legacy-flex-item-adapter-proof (old new) + "Bind NEW adapter provenance to copied children when OLD is authentic." + (cond + ((ebox-tree--legacy-flex-item-adapter-p + old ebox--flex-item-direct-adapter-token) + (ebox-tree--register-legacy-flex-item-adapter + new ebox--flex-item-direct-adapter-token (plist-get new :node) nil)) + ((ebox-tree--legacy-flex-item-adapter-p + old ebox--flex-item-wrapper-adapter-token) + (let ((wrapper (plist-get new :node))) + (ebox-tree--register-legacy-flex-item-adapter + new ebox--flex-item-wrapper-adapter-token + wrapper (plist-get wrapper :ebox-content-node)))) + (t + (remhash new ebox-tree--legacy-flex-item-adapter-proofs) + new))) + +(defun ebox-tree--legacy-flex-item-duplicate-p (node parent grandparent) + "Return non-nil when NODE is an exact duplicate inside a legacy adapter." + (or + (and (ebox-tree--legacy-flex-item-adapter-p + parent ebox--flex-item-direct-adapter-token) + (eq node (plist-get parent :node))) + (and (ebox-tree--legacy-flex-item-adapter-p + parent ebox--flex-item-wrapper-adapter-token) + (let ((wrapper (plist-get parent :node))) + (and (eq node wrapper) + (eq (plist-get wrapper :ebox-type) 'box) + (listp (plist-get wrapper :ebox-content-node))))) + (and (ebox-tree--legacy-flex-item-adapter-p + grandparent ebox--flex-item-wrapper-adapter-token) + (let ((wrapper (plist-get grandparent :node))) + (and (eq parent wrapper) + (eq (plist-get wrapper :ebox-type) 'box) + (eq node (plist-get wrapper :ebox-content-node))))))) + +(defun ebox-tree--validate-node-parent-participation + (node parent-kind parent grandparent source) + "Validate NODE participation under PARENT-KIND from AUTHOR or COMPUTED SOURCE." + (unless (ebox-tree--legacy-flex-item-duplicate-p node parent grandparent) + (let ((keys (ebox-tree--node-participation-keys node source))) + (when keys + (when (eq (plist-get node :ebox-kind) 'text) + (error "Ebox Text cannot carry participation properties: %S" keys)) + (pcase parent-kind + ('flex + (when (cl-intersection + keys ebox-tree--grid-participation-keys :test #'eq) + (error "Grid participation requires a direct Grid parent: %S" + keys))) + ('grid + (when (cl-intersection + keys ebox-tree--flex-participation-keys :test #'eq) + (error "Flex participation requires a direct Flex parent: %S" + keys))) + (_ (error "Ebox participation requires a Flex or Grid parent: %S" + keys)))))) + node) + +(defun ebox-tree--indexed-parent-context + (node-id node-table parent-table) + "Return (PARENT-KIND PARENT GRANDPARENT) for NODE-ID in indexed tree." + (let* ((parent-id (gethash node-id parent-table)) + (parent (and parent-id (gethash parent-id node-table))) + (grandparent-id (and parent-id (gethash parent-id parent-table))) + (grandparent (and grandparent-id + (gethash grandparent-id node-table))) + (layout-owner parent)) + (while (and layout-owner + (eq (plist-get layout-owner :ebox-type) 'flex-item)) + (let ((owner-id (plist-get layout-owner :node-id))) + (setq owner-id (and owner-id (gethash owner-id parent-table)) + layout-owner (and owner-id (gethash owner-id node-table))))) + (list (and layout-owner + (ebox-tree--child-layout-kind layout-owner nil)) + parent grandparent))) + +(defun ebox-tree-validate-indexed-participation + (node-table parent-table node-ids &optional source) + "Validate NODE-IDS in indexed final tree using AUTHOR or COMPUTED SOURCE." + (setq source (or source 'author)) + (dolist (node-id (delete-dups (copy-sequence node-ids))) + (when-let* ((node (gethash node-id node-table))) + (pcase-let ((`(,parent-kind ,parent ,grandparent) + (ebox-tree--indexed-parent-context + node-id node-table parent-table))) + (ebox-tree--validate-node-parent-participation + node parent-kind parent grandparent source)))) + node-table) + +(defun ebox-tree-validate-declarative-root + (root &optional validate-participation-p root-parent-kind) "Validate ROOT for one declarative runtime commit. The runtime tree must be a proper tree: a node object cannot appear in two @@ -502,7 +771,7 @@ and all explicit identities are compared with `equal'. Return ROOT on success." (active (make-hash-table :test 'eq)) (range-refs (make-hash-table :test 'equal))) (cl-labels - ((visit (node) + ((visit (node parent-kind parent grandparent) (when (and (listp node) (not (stringp node))) (when (gethash node active) (error "Ebox declarative tree contains a cycle")) @@ -510,6 +779,9 @@ and all explicit identities are compared with `equal'. Return ROOT on success." (error "Ebox declarative tree reuses one node object")) (puthash node t seen) (puthash node t active) + (when validate-participation-p + (ebox-tree--validate-node-parent-participation + node parent-kind parent grandparent 'author)) (let* ((keys (make-hash-table :test 'equal)) (type (plist-get node :ebox-type)) (raw (and (memq type '(concat stack flex grid)) @@ -547,9 +819,11 @@ and all explicit identities are compared with `equal'. Return ROOT on success." (error "Ebox declarative siblings use duplicate key %S" key)) (puthash key t keys)) - (visit child))) + (visit child + (ebox-tree--child-layout-kind node parent-kind) + node parent))) (remhash node active)))) - (visit root)) + (visit root root-parent-kind nil nil)) root)) (defun ebox-tree-clear-runtime-identities (root) diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index 466fc40..f857895 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -1260,6 +1260,91 @@ remain retained identities." (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-candidate-rejects-invalid-final-parent-participation () + "A detached replacement must be revalidated after its final graft." + (let* ((buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-participation-rollback*") + (ebox-column + (ebox-create :key 'target :host-ref 'target + :content "Stable" :width '(80))))) + (surface (with-current-buffer buffer ebox-surface--buffer-surface)) + (revision (tp-surface-revision surface)) + (before (ebox-commit-test--buffer-string buffer)) + (candidate (ebox-candidate-begin buffer))) + (unwind-protect + (progn + ;; Detached subtrees do not know their parent yet, so recording the + ;; replacement is legal. The final Column graft is authoritative. + (ebox-candidate-replace-host-ref + candidate 'target + (ebox-create :key 'target :host-ref 'target + :content "Invalid" :width '(80) :flex-grow 1)) + (should-error (ebox-commit buffer candidate) :type 'error) + (should (= (tp-surface-revision surface) revision)) + (should (equal (ebox-commit-test--buffer-string buffer) before))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-candidate-cannot-launder-legacy-adapter-provenance () + "A public path copy must not authenticate a replacement as adapter output." + (let* ((adapter + (ebox-flex-item + (ebox-create :host-ref 'target :content "Stable" :width '(80)))) + (buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-adapter-proof-rollback*") + (ebox-column adapter))) + (surface (with-current-buffer buffer ebox-surface--buffer-surface)) + (revision (tp-surface-revision surface)) + (before (ebox-commit-test--buffer-string buffer)) + (candidate (ebox-candidate-begin buffer)) + (replacement + (ebox-create :host-ref 'target :content "Invalid" :width '(80)))) + (plist-put replacement :flex-grow 1) + (unwind-protect + (progn + (ebox-candidate-replace-host-ref candidate 'target replacement) + (should-error (ebox-commit buffer candidate) :type 'error) + (should (= (tp-surface-revision surface) revision)) + (should (equal (ebox-commit-test--buffer-string buffer) before))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-candidate-participation-validation-stays-changed-local () + "One replacement must not participation-validate every sibling." + (let* ((children + (cl-loop for index below 200 + collect + (if (= index 99) + (ebox-create :key index :host-ref 'target + :content (number-to-string index)) + (ebox-create :key index + :content (number-to-string index))))) + (buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-participation-local*") + (apply #'ebox-column children))) + (candidate (ebox-candidate-begin buffer)) + (original + (symbol-function 'ebox-tree-validate-indexed-participation)) + author-node-counts) + (unwind-protect + (progn + (ebox-candidate-replace-host-ref + candidate 'target + (ebox-create :key 99 :host-ref 'target :content "changed")) + (cl-letf + (((symbol-function 'ebox-tree-validate-indexed-participation) + (lambda (node-table parent-table node-ids &optional source) + (when (eq source 'author) + (push (length node-ids) author-node-counts)) + (funcall original node-table parent-table node-ids source)))) + (ebox-commit buffer candidate)) + (should (equal author-node-counts '(2)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-candidate-root-replacement-reuses-candidate-validity-contract () "Root replacement rejects invalid, other-buffer, stale, and sealed use." (let ((first (generate-new-buffer " *ebox-root-valid-first*")) diff --git a/tests/ebox-dsl-tests.el b/tests/ebox-dsl-tests.el index a6d595a..bbb6cd2 100644 --- a/tests/ebox-dsl-tests.el +++ b/tests/ebox-dsl-tests.el @@ -421,6 +421,106 @@ (should (= (ebox-dsl-test--tree-count box (lambda (_node) t)) 5)) (should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 5)))) +(ert-deftest ebox-canonical-participation-validates-the-final-parent-context () + "Participation should be legal only under its matching direct parent layout." + (cl-labels + ((child (&rest props) + (apply #'ebox-box-create + :layout (ebox-normal-layout-create) + :children (list (ebox-text-create :value "A")) + props)) + (parent (layout child) + (ebox-box-create :layout layout :children (list child)))) + (should (stringp + (ebox-render + (parent (ebox-flex-layout-create) (child :flex-grow 1))))) + (should (stringp + (ebox-render + (parent (ebox-grid-layout-create) (child :grid-row 1))))) + (should-error + (ebox-render (child :flex-grow 1)) :type 'error) + (should-error + (ebox-render + (parent (ebox-normal-layout-create) (child :flex-grow 1))) + :type 'error) + (should-error + (ebox-render + (parent (ebox-flex-layout-create) (child :grid-row 1))) + :type 'error) + (should-error + (ebox-render + (parent (ebox-grid-layout-create) (child :flex-grow 1))) + :type 'error) + (let ((text (ebox-text-create :value "A"))) + (plist-put text :flex-grow 1) + (should-error + (ebox-render (parent (ebox-flex-layout-create) text)) :type 'error)) + ;; The final candidate is authoritative after ECSS projection too. + (let ((ebox-style-stylesheet (ecss-stylesheet-create))) + (ebox-style-add-rule "box" (list :flex-grow 1)) + (should-error + (ebox-render + (parent (ebox-normal-layout-create) (child))) + :type 'error)) + ;; Detached Flex geometry retains its final parent during computed style. + (let ((ebox-style-stylesheet (ecss-stylesheet-create)) + (detached (child))) + (plist-put detached :class "detached-flex-item") + (ebox-style-add-rule ".detached-flex-item" (list :flex-grow 1)) + (let ((ebox--render-root-parent-kind 'flex)) + (should (stringp (ebox-render detached)))) + ;; The dynamic context is exact and cannot authorize a later root. + (should-error (ebox-render detached) :type 'error)) + (let ((ebox-style-stylesheet (ecss-stylesheet-create)) + (detached (child))) + (plist-put detached :class "detached-flex-item") + (ebox-style-add-rule ".detached-flex-item" (list :grid-row 1)) + (let ((ebox--render-root-parent-kind 'flex)) + (should-error (ebox-render detached) :type 'error))) + ;; A real legacy adapter exempts only its two exact duplicate nodes. + (let* ((illegal-grandchild (child :flex-grow 1)) + (nested (parent (ebox-normal-layout-create) illegal-grandchild)) + (adapter (ebox-flex-item nested :flex-grow 1 :padding 1))) + ;; The obsolete mutable depth field must have no authority. + (plist-put adapter :ebox-flex-item-adapter-depth 99) + (should-error + (ebox-render (ebox-flex :width '(100) adapter)) + :type 'error)) + ;; Provenance binds the exact constructor-time source identities. + (let* ((adapter (ebox-flex-item (ebox-create :content "original"))) + (replacement (ebox-create :content "replacement"))) + (plist-put replacement :flex-grow 1) + (plist-put adapter :node replacement) + (should-error + (ebox-render (ebox-column adapter)) :type 'error)) + (let* ((adapter (ebox-flex-item (ebox-create :content "original"))) + (source (plist-get adapter :node)) + (replacement (ebox-create :content "replacement"))) + (plist-put replacement :flex-grow 1) + (setq adapter + (ebox-tree-copy-with-direct-child-replacements + adapter (list (cons source replacement)))) + (should-error + (ebox-render (ebox-column adapter)) :type 'error)) + (let* ((adapter + (ebox-flex-item (ebox-create :content "original") :padding 1)) + (wrapper (plist-get adapter :node)) + (replacement (ebox-create :content "replacement"))) + (plist-put replacement :flex-grow 1) + (plist-put wrapper :ebox-content-node replacement) + (should-error + (ebox-render (ebox-column adapter)) :type 'error)) + ;; Internal layout and parent participation are orthogonal facts. + (should + (stringp + (ebox-render + (parent + (ebox-grid-layout-create) + (ebox-box-create + :layout (ebox-flex-layout-create) + :children (list (ebox-text-create :value "A")) + :grid-row 1))))))) + (ert-deftest ebox-canonical-grid-revalidates-the-typed-config-boundary () "GridConfig must reject frame properties and cross-kind property sets." (let ((extra-frame-prop (ebox-grid-layout-create))