css_variable_lsp/
completion_context.rs1use std::collections::HashMap;
2
3use crate::document_kind::{resolve_document_kind, DocumentKind};
4use crate::text_utils::{clamp_to_char_boundary, is_word_byte, is_word_char};
5use crate::types::position_to_offset;
6use ls_types::{Position, Uri};
7
8pub struct CompletionContextSlice<'a> {
9 pub slice: &'a str,
10 pub allow_without_braces: bool,
11}
12
13pub struct ValueContext {
14 pub is_value_context: bool,
15 pub property_name: Option<String>,
16}
17
18pub fn completion_value_context_slice<'a>(
19 text: &'a str,
20 position: Position,
21 language_id: Option<&str>,
22 uri: &Uri,
23 lookup_extension_map: &HashMap<String, DocumentKind>,
24) -> Option<CompletionContextSlice<'a>> {
25 let offset = position_to_offset(text, position)?;
26 let start = clamp_to_char_boundary(text, offset.saturating_sub(400));
27 let offset = clamp_to_char_boundary(text, offset);
28 let before_cursor = &text[start..offset];
29
30 match resolve_document_kind(uri.path().as_str(), language_id, lookup_extension_map) {
31 Some(DocumentKind::Js) => {
32 let slice = find_js_string_segment(before_cursor)?;
33 Some(CompletionContextSlice {
34 slice,
35 allow_without_braces: true,
36 })
37 }
38 Some(DocumentKind::Html) => find_html_style_context_slice(before_cursor),
39 Some(DocumentKind::Css) => Some(CompletionContextSlice {
40 slice: before_cursor,
41 allow_without_braces: false,
42 }),
43 None => None,
44 }
45}
46
47pub fn find_html_style_attribute_slice(before_cursor: &str) -> Option<&str> {
48 let lower = before_cursor.to_ascii_lowercase();
49 let bytes = lower.as_bytes();
50 let mut search_end = lower.len();
51
52 while let Some(idx) = lower[..search_end].rfind("style") {
53 if idx > 0 && is_word_byte(bytes[idx - 1]) {
54 search_end = idx;
55 continue;
56 }
57 let after_idx = idx + 5;
58 if after_idx < bytes.len() && is_word_byte(bytes[after_idx]) {
59 search_end = idx;
60 continue;
61 }
62
63 let mut j = after_idx;
64 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
65 j += 1;
66 }
67 if j >= bytes.len() || bytes[j] != b'=' {
68 search_end = idx;
69 continue;
70 }
71 j += 1;
72 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
73 j += 1;
74 }
75 if j >= bytes.len() {
76 return None;
77 }
78
79 let quote = bytes[j];
80 if quote != b'"' && quote != b'\'' {
81 search_end = idx;
82 continue;
83 }
84 let value_start = j + 1;
85 let rest = &bytes[value_start..];
86 if !rest.contains("e) {
87 return Some(&before_cursor[value_start..]);
88 }
89
90 search_end = idx;
91 }
92
93 None
94}
95
96pub fn find_html_style_block_slice(before_cursor: &str) -> Option<&str> {
97 let lower = before_cursor.to_ascii_lowercase();
98 let open_idx = lower.rfind("<style")?;
99 if let Some(close_idx) = lower.rfind("</style") {
100 if close_idx > open_idx {
101 return None;
102 }
103 }
104
105 let tag_end_rel = lower[open_idx..].find('>')?;
106 let tag_end = open_idx + tag_end_rel;
107 if tag_end + 1 > before_cursor.len() {
108 return None;
109 }
110
111 Some(&before_cursor[tag_end + 1..])
112}
113
114pub fn find_html_style_context_slice(before_cursor: &str) -> Option<CompletionContextSlice<'_>> {
115 if let Some(slice) = find_html_style_attribute_slice(before_cursor) {
116 return Some(CompletionContextSlice {
117 slice,
118 allow_without_braces: true,
119 });
120 }
121 if let Some(slice) = find_html_style_block_slice(before_cursor) {
122 return Some(CompletionContextSlice {
123 slice,
124 allow_without_braces: false,
125 });
126 }
127 None
128}
129
130pub fn find_js_string_segment(before_cursor: &str) -> Option<&str> {
131 let bytes = before_cursor.as_bytes();
132 let mut in_quote: Option<u8> = None;
133 let mut in_template = false;
134 let mut template_expr_depth: i32 = 0;
135 let mut expr_quote: Option<u8> = None;
136 let mut segment_start: Option<usize> = None;
137
138 let mut i = 0;
139 while i < bytes.len() {
140 let b = bytes[i];
141 if let Some(q) = in_quote {
142 if b == b'\\' {
143 i = i.saturating_add(2);
144 continue;
145 }
146 if b == q {
147 in_quote = None;
148 segment_start = None;
149 }
150 i += 1;
151 continue;
152 }
153
154 if in_template {
155 if template_expr_depth > 0 {
156 if let Some(q) = expr_quote {
157 if b == b'\\' {
158 i = i.saturating_add(2);
159 continue;
160 }
161 if b == q {
162 expr_quote = None;
163 }
164 i += 1;
165 continue;
166 }
167
168 if b == b'\'' || b == b'"' || b == b'`' {
169 expr_quote = Some(b);
170 i += 1;
171 continue;
172 }
173 if b == b'{' {
174 template_expr_depth += 1;
175 } else if b == b'}' {
176 template_expr_depth -= 1;
177 if template_expr_depth == 0 {
178 segment_start = Some(i + 1);
179 }
180 }
181 i += 1;
182 continue;
183 }
184
185 if b == b'\\' {
186 i = i.saturating_add(2);
187 continue;
188 }
189 if b == b'`' {
190 in_template = false;
191 segment_start = None;
192 i += 1;
193 continue;
194 }
195 if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
196 template_expr_depth = 1;
197 segment_start = None;
198 i += 2;
199 continue;
200 }
201 i += 1;
202 continue;
203 }
204
205 if b == b'\'' || b == b'"' {
206 in_quote = Some(b);
207 segment_start = Some(i + 1);
208 i += 1;
209 continue;
210 }
211 if b == b'`' {
212 in_template = true;
213 segment_start = Some(i + 1);
214 i += 1;
215 continue;
216 }
217 i += 1;
218 }
219
220 if in_quote.is_some() {
221 return segment_start.map(|start| &before_cursor[start..]);
222 }
223 if in_template && template_expr_depth == 0 {
224 return segment_start.map(|start| &before_cursor[start..]);
225 }
226 None
227}
228
229pub fn find_context_colon(before_cursor: &str, allow_without_braces: bool) -> Option<usize> {
230 let mut in_braces = 0i32;
231 let mut in_parens = 0i32;
232 let mut last_colon: i32 = -1;
233 let mut last_semicolon: i32 = -1;
234 let mut last_brace: i32 = -1;
235
236 for (idx, ch) in before_cursor.char_indices().rev() {
237 match ch {
238 ')' => in_parens += 1,
239 '(' => {
240 in_parens -= 1;
241 if in_parens < 0 {
242 in_parens = 0;
243 }
244 }
245 '}' => in_braces += 1,
246 '{' => {
247 in_braces -= 1;
248 if in_braces < 0 {
249 last_brace = idx as i32;
250 break;
251 }
252 }
253 ':' if in_parens == 0 && in_braces == 0 && last_colon == -1 => {
254 last_colon = idx as i32;
255 }
256 ';' if in_parens == 0 && in_braces == 0 && last_semicolon == -1 => {
257 last_semicolon = idx as i32;
258 }
259 _ => {}
260 }
261 }
262
263 if !allow_without_braces && last_brace == -1 {
264 return None;
265 }
266
267 if last_colon > last_semicolon && last_colon > last_brace {
268 Some(last_colon as usize)
269 } else {
270 None
271 }
272}
273
274pub fn get_value_context_info(before_cursor: &str, allow_without_braces: bool) -> ValueContext {
275 let colon_pos = match find_context_colon(before_cursor, allow_without_braces) {
276 Some(pos) => pos,
277 None => {
278 return ValueContext {
279 is_value_context: false,
280 property_name: None,
281 }
282 }
283 };
284 let before_colon = before_cursor[..colon_pos].trim_end();
285 if before_colon.is_empty() {
286 return ValueContext {
287 is_value_context: true,
288 property_name: None,
289 };
290 }
291
292 let mut start = before_colon.len();
293 for (idx, ch) in before_colon.char_indices().rev() {
294 if is_word_char(ch) {
295 start = idx;
296 } else {
297 break;
298 }
299 }
300
301 if start >= before_colon.len() {
302 return ValueContext {
303 is_value_context: true,
304 property_name: None,
305 };
306 }
307
308 ValueContext {
309 is_value_context: true,
310 property_name: Some(before_colon[start..].to_lowercase()),
311 }
312}
313
314pub fn score_variable_relevance(var_name: &str, property_name: Option<&str>) -> i32 {
315 let property_name = match property_name {
316 Some(name) => name,
317 None => return -1,
318 };
319
320 let lower_var_name = var_name.to_lowercase();
321
322 let color_properties = [
323 "color",
324 "background-color",
325 "background",
326 "border-color",
327 "outline-color",
328 "text-decoration-color",
329 "fill",
330 "stroke",
331 ];
332 if color_properties.contains(&property_name) {
333 if lower_var_name.contains("color")
334 || lower_var_name.contains("bg")
335 || lower_var_name.contains("background")
336 || lower_var_name.contains("primary")
337 || lower_var_name.contains("secondary")
338 || lower_var_name.contains("accent")
339 || lower_var_name.contains("text")
340 || lower_var_name.contains("border")
341 || lower_var_name.contains("link")
342 {
343 return 10;
344 }
345 if lower_var_name.contains("spacing")
346 || lower_var_name.contains("margin")
347 || lower_var_name.contains("padding")
348 || lower_var_name.contains("size")
349 || lower_var_name.contains("width")
350 || lower_var_name.contains("height")
351 || lower_var_name.contains("font")
352 || lower_var_name.contains("weight")
353 || lower_var_name.contains("radius")
354 {
355 return 0;
356 }
357 return 5;
358 }
359
360 let spacing_properties = [
361 "margin",
362 "margin-top",
363 "margin-right",
364 "margin-bottom",
365 "margin-left",
366 "padding",
367 "padding-top",
368 "padding-right",
369 "padding-bottom",
370 "padding-left",
371 "gap",
372 "row-gap",
373 "column-gap",
374 ];
375 if spacing_properties.contains(&property_name) {
376 if lower_var_name.contains("spacing")
377 || lower_var_name.contains("margin")
378 || lower_var_name.contains("padding")
379 || lower_var_name.contains("gap")
380 {
381 return 10;
382 }
383 if lower_var_name.contains("color")
384 || lower_var_name.contains("bg")
385 || lower_var_name.contains("background")
386 {
387 return 0;
388 }
389 return 5;
390 }
391
392 let size_properties = [
393 "width",
394 "height",
395 "max-width",
396 "max-height",
397 "min-width",
398 "min-height",
399 "font-size",
400 ];
401 if size_properties.contains(&property_name) {
402 if lower_var_name.contains("width")
403 || lower_var_name.contains("height")
404 || lower_var_name.contains("size")
405 {
406 return 10;
407 }
408 if lower_var_name.contains("color")
409 || lower_var_name.contains("bg")
410 || lower_var_name.contains("background")
411 {
412 return 0;
413 }
414 return 5;
415 }
416
417 if property_name.contains("radius") {
418 if lower_var_name.contains("radius") || lower_var_name.contains("rounded") {
419 return 10;
420 }
421 if lower_var_name.contains("color")
422 || lower_var_name.contains("bg")
423 || lower_var_name.contains("background")
424 {
425 return 0;
426 }
427 return 5;
428 }
429
430 let font_properties = ["font-family", "font-weight", "font-style"];
431 if font_properties.contains(&property_name) {
432 if lower_var_name.contains("font") {
433 return 10;
434 }
435 if lower_var_name.contains("color") || lower_var_name.contains("spacing") {
436 return 0;
437 }
438 return 5;
439 }
440
441 -1
442}