css_variable_lsp/parsers/
js.rs1use ls_types::Uri;
2
3use super::css::{parse_css_snippet, CssParseContext};
4use crate::manager::CssVariableManager;
5
6pub(crate) struct JsCssSnippet {
8 pub content_start: usize,
10 pub content: String,
12}
13
14pub 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
48fn has_css_like_content(s: &str) -> bool {
51 if s.contains("var(") || s.contains("--") {
57 return true;
58 }
59
60 if let Some(pos) = s.find(':') {
62 let after = &s[pos + 1..].trim_start();
64 if s.starts_with("http") || s.starts_with("//") {
66 return false;
67 }
68 if !after.starts_with(' ') && !after.starts_with(';') && !after.is_empty() {
72 if s.contains("://") || s.starts_with('/') {
74 return false;
75 }
76 }
77 }
78
79 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
87pub(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 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 let content_start = i + 1;
124 let mut content = String::with_capacity(64);
125 let mut expr_depth: i32 = 0;
126 let mut expr_quote: Option<u8> = None;
128 i += 1;
129
130 while i < bytes.len() {
131 if expr_depth > 0 {
132 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 if bytes[i] == b'\\' {
176 i += 2;
177 continue;
178 }
179 if bytes[i] == b'`' {
180 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 content.push(' ');
194 content.push(' ');
195 i += 2;
196 continue;
197 }
198 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 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 assert!(c.contains("background: #3b82f6"));
264 assert!(c.contains("color:"));
265 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 assert!(snippets[0].content.contains("color:"));
341 }
342}