Skip to main content

css_variable_lsp/
completion_context.rs

1use 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(&quote) {
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}