Skip to main content

logicaffeine_language/lgx/
selector.rs

1//! The CSS selector subset LGX understands — parsed ONCE, here.
2//!
3//! Two very different things ask "what does this selector name": the runtime engine that
4//! answers `the count of ".row"` against a live page, and the static check that refuses
5//! `When "#draft" is clicked:` when no view renders a `#draft`. If those two disagreed, a page
6//! would pass its checks and then fail to find its own elements. They cannot disagree, because
7//! they read the same parse.
8//!
9//! The subset is what a page test and a page handler actually need: a descendant chain of
10//! compounds, each an optional tag plus any number of `#id`, `.class`, and `[attr]` /
11//! `[attr=value]` qualifiers. No sibling combinators, no pseudo-classes — those belong to
12//! stylesheets, and a stylesheet says them in `Style:` where real CSS is read verbatim.
13
14/// One compound selector: everything that must hold of a SINGLE element (`li#a.done[x=1]`).
15#[derive(Debug, Default, Clone, PartialEq, Eq)]
16pub struct Compound {
17    pub tag: Option<String>,
18    pub id: Option<String>,
19    pub classes: Vec<String>,
20    /// `[name]` (presence) or `[name=value]` (equality); quotes around the value optional.
21    pub attrs: Vec<(String, Option<String>)>,
22}
23
24/// A descendant chain: `.card li.done` is "a `li.done` anywhere inside a `.card`".
25pub type Selector = Vec<Compound>;
26
27/// The CSS syntax this engine deliberately does not read, and what to write instead.
28///
29/// ★ A CATALOGUE, NOT A LIST OF CHECKS. Each entry carries its own sentence, so adding a form the
30/// engine cannot evaluate means writing the explanation the author will read — there is nowhere to
31/// add one silently.
32///
33/// ★ AND EVERY ONE OF THESE USED TO PARSE AS A NAME. `compound()` takes the leading run up to `#`,
34/// `.` or `[` as the TAG, so `>` became a tag named `>` and `li:first-child` a tag named
35/// `li:first-child`. Both matched nothing — the same answer a correct selector gives for a page
36/// that genuinely has none — so `Expect the count of "ul > li" to be 0.` passed, and would have
37/// passed with five hundred items on the page.
38pub const UNSUPPORTED_SYNTAX: &[(char, &str)] = &[
39    ('>', "uses the child combinator `>`, which this engine does not read. It names elements by tag, `#id`, `.class`, `[attr]` and DESCENDANT nesting, so write `ul li` for a `li` anywhere inside a `ul`. Full CSS, combinators included, belongs in `Style:`, where it is read verbatim"),
40    ('+', "uses the adjacent-sibling combinator `+`, which this engine does not read. It names elements by tag, `#id`, `.class`, `[attr]` and descendant nesting; give the element you mean a class and name that. Full CSS belongs in `Style:`, where it is read verbatim"),
41    ('~', "uses the general-sibling combinator `~`, which this engine does not read. It names elements by tag, `#id`, `.class`, `[attr]` and descendant nesting; give the element you mean a class and name that. Full CSS belongs in `Style:`, where it is read verbatim"),
42    (',', "is TWO selectors joined by a comma, and this engine answers about one. Ask each question on its own, or give both elements a shared class and name that"),
43    (':', "uses a pseudo-class, which this engine does not read. A pseudo-class is about how a page is DRAWN, so it belongs in `Style:`, where real CSS is read verbatim; to pick one element out of several here, give it an id or a class"),
44    ('*', "uses the universal selector `*`, which this engine does not read. Name the tag, id or class you actually mean"),
45];
46
47/// Why `selector` is outside the subset, or `None` when it is inside it.
48///
49/// Scanned OUTSIDE `[...]`, because an attribute VALUE may legitimately hold any of these —
50/// `[href="https://example.com"]` carries a colon and a slash and is perfectly ordinary.
51pub fn unsupported(selector: &str) -> Option<String> {
52    let mut in_brackets = false;
53    for c in selector.chars() {
54        match c {
55            '[' => in_brackets = true,
56            ']' => in_brackets = false,
57            _ if in_brackets => {}
58            _ => {
59                if let Some((_, why)) = UNSUPPORTED_SYNTAX.iter().find(|(k, _)| *k == c) {
60                    return Some(format!("`{selector}` {why}"));
61                }
62            }
63        }
64    }
65    None
66}
67
68/// Parse a selector, or `None` when it is empty, malformed, or outside the subset.
69pub fn parse(selector: &str) -> Option<Selector> {
70    if unsupported(selector).is_some() {
71        return None;
72    }
73    let mut parts = Vec::new();
74    for token in selector.split_whitespace() {
75        parts.push(compound(token)?);
76    }
77    (!parts.is_empty()).then_some(parts)
78}
79
80/// Parse one compound.
81pub fn compound(token: &str) -> Option<Compound> {
82    let mut c = Compound::default();
83    let chars: Vec<char> = token.chars().collect();
84    let mut i = 0;
85    // A leading bare word is the tag name.
86    while i < chars.len() && !matches!(chars[i], '#' | '.' | '[') {
87        i += 1;
88    }
89    if i > 0 {
90        c.tag = Some(chars[..i].iter().collect());
91    }
92    while i < chars.len() {
93        match chars[i] {
94            '#' | '.' => {
95                let kind = chars[i];
96                i += 1;
97                let start = i;
98                while i < chars.len() && !matches!(chars[i], '#' | '.' | '[') {
99                    i += 1;
100                }
101                if i == start {
102                    return None;
103                }
104                let name: String = chars[start..i].iter().collect();
105                match kind {
106                    '#' => c.id = Some(name),
107                    _ => c.classes.push(name),
108                }
109            }
110            '[' => {
111                i += 1;
112                let start = i;
113                while i < chars.len() && chars[i] != ']' {
114                    i += 1;
115                }
116                if i >= chars.len() {
117                    return None;
118                }
119                let inner: String = chars[start..i].iter().collect();
120                i += 1;
121                match inner.split_once('=') {
122                    Some((name, value)) => {
123                        let value = value.trim().trim_matches(|q| q == '"' || q == '\'');
124                        c.attrs
125                            .push((name.trim().to_string(), Some(value.to_string())));
126                    }
127                    None => c.attrs.push((inner.trim().to_string(), None)),
128                }
129            }
130            _ => return None,
131        }
132    }
133    (c.tag.is_some() || c.id.is_some() || !c.classes.is_empty() || !c.attrs.is_empty()).then_some(c)
134}
135
136#[cfg(test)]
137#[path = "../../../logicaffeine_tests/unit/logicaffeine_language/lgx/selector/tests.rs"]
138mod tests;