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
87fn escaped_sequence_byte_len(text: &str, slash_offset: usize) -> usize {
88    let bytes = text.as_bytes();
89    if slash_offset + 1 >= bytes.len() {
90        return 1;
91    }
92
93    if bytes[slash_offset + 1] == b'\r'
94        && slash_offset + 2 < bytes.len()
95        && bytes[slash_offset + 2] == b'\n'
96    {
97        return 3;
98    }
99
100    1 + text[slash_offset + 1..]
101        .chars()
102        .next()
103        .map(char::len_utf8)
104        .unwrap_or(0)
105}
106
107/// Extract all CSS-like string/template literal snippets from a JS source.
108pub(crate) fn extract_js_css_snippets(text: &str) -> Vec<JsCssSnippet> {
109    let bytes = text.as_bytes();
110    let mut snippets = Vec::new();
111    let mut i = 0;
112
113    while i < bytes.len() {
114        let b = bytes[i];
115
116        match b {
117            b'\'' | b'"' => {
118                // Regular string literal
119                let quote = b;
120                let content_start = i + 1;
121                i += 1;
122                while i < bytes.len() {
123                    if bytes[i] == b'\\' {
124                        i += 2;
125                        continue;
126                    }
127                    if bytes[i] == quote {
128                        let content = &text[content_start..i];
129                        if has_css_like_content(content) {
130                            snippets.push(JsCssSnippet {
131                                content_start,
132                                content: content.to_string(),
133                            });
134                        }
135                        i += 1;
136                        break;
137                    }
138                    i += 1;
139                }
140            }
141            b'`' => {
142                // Template literal — handle ${…} expressions
143                let content_start = i + 1;
144                let mut content = String::with_capacity(64);
145                let mut expr_depth: i32 = 0;
146                // Track nested quote within expressions
147                let mut expr_quote: Option<u8> = None;
148                i += 1;
149
150                while i < bytes.len() {
151                    if expr_depth > 0 {
152                        // Blank every consumed source byte so later offsets remain stable.
153                        if let Some(q) = expr_quote {
154                            if bytes[i] == b'\\' {
155                                let consumed = escaped_sequence_byte_len(text, i);
156                                append_blank_bytes(&mut content, consumed);
157                                i += consumed;
158                                continue;
159                            }
160                            append_blank_bytes(&mut content, 1);
161                            if bytes[i] == q {
162                                expr_quote = None;
163                            }
164                            i += 1;
165                            continue;
166                        }
167                        match bytes[i] {
168                            b'\'' | b'"' | b'`' => {
169                                expr_quote = Some(bytes[i]);
170                                append_blank_bytes(&mut content, 1);
171                                i += 1;
172                                continue;
173                            }
174                            b'{' => {
175                                expr_depth += 1;
176                                append_blank_bytes(&mut content, 1);
177                                i += 1;
178                                continue;
179                            }
180                            b'}' => {
181                                expr_depth -= 1;
182                                append_blank_bytes(&mut content, 1);
183                                i += 1;
184                                continue;
185                            }
186                            _ => {
187                                append_blank_bytes(&mut content, 1);
188                                i += 1;
189                                continue;
190                            }
191                        }
192                    }
193
194                    // Inside template literal (not in expression)
195                    if bytes[i] == b'\\' {
196                        let consumed = escaped_sequence_byte_len(text, i);
197                        append_blank_bytes(&mut content, consumed);
198                        i += consumed;
199                        continue;
200                    }
201                    if bytes[i] == b'`' {
202                        // End of template literal
203                        if has_css_like_content(&content) {
204                            snippets.push(JsCssSnippet {
205                                content_start,
206                                content,
207                            });
208                        }
209                        i += 1;
210                        break;
211                    }
212                    if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
213                        expr_depth = 1;
214                        // Replace ${ with spaces to preserve offsets
215                        content.push(' ');
216                        content.push(' ');
217                        i += 2;
218                        continue;
219                    }
220                    // Safely handle multi-byte UTF-8 characters
221                    if let Some(c) = text[i..].chars().next() {
222                        content.push(c);
223                        i += c.len_utf8();
224                    } else {
225                        i += 1;
226                    }
227                }
228            }
229            b'/' => {
230                // Skip comments to avoid false positives from URLs or regex
231                if i + 1 < bytes.len() {
232                    if bytes[i + 1] == b'/' {
233                        i += 2;
234                        while i < bytes.len() && bytes[i] != b'\n' {
235                            i += 1;
236                        }
237                        continue;
238                    }
239                    if bytes[i + 1] == b'*' {
240                        i += 2;
241                        while i + 1 < bytes.len() {
242                            if bytes[i] == b'*' && bytes[i + 1] == b'/' {
243                                i += 2;
244                                break;
245                            }
246                            i += 1;
247                        }
248                        continue;
249                    }
250                }
251                i += 1;
252            }
253            _ => {
254                i += 1;
255            }
256        }
257    }
258
259    snippets
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    use crate::manager::CssVariableManager;
267    use crate::types::{offset_to_position, Config};
268    use std::str::FromStr;
269
270    #[test]
271    fn test_extract_simple_template_literal() {
272        let text = "const css = `color: red;`";
273        let snippets = extract_js_css_snippets(text);
274        assert_eq!(snippets.len(), 1);
275        assert_eq!(snippets[0].content, "color: red;");
276    }
277
278    #[test]
279    fn test_extract_template_with_expressions() {
280        let text = "const Btn = styled.button`\n  color: ${props => props.$color};\n  background: #3b82f6;\n`";
281        let snippets = extract_js_css_snippets(text);
282        assert_eq!(snippets.len(), 1);
283        let c = &snippets[0].content;
284        // The expression ${...} should be replaced with spaces
285        assert!(c.contains("background: #3b82f6"));
286        assert!(c.contains("color:"));
287        // Expression region should be blanked
288        assert!(!c.contains("props"));
289    }
290
291    async fn assert_variable_name_position(text: &str, name: &str) {
292        let manager = CssVariableManager::new(Config::default());
293        let uri = Uri::from_str("file:///test.ts").unwrap();
294        parse_js_document(text, &uri, &manager).await.unwrap();
295        let variables = manager.get_variables(name).await;
296        assert_eq!(variables.len(), 1);
297        let expected_offset = text.find(name).unwrap();
298        assert_eq!(variables[0].source_position, expected_offset);
299        assert_eq!(
300            variables[0].name_range.unwrap().start,
301            offset_to_position(text, expected_offset),
302        );
303    }
304
305    #[tokio::test]
306    async fn test_template_expression_preserves_following_range() {
307        let text = r#"const css = `color: ${"red"}; --after: blue;`;"#;
308        assert_variable_name_position(text, "--after").await;
309    }
310
311    #[tokio::test]
312    async fn test_escaped_template_expression_preserves_following_range() {
313        let text = r#"const css = `color: ${"re\"d"}; --after: blue;`;"#;
314        assert_variable_name_position(text, "--after").await;
315    }
316
317    #[tokio::test]
318    async fn test_template_escape_preserves_following_range() {
319        let text = r#"const css = `content: \`; --after: blue;`;"#;
320        assert_variable_name_position(text, "--after").await;
321    }
322
323    #[tokio::test]
324    async fn test_multibyte_template_expression_preserves_following_range() {
325        let text = r#"const css = `color: ${"赤色"}; --after: blue;`;"#;
326        assert_variable_name_position(text, "--after").await;
327    }
328
329    #[test]
330    fn test_extract_multiple_templates() {
331        let text = r#"
332            const a = styled.div`color: red;`;
333            const b = styled.span`background: blue;`;
334        "#;
335        let snippets = extract_js_css_snippets(text);
336        assert_eq!(snippets.len(), 2);
337    }
338
339    #[test]
340    fn test_extract_string_literal() {
341        let text = r#"const css = "color: #fff;""#;
342        let snippets = extract_js_css_snippets(text);
343        assert_eq!(snippets.len(), 1);
344        assert_eq!(snippets[0].content, "color: #fff;");
345    }
346
347    #[test]
348    fn test_skip_non_css_strings() {
349        let text = r#"const msg = "hello world";"#;
350        let snippets = extract_js_css_snippets(text);
351        assert_eq!(snippets.len(), 0);
352    }
353
354    #[test]
355    fn test_skip_comments() {
356        let text = "// this is a `comment` with a backtick\nconst css = `color: red;`";
357        let snippets = extract_js_css_snippets(text);
358        assert_eq!(snippets.len(), 1);
359        assert_eq!(snippets[0].content, "color: red;");
360    }
361
362    #[test]
363    fn test_template_nested_braces_in_expression() {
364        let text = "const css = `color: ${({theme}) => theme.primary};`";
365        let snippets = extract_js_css_snippets(text);
366        assert_eq!(snippets.len(), 1);
367        // The content should still be recognized as CSS (has colon)
368        assert!(snippets[0].content.contains("color:"));
369    }
370}