Skip to main content

a11y_kit/
tree.rs

1//! The tree and the audit — the CI gate nobody in the ecosystem has
2//! (AccessKit ships no cross-screen-reader harness; every toolkit's
3//! a11y CI is hand-rolled per project).
4
5use crate::error::AuditError;
6use crate::node::{is_interactive, A11yNode, NodeId};
7
8/// What an audit measured.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct TreeAudit {
11    /// Nodes in the tree.
12    pub nodes: usize,
13    /// Roots (parentless nodes).
14    pub roots: usize,
15    /// Interactive roles checked for names.
16    pub interactive: usize,
17    /// Relation edges validated.
18    pub relations: usize,
19}
20
21/// An insertion-ordered tree of semantic nodes.
22#[derive(Debug, Clone, Default)]
23pub struct A11yTree {
24    nodes: Vec<A11yNode>,
25}
26
27impl A11yTree {
28    /// An empty tree.
29    #[must_use]
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Insert a node; ids must be unique and ascending is conventional but
35    /// not required. Returns the node's id for chaining.
36    pub fn insert(&mut self, node: A11yNode) -> NodeId {
37        let id = node.id;
38        self.nodes.push(node);
39        id
40    }
41
42    /// Insert with the next free id.
43    pub fn push(&mut self, mut node: A11yNode) -> NodeId {
44        node.id = NodeId(self.nodes.len() as u32);
45        self.insert(node)
46    }
47
48    /// Look up a node.
49    #[must_use]
50    pub fn get(&self, id: NodeId) -> Option<&A11yNode> {
51        self.nodes.iter().find(|n| n.id == id)
52    }
53
54    /// All nodes, in insertion order.
55    pub fn nodes(&self) -> &[A11yNode] {
56        &self.nodes
57    }
58
59    /// Roots (nodes without a parent).
60    #[must_use]
61    pub fn roots(&self) -> Vec<NodeId> {
62        self.nodes
63            .iter()
64            .filter(|n| n.parent.is_none())
65            .map(|n| n.id)
66            .collect()
67    }
68
69    /// The focused node, if exactly one claims focus.
70    #[must_use]
71    pub fn focused(&self) -> Vec<NodeId> {
72        self.nodes
73            .iter()
74            .filter(|n| n.states.focused)
75            .map(|n| n.id)
76            .collect()
77    }
78
79    /// Audit the tree. Every rule is a CI gate:
80    ///
81    /// 1. unique ids;
82    /// 2. parents and relation targets exist;
83    /// 3. no structural cycles;
84    /// 4. interactive roles carry an accessible name;
85    /// 5. at most one focused node, and it is reachable from a root.
86    pub fn audit(&self) -> Result<TreeAudit, AuditError> {
87        // (1) unique ids
88        for (i, node) in self.nodes.iter().enumerate() {
89            if self.nodes.iter().skip(i + 1).any(|n| n.id == node.id) {
90                return Err(AuditError::DuplicateId(node.id));
91            }
92        }
93
94        // (2) dangling references
95        for node in &self.nodes {
96            if let Some(parent) = node.parent {
97                if self.get(parent).is_none() {
98                    return Err(AuditError::DanglingReference {
99                        node: node.id,
100                        target: parent,
101                    });
102                }
103            }
104            for relation in &node.relations {
105                let target = match relation {
106                    crate::node::Relation::Controls(id)
107                    | crate::node::Relation::DescribedBy(id)
108                    | crate::node::Relation::LabelledBy(id)
109                    | crate::node::Relation::ActiveDescendant(id) => *id,
110                };
111                if self.get(target).is_none() {
112                    return Err(AuditError::DanglingReference {
113                        node: node.id,
114                        target,
115                    });
116                }
117            }
118        }
119
120        // (3) structural cycles: walk each node up to a root
121        for node in &self.nodes {
122            let mut seen = vec![node.id];
123            let mut current = node.parent;
124            while let Some(id) = current {
125                if seen.contains(&id) {
126                    return Err(AuditError::Cycle { node: node.id });
127                }
128                seen.push(id);
129                current = self.get(id).and_then(|n| n.parent);
130            }
131        }
132
133        // (4) interactive roles must be named
134        let mut interactive = 0usize;
135        for node in &self.nodes {
136            if is_interactive(node.role) {
137                interactive += 1;
138                if node.name.trim().is_empty() {
139                    return Err(AuditError::MissingAccessibleName(node.id));
140                }
141            }
142        }
143
144        // (5) focus discipline: at most one focused node, and it must live
145        // inside a Window subtree — a focusable node floating as its own
146        // root is the classic "the screen reader can't see what has focus"
147        // bug.
148        let focused = self.focused();
149        if focused.len() > 1 {
150            return Err(AuditError::MultipleFocus(focused));
151        }
152        if let Some(id) = focused.first() {
153            let in_window = self
154                .nodes
155                .iter()
156                .filter(|n| n.role == crate::node::A11yRole::Window)
157                .flat_map(|w| self.descendants_of(w.id))
158                .any(|n| n == *id);
159            if !in_window {
160                return Err(AuditError::UnreachableFocus(*id));
161            }
162        }
163
164        let relations = self.nodes.iter().map(|n| n.relations.len()).sum();
165        Ok(TreeAudit {
166            nodes: self.nodes.len(),
167            roots: self.roots().len(),
168            interactive,
169            relations,
170        })
171    }
172
173    /// Every node reachable by walking parents down from the roots.
174    #[must_use]
175    pub fn reachable_from_roots(&self) -> Vec<NodeId> {
176        let mut out = Vec::new();
177        let mut stack = self.roots();
178        while let Some(id) = stack.pop() {
179            out.push(id);
180            for child in self.children_of(id) {
181                stack.push(child);
182            }
183        }
184        out
185    }
186
187    /// Direct children of `id`.
188    #[must_use]
189    pub fn children_of(&self, id: NodeId) -> Vec<NodeId> {
190        self.nodes
191            .iter()
192            .filter(|n| n.parent == Some(id))
193            .map(|n| n.id)
194            .collect()
195    }
196
197    /// `id` and every node beneath it (depth-first, parents first).
198    #[must_use]
199    pub fn descendants_of(&self, id: NodeId) -> Vec<NodeId> {
200        let mut out = vec![id];
201        let mut stack = vec![id];
202        while let Some(current) = stack.pop() {
203            for child in self.children_of(current) {
204                out.push(child);
205                stack.push(child);
206            }
207        }
208        out
209    }
210}