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
83/// Extract all CSS-like string/template literal snippets from a JS source.
84pub(crate) fn extract_js_css_snippets(text: &str) -> Vec<JsCssSnippet> {
85    let bytes = text.as_bytes();
86    let mut snippets = Vec::new();
87    let mut i = 0;
88
89    while i < bytes.len() {
90        let b = bytes[i];
91
92        match b {
93            b'\'' | b'"' => {
94                // Regular string literal
95                let quote = b;
96                let content_start = i + 1;
97                i += 1;
98                while i < bytes.len() {
99                    if bytes[i] == b'\\' {
100                        i += 2;
101                        continue;
102                    }
103                    if bytes[i] == quote {
104                        let content = &text[content_start..i];
105                        if has_css_like_content(content) {
106                            snippets.push(JsCssSnippet {
107                                content_start,
108                                content: content.to_string(),
109                            });
110                        }
111                        i += 1;
112                        break;
113                    }
114                    i += 1;
115                }
116            }
117            b'`' => {
118                // Template literal — handle ${…} expressions
119                let content_start = i + 1;
120                let mut content = String::with_capacity(64);
121                let mut expr_depth: i32 = 0;
122                // Track nested quote within expressions
123                let mut expr_quote: Option<u8> = None;
124                i += 1;
125
126                while i < bytes.len() {
127                    if expr_depth > 0 {
128                        // Inside a template expression
129                        if let Some(q) = expr_quote {
130                            // Inside a nested string within the expression
131                            if bytes[i] == b'\\' {
132                                i += 2;
133                                continue;
134                            }
135                            if bytes[i] == q {
136                                expr_quote = None;
137                            }
138                            i += 1;
139                            continue;
140                        }
141
142                        match bytes[i] {
143                            b'\'' | b'"' | b'`' => {
144                                expr_quote = Some(bytes[i]);
145                                i += 1;
146                                continue;
147                            }
148                            b'{' => {
149                                expr_depth += 1;
150                                i += 1;
151                                continue;
152                            }
153                            b'}' => {
154                                expr_depth -= 1;
155                                if expr_depth == 0 {
156                                    // Expression closed; push a space to preserve
157                                    // offsets for the literal content that follows
158                                    content.push(' ');
159                                    i += 1;
160                                    continue;
161                                }
162                                i += 1;
163                                continue;
164                            }
165                            _ => {
166                                // Replace expression characters with spaces
167                                // to keep position tracking accurate
168                                content.push(' ');
169                                i += 1;
170                                continue;
171                            }
172                        }
173                    }
174
175                    // Inside template literal (not in expression)
176                    if bytes[i] == b'\\' {
177                        i += 2;
178                        continue;
179                    }
180                    if bytes[i] == b'`' {
181                        // End of template literal
182                        if has_css_like_content(&content) {
183                            snippets.push(JsCssSnippet {
184                                content_start,
185                                content,
186                            });
187                        }
188                        i += 1;
189                        break;
190                    }
191                    if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
192                        expr_depth = 1;
193                        // Replace ${ with spaces to preserve offsets
194                        content.push(' ');
195                        content.push(' ');
196                        i += 2;
197                        continue;
198                    }
199                    // Safely handle multi-byte UTF-8 characters
200                    if let Some(c) = text[i..].chars().next() {
201                        content.push(c);
202                        i += c.len_utf8();
203                    } else {
204                        i += 1;
205                    }
206                }
207            }
208            b'/' => {
209                // Skip comments to avoid false positives from URLs or regex
210                if i + 1 < bytes.len() {
211                    if bytes[i + 1] == b'/' {
212                        i += 2;
213                        while i < bytes.len() && bytes[i] != b'\n' {
214                            i += 1;
215                        }
216                        continue;
217                    }
218                    if bytes[i + 1] == b'*' {
219                        i += 2;
220                        while i + 1 < bytes.len() {
221                            if bytes[i] == b'*' && bytes[i + 1] == b'/' {
222                                i += 2;
223                                break;
224                            }
225                            i += 1;
226                        }
227                        continue;
228                    }
229                }
230                i += 1;
231            }
232            _ => {
233                i += 1;
234            }
235        }
236    }
237
238    snippets
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_extract_simple_template_literal() {
247        let text = "const css = `color: red;`";
248        let snippets = extract_js_css_snippets(text);
249        assert_eq!(snippets.len(), 1);
250        assert_eq!(snippets[0].content, "color: red;");
251    }
252
253    #[test]
254    fn test_extract_template_with_expressions() {
255        let text = "const Btn = styled.button`\n  color: ${props => props.$color};\n  background: #3b82f6;\n`";
256        let snippets = extract_js_css_snippets(text);
257        assert_eq!(snippets.len(), 1);
258        let c = &snippets[0].content;
259        // The expression ${...} should be replaced with spaces
260        assert!(c.contains("background: #3b82f6"));
261        assert!(c.contains("color:"));
262        // Expression region should be blanked
263        assert!(!c.contains("props"));
264    }
265
266    #[test]
267    fn test_extract_multiple_templates() {
268        let text = r#"
269            const a = styled.div`color: red;`;
270            const b = styled.span`background: blue;`;
271        "#;
272        let snippets = extract_js_css_snippets(text);
273        assert_eq!(snippets.len(), 2);
274    }
275
276    #[test]
277    fn test_extract_string_literal() {
278        let text = r#"const css = "color: #fff;""#;
279        let snippets = extract_js_css_snippets(text);
280        assert_eq!(snippets.len(), 1);
281        assert_eq!(snippets[0].content, "color: #fff;");
282    }
283
284    #[test]
285    fn test_skip_non_css_strings() {
286        let text = r#"const msg = "hello world";"#;
287        let snippets = extract_js_css_snippets(text);
288        assert_eq!(snippets.len(), 0);
289    }
290
291    #[test]
292    fn test_skip_comments() {
293        let text = "// this is a `comment` with a backtick\nconst css = `color: red;`";
294        let snippets = extract_js_css_snippets(text);
295        assert_eq!(snippets.len(), 1);
296        assert_eq!(snippets[0].content, "color: red;");
297    }
298
299    #[test]
300    fn test_template_nested_braces_in_expression() {
301        let text = "const css = `color: ${({theme}) => theme.primary};`";
302        let snippets = extract_js_css_snippets(text);
303        assert_eq!(snippets.len(), 1);
304        // The content should still be recognized as CSS (has colon)
305        assert!(snippets[0].content.contains("color:"));
306    }
307}