logicaffeine_language/lgx/
selector.rs1#[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 pub attrs: Vec<(String, Option<String>)>,
22}
23
24pub type Selector = Vec<Compound>;
26
27pub 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
47pub 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
68pub 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
80pub 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 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;