etaf/etaf-view.el
Kinneyzhang 0185c4e05a feat: establish unified etaf view foundation
Implement the P0 View grammar, expr bridge, stateless view Components, and Ebox mount path in a new independent package. Include bilingual architecture and implementation documents plus contract tests.
2026-08-05 00:36:52 +08:00

350 lines
13 KiB
EmacsLisp

;;; etaf-view.el --- Unified ETAF View grammar -*- lexical-binding: t; -*-
;; SPDX-License-Identifier: GPL-3.0-or-later
;;; Commentary:
;; This file owns the small structural language shared by Hosts and
;; Components. It deliberately does not render, mutate buffers, or implement
;; lifecycle. `etaf-view' compiles one structural form into a short-lived
;; View value; the renderer lowers that value to Ebox later.
;;; Code:
(require 'cl-lib)
(define-error 'etaf-view-error "Invalid ETAF View")
(define-error 'etaf-view-syntax-error "Invalid ETAF View syntax"
'etaf-view-error)
(define-error 'etaf-component-call-error "Invalid ETAF Component call"
'etaf-view-error)
(cl-defstruct (etaf--view-node
(:constructor etaf--view-node-create))
"Internal normalized description of one Host View."
name
props
children)
(cl-defstruct (etaf--expr
(:constructor etaf--expr-create))
"Internal executable child expression."
thunk)
(cl-defstruct (etaf--component-spec
(:constructor etaf--component-spec-create))
"Internal definition of one stateless Component."
name
props
render)
(cl-defstruct (etaf--component-call
(:constructor etaf--component-call-create))
"Internal Component invocation retained until rendering."
spec
props
children)
(defconst etaf--host-names
'(text fragment container row column stack flex spacer)
"Minimal unstyled Hosts implemented by ETAF core.
Product Components such as Button belong to `etaf-ui'; they are not added to
this list merely to make a demo convenient.")
(defconst etaf--host-marker 'etaf--host
"Value stored in the View registry for a built-in Host.")
(defconst etaf--ordinary-elisp-heads
'(and or not if when unless cond case pcase
let let* letrec letrec* prog prog1 prog2 progn
while dolist dotimes cl-loop mapcar mapc mapcan
save-excursion save-restriction save-window-excursion
condition-case condition-case-unless-debug unwind-protect
catch throw signal error user-error quote function lambda
setq setq-default setf psetf psetq incf decf push pop
funcall apply apply-partially progn eval macroexpand)
"Elisp heads that must appear inside `expr', not as View children.")
(defvar etaf--view-registry (make-hash-table :test #'eq)
"Registry of Hosts and Components used by structural View calls.")
(defun etaf--syntax-error (format-string &rest arguments)
"Signal a View syntax error formatted from FORMAT-STRING and ARGUMENTS."
(signal 'etaf-view-syntax-error
(list (apply #'format format-string arguments))))
(defun etaf--component-error (format-string &rest arguments)
"Signal a Component call error formatted from FORMAT-STRING and ARGUMENTS."
(signal 'etaf-component-call-error
(list (apply #'format format-string arguments))))
(defun etaf--keyword-for-name (name)
"Return the property keyword corresponding to symbol NAME."
(intern (concat ":" (symbol-name name))))
(defun etaf--component-alias (name)
"Return the public View alias for canonical Component NAME, or nil.
Canonical names may carry the `etaf-' package prefix. The prefix is omitted
in View syntax unless doing so would collide with an Elisp function, special
form, or core Host. A collision receives a semantic `-view' alias."
(when (and (symbolp name)
(string-prefix-p "etaf-" (symbol-name name)))
(let* ((suffix (substring (symbol-name name) (length "etaf-")))
(candidate (intern suffix)))
(cond
((or (memq candidate etaf--host-names)
(special-form-p candidate)
(fboundp candidate))
(intern (concat suffix "-view")))
(t candidate)))))
(defun etaf--register-component (name spec)
"Register Component SPEC under canonical NAME and its public alias."
(unless (and (symbolp name) (etaf--component-spec-p spec))
(signal 'wrong-type-argument (list 'etaf--component-spec-p spec)))
(let ((existing (gethash name etaf--view-registry)))
(when (eq existing etaf--host-marker)
(etaf--component-error
"Component %S conflicts with a core Host" name)))
(puthash name spec etaf--view-registry)
(when-let ((alias (etaf--component-alias name)))
(let ((existing (gethash alias etaf--view-registry)))
(when (and existing (not (eq existing spec)))
(etaf--component-error
"Component alias %S is already registered" alias)))
(puthash alias spec etaf--view-registry))
spec)
(defun etaf--register-core-hosts ()
"Register the core Host names and explicit prefixed spellings."
(dolist (name etaf--host-names)
(puthash name etaf--host-marker etaf--view-registry)
(puthash (intern (concat "etaf-" (symbol-name name)))
etaf--host-marker
etaf--view-registry)))
(defun etaf--canonical-host-name (name)
"Return the unprefixed renderer name for Host NAME."
(if (and (symbolp name)
(string-prefix-p "etaf-" (symbol-name name)))
(let ((short-name (intern (substring (symbol-name name) 5))))
(if (memq short-name etaf--host-names)
short-name
name))
name))
(etaf--register-core-hosts)
(defun etaf--validate-property-plist (props)
"Validate evaluated View PROPS and return a defensive copy."
(unless (and (proper-list-p props) (zerop (% (length props) 2)))
(etaf--component-error "View properties must be keyword/value pairs: %S"
props))
(let ((copy nil)
(seen nil)
(tail props))
(while tail
(let ((key (pop tail))
(value (pop tail)))
(unless (keywordp key)
(etaf--component-error "View property name must be a keyword: %S"
key))
(when (memq key seen)
(etaf--component-error "Duplicate View property: %S" key))
(push key seen)
(setq copy (append copy (list key value)))))
copy))
(defun etaf--validate-key (key)
"Validate a Host identity KEY and return it."
(unless (or (null key) (symbolp key) (stringp key)
(integerp key) (floatp key))
(etaf--component-error
"View keys must be immutable scalar values: %S" key))
key)
(defun etaf--parse-attributes-and-children (items)
"Split structural ITEMS into `(PROPS . CHILDREN)'.
All keyword attributes must precede the first non-keyword child. Values are
returned as unevaluated forms because they are ordinary Elisp expressions in
the generated code."
(let (props children seen children-started)
(while items
(let ((item (pop items)))
(if (keywordp item)
(progn
(when children-started
(etaf--syntax-error
"Attributes must precede children; found %S after a child"
item))
(unless items
(etaf--syntax-error "Missing value for View property %S"
item))
(when (memq item seen)
(etaf--syntax-error "Duplicate View property %S" item))
(push item seen)
(let ((value (pop items)))
(push item props)
(push value props)))
(setq children-started t)
(push item children))))
(cons (nreverse props) (nreverse children))))
(defun etaf--parse-expr-form (items)
"Return the value form from an `expr' child with ITEMS.
`expr' intentionally has one property, `:value', and no children."
(let ((parts (etaf--parse-attributes-and-children items)))
(when (cdr parts)
(etaf--syntax-error "expr accepts :value and no children"))
(let ((props (car parts)))
(unless (and (= (length props) 2)
(eq (car props) :value))
(etaf--syntax-error
"expr accepts exactly one attribute: :value"))
(cadr props))))
(defun etaf--ordinary-expression-head-p (head)
"Return non-nil when HEAD denotes ordinary Elisp computation."
(or (memq head etaf--ordinary-elisp-heads)
(special-form-p head)
(and (symbolp head) (fboundp head))))
(defun etaf--compile-expr-form (items)
"Compile an `expr' form with ITEMS into an executable View value."
`(etaf--expr-create
:thunk (lambda () ,(etaf--parse-expr-form items))))
(defun etaf--compile-child-form (form)
"Compile structural child FORM into code returning a View value."
(cond
((null form) nil)
((stringp form) `(quote ,form))
((and (consp form) (eq (car form) 'expr))
(etaf--compile-expr-form (cdr form)))
((and (consp form) (symbolp (car form)))
(when (etaf--ordinary-expression-head-p (car form))
(etaf--syntax-error
"Elisp expression %S must be inside (expr :value ...)" (car form)))
(etaf--compile-view-form form))
((consp form)
(etaf--syntax-error "Invalid View child form: %S" form))
(t
(etaf--syntax-error
"View children must be strings, nil, View forms, or expr results: %S"
form))))
(defun etaf--compile-view-form (form)
"Compile one structural View FORM into runtime construction code."
(cond
((null form) nil)
((stringp form) `(quote ,form))
((not (and (consp form) (symbolp (car form))))
(etaf--syntax-error "View form must start with a symbol: %S" form))
((eq (car form) 'expr)
(etaf--compile-expr-form (cdr form)))
((eq (car form) 'slot)
(etaf--syntax-error
"slot is not implemented in the P0 package; use ordinary children for now"))
((eq (car form) 'raw-ebox)
(etaf--syntax-error
"raw-ebox is not implemented in the P0 package"))
(t
(let* ((parts (etaf--parse-attributes-and-children (cdr form)))
(props (car parts))
(children (cdr parts)))
`(etaf--view-call ',(car form)
(list ,@props)
(list ,@(mapcar #'etaf--compile-child-form children)))))))
;;;###autoload
(defmacro etaf-view (form)
"Construct a normalized ETAF View from structural FORM.
FORM uses one grammar for Hosts and Component calls:
(NAME :PROPERTY VALUE ... CHILD ...)
Properties must come first and children must come last. Property values are
ordinary Elisp expressions. `expr' is the only computation bridge in the
child region and accepts only `:value'."
(declare (indent 1) (debug (form)))
(etaf--compile-view-form form))
(defun etaf--component-prop-key (name)
"Return the keyword used to pass Component prop NAME."
(if (keywordp name)
name
(etaf--keyword-for-name name)))
(defun etaf--validate-component-props (spec props)
"Validate Component SPEC against evaluated property PLIST PROPS."
(let ((allowed (mapcar #'etaf--component-prop-key
(etaf--component-spec-props spec)))
(tail (etaf--validate-property-plist props)))
(while tail
(let ((key (pop tail)))
(pop tail)
(unless (memq key allowed)
(etaf--component-error
"Unknown prop %S for Component %S"
key (etaf--component-spec-name spec)))))
props))
(defun etaf--view-call (name props children)
"Construct a Host or Component named NAME from PROPS and CHILDREN."
(unless (symbolp name)
(etaf--syntax-error "View name must be a symbol: %S" name))
(setq props (etaf--validate-property-plist props))
(let ((entry (gethash name etaf--view-registry)))
(cond
((eq entry etaf--host-marker)
(when (plist-member props :key)
(etaf--validate-key (plist-get props :key)))
(etaf--view-node-create
:name (etaf--canonical-host-name name)
:props props
:children children))
((etaf--component-spec-p entry)
(etaf--validate-component-props entry props)
(etaf--component-call-create
:spec entry
:props props
:children children))
(t
(etaf--component-error "Unknown ETAF Host or Component: %S" name)))))
(defun etaf--resolve-value (value)
"Resolve VALUE to a flat list of string or View leaves.
This is deliberately a value normalizer, not an evaluator. Only `expr'
thunks are executed; a quoted list returned by an expression is treated as a
sequence and each member must already be a valid View value."
(cond
((null value) nil)
((or (stringp value)
(etaf--view-node-p value))
(list value))
((etaf--expr-p value)
(etaf--resolve-value (funcall (etaf--expr-thunk value))))
((etaf--component-call-p value)
(etaf--resolve-value
(funcall (etaf--component-spec-render
(etaf--component-call-spec value))
(etaf--component-call-props value)
(etaf--component-call-children value))))
((proper-list-p value)
(cl-mapcan #'etaf--resolve-value value))
(t
(signal 'etaf-view-error
(list (format
"View values must be strings, nil, Views, or sequences: %S"
value))))))
(provide 'etaf-view)
;;; etaf-view.el ends here