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}