Skip to main content

css_variable_lsp/parsers/
js.rs

1use ls_types::Uri;
2
3use super::css::{parse_css_snippet, CssParseContext};
4use crate::manager::CssVariableManager;
5
6/// A CSS snippet extracted from a JS/TS source file (e.g. styled-components).
7pub(crate) struct JsCssSnippet {
8    /// Byte offset where the CSS content starts in the full document.
9    pub content_start: usize,
10    /// The CSS content with template expressions blanked out (spaces).
11    pub content: String,
12}
13
14/// Parse a JS/TS document and extract CSS from tagged template literals and string literals.
15pub async fn parse_js_document(
16    text: &str,
17    uri: &Uri,
18    manager: &CssVariableManager,
19) -> Result<(), String> {
20    let snippets = extract_js_css_snippets(text);
21    let mut parse_errors = 0;
22    for snippet in snippets {
23        let context = CssParseContext {
24            css_text: &snippet.content,
25            full_text: text,
26            uri,
27            manager,
28            base_offset: snippet.content_start,
29            inline: false,
30            usage_context_override: Some("js-template"),
31            dom_node: None,
32        };
33        if let Err(e) = parse_css_snippet(context).await {
34            tracing::debug!("JS parse error at offset {}: {}", snippet.content_start, e);
35            parse_errors += 1;
36        }
37    }
38    if parse_errors > 0 {
39        tracing::warn!(
40            "Encountered {} parse errors in JS document {:?}",
41            parse_errors,
42            uri
43        );
44    }
45    Ok(())
46}
47
48/// Heuristic: does this string contain CSS-like content?
49/// Avoids false positives like "user:pass", "https://", etc.
50fn has_css_like_content(s: &str) -> bool {
51    // Must have colon with proper context (not URL protocol) OR contain CSS patterns
52    // CSS properties have colons with property names (letter sequence before colon)
53    // vs URLs have protocol prefix (://)
54
55    // Contains var() or --custom-property syntax (definite CSS)
56    if s.contains("var(") || s.contains("--") {
57        return true;
58    }
59
60    // Contains colon - need to check it's not a protocol or credential
61    if let Some(pos) = s.find(':') {
62        // Check what follows the colon
63        let after = &s[pos + 1..].trim_start();
64        // URL protocol pattern: "://" or just "//" at start
65        if s.starts_with("http") || s.starts_with("//") {
66            return false;
67        }
68        // Check it's not a credential pattern (word:word without space after colon)
69        // CSS property: "prop: value" has space after colon
70        // Credential: "user:pass" no space
71        if !after.starts_with(' ') && !after.starts_with(';') && !after.is_empty() {
72            // No space after colon - could be credential, check for common URL patterns
73            if s.contains("://") || s.starts_with('/') {
74                return false;
75            }
76        }
77    }
78
79    // Fallback to original logic for backward compatibility
80    s.contains(':') || s.contains("--") || s.contains("var(")
81}
82
83fn append_blank_bytes(content: &mut String, byte_count: usize) {
84    content.extend(std::iter::repeat_n(' ', byte_count));
85}
86
87/// Extract all CSS-like string/template literal snippets from a JS source.
88pub(crate) fn extract_js_css_snippets(text: &str) -> Vec<JsCssSnippet> {
89    let bytes = text.as_bytes();
90    let mut snippets = Vec::new();
91    let mut i = 0;
92
93    while i < bytes.len() {
94        let b = bytes[i];
95
96        match b {
97            b'\'' | b'"' => {
98                // Regular string literal
99                let quote = b;
100                let content_start = i + 1;
101                i += 1;
102                while i < bytes.len() {
103                    if bytes[i] == b'\\' {
104                        i += 2;
105                        continue;
106                    }
107                    if bytes[i] == quote {
108                        let content = &text[content_start..i];
109                        if has_css_like_content(content) {
110                            snippets.push(JsCssSnippet {
111                                content_start,
112                                content: content.to_string(),
113                            });
114                        }
115                        i += 1;
116                        break;
117                    }
118                    i += 1;
119                }
120            }
121            b'`' => {
122                // Template literal — handle ${…} expressions
123                let content_start = i + 1;
124                let mut content = String::with_capacity(64);
125                let mut expr_depth: i32 = 0;
126                // Track nested quote within expressions
127                let mut expr_quote: Option<u8> = None;
128                i += 1;
129
130                while i < bytes.len() {
131                    if expr_depth > 0 {
132                        // Blank every consumed source byte so later offsets remain stable.
133                        if let Some(q) = expr_quote {
134                            if bytes[i] == b'\\' {
135                                let consumed = (bytes.len() - i).min(2);
136                                append_blank_bytes(&mut content, consumed);
137                                i += consumed;
138                                continue;
139                            }
140                            append_blank_bytes(&mut content, 1);
141                            if bytes[i] == q {
142                                expr_quote = None;
143                            }
144                            i += 1;
145                            continue;
146                        }
147                        match bytes[i] {
148                            b'\'' | b'"' | b'`' => {
149                                expr_quote = Some(bytes[i]);
150                                append_blank_bytes(&mut content, 1);
151                                i += 1;
152                                continue;
153                            }
154                            b'{' => {
155                                expr_depth += 1;
156                                append_blank_bytes(&mut content, 1);
157                                i += 1;
158                                continue;
159                            }
160                            b'}' => {
161                                expr_depth -= 1;
162                                append_blank_bytes(&mut content, 1);
163                                i += 1;
164                                continue;
165                            }
166                            _ => {
167                                append_blank_bytes(&mut content, 1);
168                                i += 1;
169                                continue;
170                            }
171                        }
172                    }
173
174                    // Inside template literal (not in expression)
175                    if bytes[i] == b'\\' {
176                        i += 2;
177                        continue;
178                    }
179                    if bytes[i] == b'`' {
180                        // End of template literal
181                        if has_css_like_content(&content) {
182                            snippets.push(JsCssSnippet {
183                                content_start,
184                                content,
185                            });
186                        }
187                        i += 1;
188                        break;
189                    }
190                    if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
191                        expr_depth = 1;
192                        // Replace ${ with spaces to preserve offsets
193                        content.push(' ');
194                        content.push(' ');
195                        i += 2;
196                        continue;
197                    }
198                    // Safely handle multi-byte UTF-8 characters
199                    if let Some(c) = text[i..].chars().next() {
200                        content.push(c);
201                        i += c.len_utf8();
202                    } else {
203                        i += 1;
204                    }
205                }
206            }
207            b'/' => {
208                // Skip comments to avoid false positives from URLs or regex
209                if i + 1 < bytes.len() {
210                    if bytes[i + 1] == b'/' {
211                        i += 2;
212                        while i < bytes.len() && bytes[i] != b'\n' {
213                            i += 1;
214                        }
215                        continue;
216                    }
217                    if bytes[i + 1] == b'*' {
218                        i += 2;
219                        while i + 1 < bytes.len() {
220                            if bytes[i] == b'*' && bytes[i + 1] == b'/' {
221                                i += 2;
222                                break;
223                            }
224                            i += 1;
225                        }
226                        continue;
227                    }
228                }
229                i += 1;
230            }
231            _ => {
232                i += 1;
233            }
234        }
235    }
236
237    snippets
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    use crate::manager::CssVariableManager;
245    use crate::types::{offset_to_position, Config};
246    use std::str::FromStr;
247
248    #[test]
249    fn test_extract_simple_template_literal() {
250        let text = "const css = `color: red;`";
251        let snippets = extract_js_css_snippets(text);
252        assert_eq!(snippets.len(), 1);
253        assert_eq!(snippets[0].content, "color: red;");
254    }
255
256    #[test]
257    fn test_extract_template_with_expressions() {
258        let text = "const Btn = styled.button`\n  color: ${props => props.$color};\n  background: #3b82f6;\n`";
259        let snippets = extract_js_css_snippets(text);
260        assert_eq!(snippets.len(), 1);
261        let c = &snippets[0].content;
262        // The expression ${...} should be replaced with spaces
263        assert!(c.contains("background: #3b82f6"));
264        assert!(c.contains("color:"));
265        // Expression region should be blanked
266        assert!(!c.contains("props"));
267    }
268
269    async fn assert_variable_name_position(text: &str, name: &str) {
270        let manager = CssVariableManager::new(Config::default());
271        let uri = Uri::from_str("file:///test.ts").unwrap();
272        parse_js_document(text, &uri, &manager).await.unwrap();
273        let variables = manager.get_variables(name).await;
274        assert_eq!(variables.len(), 1);
275        let expected_offset = text.find(name).unwrap();
276        assert_eq!(variables[0].source_position, expected_offset);
277        assert_eq!(
278            variables[0].name_range.unwrap().start,
279            offset_to_position(text, expected_offset),
280        );
281    }
282
283    #[tokio::test]
284    async fn test_template_expression_preserves_following_range() {
285        let text = r#"const css = `color: ${"red"}; --after: blue;`;"#;
286        assert_variable_name_position(text, "--after").await;
287    }
288
289    #[tokio::test]
290    async fn test_escaped_template_expression_preserves_following_range() {
291        let text = r#"const css = `color: ${"re\"d"}; --after: blue;`;"#;
292        assert_variable_name_position(text, "--after").await;
293    }
294
295    #[tokio::test]
296    async fn test_multibyte_template_expression_preserves_following_range() {
297        let text = r#"const css = `color: ${"赤色"}; --after: blue;`;"#;
298        assert_variable_name_position(text, "--after").await;
299    }
300
301    #[test]
302    fn test_extract_multiple_templates() {
303        let text = r#"
304            const a = styled.div`color: red;`;
305            const b = styled.span`background: blue;`;
306        "#;
307        let snippets = extract_js_css_snippets(text);
308        assert_eq!(snippets.len(), 2);
309    }
310
311    #[test]
312    fn test_extract_string_literal() {
313        let text = r#"const css = "color: #fff;""#;
314        let snippets = extract_js_css_snippets(text);
315        assert_eq!(snippets.len(), 1);
316        assert_eq!(snippets[0].content, "color: #fff;");
317    }
318
319    #[test]
320    fn test_skip_non_css_strings() {
321        let text = r#"const msg = "hello world";"#;
322        let snippets = extract_js_css_snippets(text);
323        assert_eq!(snippets.len(), 0);
324    }
325
326    #[test]
327    fn test_skip_comments() {
328        let text = "// this is a `comment` with a backtick\nconst css = `color: red;`";
329        let snippets = extract_js_css_snippets(text);
330        assert_eq!(snippets.len(), 1);
331        assert_eq!(snippets[0].content, "color: red;");
332    }
333
334    #[test]
335    fn test_template_nested_braces_in_expression() {
336        let text = "const css = `color: ${({theme}) => theme.primary};`";
337        let snippets = extract_js_css_snippets(text);
338        assert_eq!(snippets.len(), 1);
339        // The content should still be recognized as CSS (has colon)
340        assert!(snippets[0].content.contains("color:"));
341    }
342}