ebox/ebox-dsl.el

260 lines
9.4 KiB
EmacsLisp

;;; ebox-dsl.el --- Canonical author DSL for Ebox -*- lexical-binding: t; -*-
;;; Commentary:
;; Owns the complete inert `.ebox' author boundary: form parsing, form-local
;; property validation, and desugaring into canonical TextNode/BoxNode plus
;; typed LayoutConfig values. It does not own layout, paint materialization,
;; buffer publication, or framework runtime identity.
;;; Code:
(require 'cl-lib)
(require 'ebox-style)
(require 'ebox-canonical)
(require 'ebox-flex)
(require 'ebox-grid)
(cl-defstruct
(ebox-dsl--properties
(:constructor ebox-dsl--make-properties))
"Normalized properties for one canonical Text or Box author source."
tag
source-metadata
outer
layout
declarations)
(defconst ebox-dsl--box-tags
'(box row column flex grid)
"Author tags that construct one canonical BoxNode.")
(defconst ebox-dsl--source-fields
'(:key :class :id)
"Shared source metadata accepted by Ebox author surfaces.")
(defun ebox-dsl--source-handle ()
"Return one opaque identity for a normalized author node."
(make-symbol "ebox-source"))
(defun ebox-dsl--validate-plist (plist context)
"Validate PLIST shape for CONTEXT and return PLIST."
(unless (and (proper-list-p plist) (zerop (% (length plist) 2)))
(error "%s properties must be an even plist: %S" context plist))
(cl-loop for key in plist by #'cddr
unless (keywordp key)
do (error "%s property name must be a keyword: %S" context key))
plist)
(defun ebox-dsl--field-values (plist field)
"Return every FIELD value from PLIST in source order."
(cl-loop for (key value) on plist by #'cddr
when (eq key field)
collect value))
(defun ebox-dsl--optional-field (plist field context)
"Return optional FIELD from PLIST, rejecting duplicates for CONTEXT."
(let ((values (ebox-dsl--field-values plist field)))
(when (> (length values) 1)
(error "%s accepts %S at most once" context field))
(and values (cons t (car values)))))
(defun ebox-dsl--without-keys (plist keys)
"Return PLIST without entries whose keys occur in KEYS."
(cl-loop for (key value) on plist by #'cddr
unless (memq key keys)
append (list key value)))
(defun ebox-dsl--keep-keys (plist keys)
"Return PLIST entries whose keys occur in KEYS."
(cl-loop for (key value) on plist by #'cddr
when (memq key keys)
append (list key value)))
(defun ebox-dsl--source-metadata
(properties source-handle context)
"Return source metadata from PROPERTIES and SOURCE-HANDLE for CONTEXT."
(let ((metadata (list :source-handle source-handle)))
(dolist (field ebox-dsl--source-fields)
(when-let* ((entry (ebox-dsl--optional-field
properties field context)))
(setq metadata
(append metadata (list field (cdr entry))))))
metadata))
(defun ebox-dsl--compile-style (tag properties context)
"Compile and validate TAG author PROPERTIES for CONTEXT."
(condition-case err
(if properties
(ebox-style-compile-form tag properties)
nil)
(error
(error "%s rejected properties: %s"
context (error-message-string err)))))
(defun ebox-dsl--layout-config
(tag declarations extensions context)
"Construct TAG LayoutConfig from DECLARATIONS and EXTENSIONS for CONTEXT."
(if (eq tag 'text)
nil
(condition-case err
(ebox-layout-config-for-form
tag
(if (memq tag '(row column))
extensions
(let ((names (ebox-layout-config-property-names tag)))
(ebox-style-declaration-properties
declarations
(lambda (property)
(memq (plist-get property :name) names))))))
(error
(error "%s rejected layout config: %s"
context (error-message-string err))))))
(defun ebox-dsl--normalize-properties (tag properties source-handle)
"Project evaluated TAG PROPERTIES for opaque SOURCE-HANDLE.
TAG is `text', `box', `row', `column', `flex', or `grid'. The returned
`ebox-dsl--properties' contains typed constructor inputs, source metadata,
and canonical declarations. Child forms and text payloads are outside this
function."
(unless (memq tag (cons 'text ebox-dsl--box-tags))
(error "Ebox author properties has unknown author tag: %S" tag))
(let* ((context (format "Ebox %S" tag))
(properties
(ebox-dsl--validate-plist properties context)))
(dolist (field '(:content :layout :display :ebox-type :ebox-content-node))
(when (plist-member properties field)
(error "%s does not accept private author property %S"
context field)))
(let* ((outer-field
(and (not (eq tag 'text))
(ebox-dsl--optional-field
properties :outer context)))
(outer (and (not (eq tag 'text))
(if outer-field (cdr outer-field) 'block)))
(extension-keys (and (memq tag '(row column))
'(:item-gap :cross-align)))
(extensions
(ebox-dsl--keep-keys
properties extension-keys))
(style-properties
(ebox-dsl--without-keys
properties
(append (unless (eq tag 'text) '(:outer))
extension-keys
ebox-dsl--source-fields)))
(declarations
(ebox-dsl--compile-style
tag style-properties context)))
(ebox-dsl--make-properties
:tag tag
:source-metadata
(ebox-dsl--source-metadata
properties source-handle context)
:outer outer
:layout
(ebox-dsl--layout-config
tag declarations extensions context)
:declarations declarations))))
(defconst ebox-dsl--tags '(text box row column flex grid)
"The complete Ebox author tag set.")
(defun ebox-dsl--unquote-value (value)
"Unquote one static DSL property VALUE when it is quoted data."
(if (and (consp value)
(eq (car value) 'quote)
(null (cddr value)))
(cadr value)
value))
(defun ebox-dsl--unquote-properties (items)
"Unquote static property values in top-level DSL ITEMS."
(let (result)
(while items
(let ((item (pop items)))
(if (keywordp item)
(progn
(unless items
(error "Ebox build: missing value for %S" item))
(push item result)
(push (ebox-dsl--unquote-value (pop items)) result))
(push item result))))
(nreverse result)))
(defun ebox-dsl--split (items)
"Return `(PROPERTIES . CHILDREN)' parsed from author ITEMS."
(let (properties children)
(while items
(let ((item (pop items)))
(if (keywordp item)
(progn
(unless items
(error "Ebox build: missing value for %S" item))
(setq properties
(append properties (list item (pop items)))))
(setq children (append children (list item))))))
(cons properties children)))
(defun ebox-dsl--build-text (_source items)
"Build one canonical TextNode from author ITEMS."
(let* ((split (ebox-dsl--split items))
(properties (car split))
(payloads (cdr split)))
(unless (= (length payloads) 1)
(error "Ebox text requires exactly one string payload"))
(unless (stringp (car payloads))
(error "Ebox text payload must be a string: %S" (car payloads)))
(let* ((plan (ebox-dsl--normalize-properties
'text properties (ebox-dsl--source-handle)))
(node
(apply #'ebox-text-create
(append (list :value (car payloads))
(ebox-dsl--properties-source-metadata plan)
(list :declarations
(ebox-dsl--properties-declarations plan))))))
node)))
(defun ebox-dsl--build-box (tag _source items)
"Build canonical BoxNode TAG from author ITEMS."
(let* ((split (ebox-dsl--split items))
(properties (car split))
(child-forms (cdr split))
(plan (ebox-dsl--normalize-properties
tag properties (ebox-dsl--source-handle)))
(children (mapcar #'ebox-dsl-build child-forms))
(node
(apply #'ebox-box-create
(append
(list :layout (ebox-dsl--properties-layout plan)
:outer (ebox-dsl--properties-outer plan)
:children children
:declarations
(ebox-dsl--properties-declarations plan))
(ebox-dsl--properties-source-metadata plan)))))
node))
(defun ebox-dsl-build (dsl)
"Build one canonical Ebox node from inert author DSL.
The complete grammar is String plus `text', `box', `row', `column', `flex',
and `grid'. Strings normalize to Text. The five Box forms select a typed
LayoutConfig; no form accepts `:content', `:layout', or a raw runtime node."
(cond
((stringp dsl)
(ebox-dsl--build-text dsl (list dsl)))
((not (and (consp dsl) (symbolp (car dsl))))
(error "Ebox build: invalid author node %S" dsl))
((not (memq (car dsl) ebox-dsl--tags))
(error "Ebox build: unknown author tag %S" (car dsl)))
((eq (car dsl) 'text)
(ebox-dsl--build-text
dsl (ebox-dsl--unquote-properties (cdr dsl))))
(t
(ebox-dsl--build-box
(car dsl) dsl (ebox-dsl--unquote-properties (cdr dsl))))))
(provide 'ebox-dsl)
;;; ebox-dsl.el ends here