Skip to main content

browser_commander/elements/
selectors.rs

1//! CSS selector utilities.
2//!
3//! This module provides utilities for working with CSS selectors,
4//! including parsing, normalization, and text-based selector support.
5
6use regex::Regex;
7use std::sync::LazyLock;
8
9/// Pattern for detecting text-based selectors like `:text("Submit")`.
10static TEXT_SELECTOR_PATTERN: LazyLock<Regex> =
11    LazyLock::new(|| Regex::new(r#"^:text\(["'](.+?)["']\)$"#).expect("Invalid regex pattern"));
12
13/// Pattern for detecting nth-of-type selectors.
14static NTH_OF_TYPE_PATTERN: LazyLock<Regex> =
15    LazyLock::new(|| Regex::new(r#"(.+?):nth-of-type\((\d+)\)"#).expect("Invalid regex pattern"));
16
17/// Represents a parsed selector.
18#[derive(Debug, Clone, PartialEq)]
19pub enum ParsedSelector {
20    /// A standard CSS selector.
21    Css(String),
22    /// A text-based selector.
23    Text {
24        text: String,
25        element: Option<String>,
26    },
27    /// An XPath selector.
28    XPath(String),
29}
30
31/// Check if a selector is a text-based selector.
32///
33/// Text selectors have the format `:text("text content")`.
34///
35/// # Arguments
36///
37/// * `selector` - The selector to check
38///
39/// # Returns
40///
41/// `true` if the selector is a text-based selector
42pub fn is_text_selector(selector: &str) -> bool {
43    TEXT_SELECTOR_PATTERN.is_match(selector)
44}
45
46/// Extract the text from a text selector.
47///
48/// # Arguments
49///
50/// * `selector` - A text selector like `:text("Submit")`
51///
52/// # Returns
53///
54/// The text content if this is a text selector, `None` otherwise
55pub fn extract_text_from_selector(selector: &str) -> Option<String> {
56    TEXT_SELECTOR_PATTERN
57        .captures(selector)
58        .map(|caps| caps[1].to_string())
59}
60
61/// Normalize a selector for consistent handling.
62///
63/// This function handles various selector formats and converts them
64/// to a standardized form.
65///
66/// # Arguments
67///
68/// * `selector` - The selector to normalize
69///
70/// # Returns
71///
72/// The parsed selector
73pub fn normalize_selector(selector: &str) -> ParsedSelector {
74    let trimmed = selector.trim();
75
76    // Check for text selector
77    if let Some(text) = extract_text_from_selector(trimmed) {
78        return ParsedSelector::Text {
79            text,
80            element: None,
81        };
82    }
83
84    // Check for XPath
85    if trimmed.starts_with("//") || trimmed.starts_with("(//") {
86        return ParsedSelector::XPath(trimmed.to_string());
87    }
88
89    // Standard CSS selector
90    ParsedSelector::Css(trimmed.to_string())
91}
92
93/// Build a CSS selector to find elements by visible text.
94///
95/// This creates a selector that matches elements containing the specified text.
96/// Note: This is a best-effort approach and may not work for all cases.
97///
98/// # Arguments
99///
100/// * `text` - The text to search for
101/// * `element_type` - Optional element type to restrict the search (e.g., "button")
102///
103/// # Returns
104///
105/// A CSS selector string (or XPath for more complex cases)
106pub fn build_text_selector(text: &str, element_type: Option<&str>) -> String {
107    // For simple cases, use XPath as it has better text support
108    match element_type {
109        Some(el) => format!("//{}[contains(text(), '{}')]", el, text),
110        None => format!("//*[contains(text(), '{}')]", text),
111    }
112}
113
114/// Check if a selector contains an nth-of-type modifier.
115///
116/// # Arguments
117///
118/// * `selector` - The selector to check
119///
120/// # Returns
121///
122/// `true` if the selector contains `:nth-of-type()`
123pub fn has_nth_of_type(selector: &str) -> bool {
124    NTH_OF_TYPE_PATTERN.is_match(selector)
125}
126
127/// Parse an nth-of-type selector into its base selector and index.
128///
129/// # Arguments
130///
131/// * `selector` - A selector like `button:nth-of-type(2)`
132///
133/// # Returns
134///
135/// A tuple of (base_selector, index) if this is an nth-of-type selector
136pub fn parse_nth_of_type(selector: &str) -> Option<(String, usize)> {
137    NTH_OF_TYPE_PATTERN.captures(selector).and_then(|caps| {
138        let base = caps[1].to_string();
139        let index = caps[2].parse::<usize>().ok()?;
140        Some((base, index))
141    })
142}
143
144/// Escape special characters in a CSS selector value.
145///
146/// # Arguments
147///
148/// * `value` - The value to escape
149///
150/// # Returns
151///
152/// The escaped value
153pub fn escape_selector_value(value: &str) -> String {
154    value
155        .replace('\\', "\\\\")
156        .replace('"', "\\\"")
157        .replace('\'', "\\'")
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn is_text_selector_true_for_text_selectors() {
166        assert!(is_text_selector(":text(\"Submit\")"));
167        assert!(is_text_selector(":text('Submit')"));
168        assert!(is_text_selector(":text(\"Click me\")"));
169    }
170
171    #[test]
172    fn is_text_selector_false_for_css_selectors() {
173        assert!(!is_text_selector("button"));
174        assert!(!is_text_selector(".class"));
175        assert!(!is_text_selector("#id"));
176        assert!(!is_text_selector("[data-test]"));
177    }
178
179    #[test]
180    fn extract_text_from_selector_extracts_text() {
181        assert_eq!(
182            extract_text_from_selector(":text(\"Submit\")"),
183            Some("Submit".to_string())
184        );
185        assert_eq!(
186            extract_text_from_selector(":text('Click me')"),
187            Some("Click me".to_string())
188        );
189    }
190
191    #[test]
192    fn extract_text_from_selector_returns_none_for_css() {
193        assert_eq!(extract_text_from_selector("button"), None);
194        assert_eq!(extract_text_from_selector(".class"), None);
195    }
196
197    #[test]
198    fn normalize_selector_handles_css() {
199        assert_eq!(
200            normalize_selector("button"),
201            ParsedSelector::Css("button".to_string())
202        );
203        assert_eq!(
204            normalize_selector("  .class  "),
205            ParsedSelector::Css(".class".to_string())
206        );
207    }
208
209    #[test]
210    fn normalize_selector_handles_text() {
211        assert_eq!(
212            normalize_selector(":text(\"Submit\")"),
213            ParsedSelector::Text {
214                text: "Submit".to_string(),
215                element: None
216            }
217        );
218    }
219
220    #[test]
221    fn normalize_selector_handles_xpath() {
222        assert_eq!(
223            normalize_selector("//button"),
224            ParsedSelector::XPath("//button".to_string())
225        );
226        assert_eq!(
227            normalize_selector("(//div)[1]"),
228            ParsedSelector::XPath("(//div)[1]".to_string())
229        );
230    }
231
232    #[test]
233    fn build_text_selector_without_element() {
234        let selector = build_text_selector("Submit", None);
235        assert!(selector.contains("Submit"));
236        assert!(selector.contains("contains(text()"));
237    }
238
239    #[test]
240    fn build_text_selector_with_element() {
241        let selector = build_text_selector("Submit", Some("button"));
242        assert!(selector.contains("button"));
243        assert!(selector.contains("Submit"));
244    }
245
246    #[test]
247    fn has_nth_of_type_detects_pattern() {
248        assert!(has_nth_of_type("button:nth-of-type(1)"));
249        assert!(has_nth_of_type("div.class:nth-of-type(2)"));
250        assert!(!has_nth_of_type("button"));
251        assert!(!has_nth_of_type("button:first-child"));
252    }
253
254    #[test]
255    fn parse_nth_of_type_extracts_parts() {
256        assert_eq!(
257            parse_nth_of_type("button:nth-of-type(1)"),
258            Some(("button".to_string(), 1))
259        );
260        assert_eq!(
261            parse_nth_of_type("div.class:nth-of-type(3)"),
262            Some(("div.class".to_string(), 3))
263        );
264        assert_eq!(parse_nth_of_type("button"), None);
265    }
266
267    #[test]
268    fn escape_selector_value_escapes_special_chars() {
269        assert_eq!(escape_selector_value("test"), "test");
270        assert_eq!(escape_selector_value("test\"value"), "test\\\"value");
271        assert_eq!(escape_selector_value("test'value"), "test\\'value");
272        assert_eq!(escape_selector_value("test\\value"), "test\\\\value");
273    }
274}