Created
April 30, 2026 07:04
-
-
Save jaytaph/bb0aa4aadfa0ef534fb8b102de735117 to your computer and use it in GitHub Desktop.
proof-of-concept.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //! proof-of-concept.rs | |
| //! | |
| //! Three design ideas explored together in a single compilable file: | |
| //! | |
| //! 1. Accessor-based Document trait — the engine holds NodeId values and | |
| //! asks the document questions; no &Node | |
| //! ever escapes the interface boundary. | |
| //! Because Document has no associated | |
| //! types, the *Ext shortcut traits | |
| //! (HasDocumentExt, HasCssSystemExt, …) | |
| //! are no longer needed at all. | |
| //! | |
| //! 2. Has* trait decomposition — code that only parses uses | |
| //! `C: HasDocument`, code that only draws | |
| //! uses `C: HasRenderBackend`; neither is | |
| //! forced to know about the other. | |
| //! | |
| //! 3. config! macro — user writes six choices; all impl | |
| //! blocks are generated. | |
| //! | |
| //! Two Document implementations show that swapping storage is one line: | |
| //! | |
| //! ArenaDocument — nodes as structs in a Vec (AOS) | |
| //! ColumnDocument — data in parallel Vecs (SOA, cache-friendly reads) | |
| //! | |
| //! Run with: | |
| //! rustc --edition 2021 proof-of-concept.rs && ./proof-of-concept | |
| #![allow(dead_code, unused_variables)] | |
| use std::collections::HashMap; | |
| // ── Shared primitives ───────────────────────────────────────────────────────── | |
| pub type NodeId = usize; | |
| const ROOT_ID: NodeId = 0; | |
| #[derive(Debug, Clone, PartialEq)] | |
| pub enum NodeType { | |
| Document, | |
| Element, | |
| Text, | |
| Comment, | |
| } | |
| // ── Swappable component traits ──────────────────────────────────────────────── | |
| /// The only document interface the rest of the engine ever sees. | |
| /// Nodes are never materialized outside the document — everything is NodeId. | |
| /// Note: no generic parameter C here; no associated Node type. | |
| /// That's what makes *Ext traits unnecessary. | |
| pub trait Document: 'static { | |
| fn new() -> Self | |
| where | |
| Self: Sized; | |
| fn root(&self) -> NodeId; | |
| fn node_type(&self, id: NodeId) -> NodeType; | |
| fn tag_name(&self, id: NodeId) -> Option<&str>; | |
| fn text_value(&self, id: NodeId) -> Option<&str>; | |
| fn attribute(&self, id: NodeId, name: &str) -> Option<&str>; | |
| fn children(&self, id: NodeId) -> &[NodeId]; | |
| fn parent(&self, id: NodeId) -> Option<NodeId>; | |
| // Parser calls these — never constructs a Node struct | |
| fn create_element(&mut self, tag: &str) -> NodeId; | |
| fn create_text(&mut self, value: &str) -> NodeId; | |
| fn attach(&mut self, child: NodeId, parent: NodeId, position: Option<usize>); | |
| } | |
| pub trait CssSystem: 'static { | |
| fn parse_str(css: &str) -> Vec<String>; // stub: returns selector strings | |
| } | |
| pub trait HtmlParser<C: HasDocument>: 'static { | |
| fn parse(html: &str) -> C::Document; | |
| } | |
| pub trait Layouter: 'static {} | |
| pub trait RenderBackend: 'static {} | |
| pub trait ChromeHandle: 'static {} | |
| // ── Has* traits (scoped bounds) ─────────────────────────────────────────────── | |
| // | |
| // Generic functions only declare the bounds they actually need: | |
| // | |
| // fn walk<C: HasDocument>(doc: &C::Document, id: NodeId) // no renderer in sight | |
| // fn draw<C: HasRenderBackend>(rb: &C::RenderBackend) // no parser in sight | |
| pub trait HasDocument: Sized { | |
| type Document: Document; | |
| } | |
| pub trait HasCssSystem { | |
| type CssSystem: CssSystem; | |
| } | |
| pub trait HasHtmlParser: HasDocument { | |
| type HtmlParser: HtmlParser<Self>; | |
| } | |
| pub trait HasLayouter: HasDocument + HasCssSystem { | |
| type Layouter: Layouter; | |
| } | |
| pub trait HasRenderBackend { | |
| type RenderBackend: RenderBackend; | |
| } | |
| pub trait HasChrome { | |
| type ChromeHandle: ChromeHandle; | |
| } | |
| // ── ModuleConfig ────────────────────────────────────────────────────────────── | |
| pub trait ModuleConfig: | |
| Sized | |
| + HasDocument | |
| + HasCssSystem | |
| + HasHtmlParser | |
| + HasLayouter | |
| + HasRenderBackend | |
| + HasChrome | |
| { | |
| } | |
| // ── config! macro ───────────────────────────────────────────────────────────── | |
| // | |
| // User writes six choices; all impl blocks are generated. | |
| // This is the entire public API for defining a configuration. | |
| macro_rules! config { | |
| ( | |
| $name:ident, | |
| Document = $doc:ty, | |
| HtmlParser = $parser:ty, | |
| CssSystem = $css:ty, | |
| Layouter = $layout:ty, | |
| RenderBackend = $render:ty, | |
| ChromeHandle = $chrome:ty $(,)? | |
| ) => { | |
| #[derive(Clone, Debug, PartialEq)] | |
| pub struct $name; | |
| impl HasDocument for $name { | |
| type Document = $doc; | |
| } | |
| impl HasCssSystem for $name { | |
| type CssSystem = $css; | |
| } | |
| impl HasHtmlParser for $name { | |
| type HtmlParser = $parser; | |
| } | |
| impl HasLayouter for $name { | |
| type Layouter = $layout; | |
| } | |
| impl HasRenderBackend for $name { | |
| type RenderBackend = $render; | |
| } | |
| impl HasChrome for $name { | |
| type ChromeHandle = $chrome; | |
| } | |
| impl ModuleConfig for $name {} | |
| }; | |
| } | |
| // ── Document implementation 1: Arena (AOS) ─────────────────────────────────── | |
| // Each node is a struct; all nodes live in a Vec indexed by NodeId. | |
| struct ArenaNode { | |
| node_type: NodeType, | |
| tag_name: Option<String>, | |
| text_value: Option<String>, | |
| attributes: HashMap<String, String>, | |
| children: Vec<NodeId>, | |
| parent: Option<NodeId>, | |
| } | |
| pub struct ArenaDocument { | |
| nodes: Vec<ArenaNode>, | |
| } | |
| impl ArenaDocument { | |
| fn alloc(&mut self, node: ArenaNode) -> NodeId { | |
| let id = self.nodes.len(); | |
| self.nodes.push(node); | |
| id | |
| } | |
| } | |
| impl Document for ArenaDocument { | |
| fn new() -> Self { | |
| Self { | |
| nodes: vec![ArenaNode { | |
| node_type: NodeType::Document, | |
| tag_name: None, | |
| text_value: None, | |
| attributes: HashMap::new(), | |
| children: Vec::new(), | |
| parent: None, | |
| }], | |
| } | |
| } | |
| fn root(&self) -> NodeId { ROOT_ID } | |
| fn node_type(&self, id: NodeId) -> NodeType { self.nodes[id].node_type.clone() } | |
| fn tag_name(&self, id: NodeId) -> Option<&str> { self.nodes[id].tag_name.as_deref() } | |
| fn text_value(&self, id: NodeId) -> Option<&str> { self.nodes[id].text_value.as_deref() } | |
| fn attribute(&self, id: NodeId, name: &str) -> Option<&str> { | |
| self.nodes[id].attributes.get(name).map(String::as_str) | |
| } | |
| fn children(&self, id: NodeId) -> &[NodeId] { &self.nodes[id].children } | |
| fn parent(&self, id: NodeId) -> Option<NodeId> { self.nodes[id].parent } | |
| fn create_element(&mut self, tag: &str) -> NodeId { | |
| self.alloc(ArenaNode { | |
| node_type: NodeType::Element, | |
| tag_name: Some(tag.to_string()), | |
| text_value: None, | |
| attributes: HashMap::new(), | |
| children: Vec::new(), | |
| parent: None, | |
| }) | |
| } | |
| fn create_text(&mut self, value: &str) -> NodeId { | |
| self.alloc(ArenaNode { | |
| node_type: NodeType::Text, | |
| tag_name: None, | |
| text_value: Some(value.to_string()), | |
| attributes: HashMap::new(), | |
| children: Vec::new(), | |
| parent: None, | |
| }) | |
| } | |
| fn attach(&mut self, child: NodeId, parent: NodeId, position: Option<usize>) { | |
| self.nodes[child].parent = Some(parent); | |
| match position { | |
| Some(pos) => self.nodes[parent].children.insert(pos, child), | |
| None => self.nodes[parent].children.push(child), | |
| } | |
| } | |
| } | |
| // ── Document implementation 2: Column (SOA) ────────────────────────────────── | |
| // Same interface, completely different internal layout. | |
| // Each field lives in its own Vec; reading one field for many nodes is a single | |
| // contiguous scan with no pointer chasing. | |
| pub struct ColumnDocument { | |
| node_types: Vec<NodeType>, | |
| tag_names: Vec<Option<String>>, | |
| text_values: Vec<Option<String>>, | |
| attributes: Vec<HashMap<String, String>>, | |
| children: Vec<Vec<NodeId>>, | |
| parents: Vec<Option<NodeId>>, | |
| } | |
| impl ColumnDocument { | |
| fn alloc(&mut self, node_type: NodeType) -> NodeId { | |
| let id = self.node_types.len(); | |
| self.node_types.push(node_type); | |
| self.tag_names.push(None); | |
| self.text_values.push(None); | |
| self.attributes.push(HashMap::new()); | |
| self.children.push(Vec::new()); | |
| self.parents.push(None); | |
| id | |
| } | |
| } | |
| impl Document for ColumnDocument { | |
| fn new() -> Self { | |
| Self { | |
| node_types: vec![NodeType::Document], | |
| tag_names: vec![None], | |
| text_values: vec![None], | |
| attributes: vec![HashMap::new()], | |
| children: vec![Vec::new()], | |
| parents: vec![None], | |
| } | |
| } | |
| fn root(&self) -> NodeId { ROOT_ID } | |
| fn node_type(&self, id: NodeId) -> NodeType { self.node_types[id].clone() } | |
| fn tag_name(&self, id: NodeId) -> Option<&str> { self.tag_names[id].as_deref() } | |
| fn text_value(&self, id: NodeId) -> Option<&str> { self.text_values[id].as_deref() } | |
| fn attribute(&self, id: NodeId, name: &str) -> Option<&str> { | |
| self.attributes[id].get(name).map(String::as_str) | |
| } | |
| fn children(&self, id: NodeId) -> &[NodeId] { &self.children[id] } | |
| fn parent(&self, id: NodeId) -> Option<NodeId> { self.parents[id] } | |
| fn create_element(&mut self, tag: &str) -> NodeId { | |
| let id = self.alloc(NodeType::Element); | |
| self.tag_names[id] = Some(tag.to_string()); | |
| id | |
| } | |
| fn create_text(&mut self, value: &str) -> NodeId { | |
| let id = self.alloc(NodeType::Text); | |
| self.text_values[id] = Some(value.to_string()); | |
| id | |
| } | |
| fn attach(&mut self, child: NodeId, parent: NodeId, position: Option<usize>) { | |
| self.parents[child] = Some(parent); | |
| match position { | |
| Some(pos) => self.children[parent].insert(pos, child), | |
| None => self.children[parent].push(child), | |
| } | |
| } | |
| } | |
| // ── Stub implementations of the other four components ──────────────────────── | |
| pub struct SimpleCss; | |
| impl CssSystem for SimpleCss { | |
| fn parse_str(_css: &str) -> Vec<String> { vec![] } | |
| } | |
| // The parser only needs HasDocument — it has no idea a render backend exists. | |
| pub struct SimpleParser; | |
| impl<C: HasDocument> HtmlParser<C> for SimpleParser { | |
| fn parse(_html: &str) -> C::Document { | |
| let mut doc = C::Document::new(); | |
| let html = doc.create_element("html"); | |
| doc.attach(html, ROOT_ID, None); | |
| let body = doc.create_element("body"); | |
| doc.attach(body, html, None); | |
| let text = doc.create_text("hello from the parser"); | |
| doc.attach(text, body, None); | |
| doc | |
| } | |
| } | |
| pub struct SimpleLayouter; | |
| impl Layouter for SimpleLayouter {} | |
| pub struct SimpleBackend; | |
| impl RenderBackend for SimpleBackend {} | |
| pub struct NullChrome; | |
| impl ChromeHandle for NullChrome {} | |
| // ── Two configs — one line apart ────────────────────────────────────────────── | |
| config!( | |
| ArenaConfig, | |
| Document = ArenaDocument, | |
| HtmlParser = SimpleParser, | |
| CssSystem = SimpleCss, | |
| Layouter = SimpleLayouter, | |
| RenderBackend = SimpleBackend, | |
| ChromeHandle = NullChrome, | |
| ); | |
| config!( | |
| ColumnConfig, | |
| Document = ColumnDocument, // ← the only difference | |
| HtmlParser = SimpleParser, | |
| CssSystem = SimpleCss, | |
| Layouter = SimpleLayouter, | |
| RenderBackend = SimpleBackend, | |
| ChromeHandle = NullChrome, | |
| ); | |
| // ── Generic tree walker — only needs HasDocument ────────────────────────────── | |
| // This function has no idea whether it's talking to ArenaDocument or | |
| // ColumnDocument; both satisfy the same Document contract. | |
| fn walk<C: HasDocument>(doc: &C::Document, id: NodeId, depth: usize) { | |
| let pad = " ".repeat(depth); | |
| match doc.node_type(id) { | |
| NodeType::Element => { | |
| let tag = doc.tag_name(id).unwrap_or("?"); | |
| println!("{}<{}>", pad, tag); | |
| for &child in doc.children(id) { | |
| walk::<C>(doc, child, depth + 1); | |
| } | |
| println!("</{}>", tag); | |
| } | |
| NodeType::Text => { | |
| println!("{}\"{}\"", pad, doc.text_value(id).unwrap_or("")); | |
| } | |
| NodeType::Document => { | |
| for &child in doc.children(id) { | |
| walk::<C>(doc, child, depth); | |
| } | |
| } | |
| _ => {} | |
| } | |
| } | |
| // ── Demo ────────────────────────────────────────────────────────────────────── | |
| fn main() { | |
| println!("=== ArenaDocument (AOS) ==="); | |
| let doc = <SimpleParser as HtmlParser<ArenaConfig>>::parse("<ignored>"); | |
| walk::<ArenaConfig>(&doc, ROOT_ID, 0); | |
| println!("\n=== ColumnDocument (SOA) ==="); | |
| let doc = <SimpleParser as HtmlParser<ColumnConfig>>::parse("<ignored>"); | |
| walk::<ColumnConfig>(&doc, ROOT_ID, 0); | |
| println!("\nBoth produced by the same SimpleParser, walked by the same walk()."); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment