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 offset = clamp_to_char_boundary(text, offset);
27    let document_kind =
28        resolve_document_kind(uri.path().as_str(), language_id, lookup_extension_map)?;
29    let start = completion_lookback_start(text, offset, document_kind);
30    let before_cursor = &text[start..offset];
31
32    match document_kind {
33        DocumentKind::Js => {
34            let slice = find_js_string_segment(before_cursor)?;
35            Some(CompletionContextSlice {
36                slice,
37                allow_without_braces: true,
38            })
39        }
40        DocumentKind::Html => find_html_style_context_slice(before_cursor),
41        DocumentKind::Css => Some(CompletionContextSlice {
42            slice: before_cursor,
43            allow_without_braces: false,
44        }),
45    }
46}
47
48fn completion_lookback_start(text: &str, offset: usize, document_kind: DocumentKind) -> usize {
49    match document_kind {
50        DocumentKind::Css => find_containing_block_start(text, offset),
51        DocumentKind::Html => find_html_completion_lookback_start(text, offset),
52        DocumentKind::Js => clamp_to_char_boundary(text, offset.saturating_sub(400)),
53    }
54}
55
56/// Scan backward to the opening `{` of the block containing the cursor.
57fn find_containing_block_start(text: &str, offset: usize) -> usize {
58    let offset = clamp_to_char_boundary(text, offset.min(text.len()));
59    let mut brace_depth = 0i32;
60
61    for (idx, ch) in text[..offset].char_indices().rev() {
62        match ch {
63            '}' => brace_depth += 1,
64            '{' => {
65                if brace_depth == 0 {
66                    return idx;
67                }
68                brace_depth -= 1;
69            }
70            _ => {}
71        }
72    }
73
74    clamp_to_char_boundary(text, offset.saturating_sub(400))
75}
76
77/// For HTML, include the enclosing `<style>` tag when present so block parsing works.
78fn find_html_completion_lookback_start(text: &str, offset: usize) -> usize {
79    let offset = clamp_to_char_boundary(text, offset.min(text.len()));
80    let lower = text[..offset].to_ascii_lowercase();
81
82    if let Some(style_tag_idx) = lower.rfind("<style") {
83        let inside_open_tag = lower
84            .rfind("</style")
85            .map(|close_idx| close_idx < style_tag_idx)
86            .unwrap_or(true);
87        if inside_open_tag {
88            return style_tag_idx;
89        }
90    }
91
92    find_containing_block_start(text, offset)
93}
94
95pub fn find_html_style_attribute_slice(before_cursor: &str) -> Option<&str> {
96    let lower = before_cursor.to_ascii_lowercase();
97    let bytes = lower.as_bytes();
98    let mut search_end = lower.len();
99
100    while let Some(idx) = lower[..search_end].rfind("style") {
101        if idx > 0 && is_word_byte(bytes[idx - 1]) {
102            search_end = idx;
103            continue;
104        }
105        let after_idx = idx + 5;
106        if after_idx < bytes.len() && is_word_byte(bytes[after_idx]) {
107            search_end = idx;
108            continue;
109        }
110
111        let mut j = after_idx;
112        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
113            j += 1;
114        }
115        if j >= bytes.len() || bytes[j] != b'=' {
116            search_end = idx;
117            continue;
118        }
119        j += 1;
120        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
121            j += 1;
122        }
123        if j >= bytes.len() {
124            return None;
125        }
126
127        let quote = bytes[j];
128        if quote != b'"' && quote != b'\'' {
129            search_end = idx;
130            continue;
131        }
132        let value_start = j + 1;
133        let rest = &bytes[value_start..];
134        if !rest.contains(&quote) {
135            return Some(&before_cursor[value_start..]);
136        }
137
138        search_end = idx;
139    }
140
141    None
142}
143
144pub fn find_html_style_block_slice(before_cursor: &str) -> Option<&str> {
145    let lower = before_cursor.to_ascii_lowercase();
146    let open_idx = lower.rfind("<style")?;
147    if let Some(close_idx) = lower.rfind("</style") {
148        if close_idx > open_idx {
149            return None;
150        }
151    }
152
153    let tag_end_rel = lower[open_idx..].find('>')?;
154    let tag_end = open_idx + tag_end_rel;
155    if tag_end + 1 > before_cursor.len() {
156        return None;
157    }
158
159    Some(&before_cursor[tag_end + 1..])
160}
161
162pub fn find_html_style_context_slice(before_cursor: &str) -> Option<CompletionContextSlice<'_>> {
163    if let Some(slice) = find_html_style_attribute_slice(before_cursor) {
164        return Some(CompletionContextSlice {
165            slice,
166            allow_without_braces: true,
167        });
168    }
169    if let Some(slice) = find_html_style_block_slice(before_cursor) {
170        return Some(CompletionContextSlice {
171            slice,
172            allow_without_braces: false,
173        });
174    }
175    None
176}
177
178pub fn find_js_string_segment(before_cursor: &str) -> Option<&str> {
179    let bytes = before_cursor.as_bytes();
180    let mut in_quote: Option<u8> = None;
181    let mut in_template = false;
182    let mut template_expr_depth: i32 = 0;
183    let mut expr_quote: Option<u8> = None;
184    let mut segment_start: Option<usize> = None;
185
186    let mut i = 0;
187    while i < bytes.len() {
188        let b = bytes[i];
189        if let Some(q) = in_quote {
190            if b == b'\\' {
191                i = i.saturating_add(2);
192                continue;
193            }
194            if b == q {
195                in_quote = None;
196                segment_start = None;
197            }
198            i += 1;
199            continue;
200        }
201
202        if in_template {
203            if template_expr_depth > 0 {
204                if let Some(q) = expr_quote {
205                    if b == b'\\' {
206                        i = i.saturating_add(2);
207                        continue;
208                    }
209                    if b == q {
210                        expr_quote = None;
211                    }
212                    i += 1;
213                    continue;
214                }
215
216                if b == b'\'' || b == b'"' || b == b'`' {
217                    expr_quote = Some(b);
218                    i += 1;
219                    continue;
220                }
221                if b == b'{' {
222                    template_expr_depth += 1;
223                } else if b == b'}' {
224                    template_expr_depth -= 1;
225                    if template_expr_depth == 0 {
226                        segment_start = Some(i + 1);
227                    }
228                }
229                i += 1;
230                continue;
231            }
232
233            if b == b'\\' {
234                i = i.saturating_add(2);
235                continue;
236            }
237            if b == b'`' {
238                in_template = false;
239                segment_start = None;
240                i += 1;
241                continue;
242            }
243            if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
244                template_expr_depth = 1;
245                segment_start = None;
246                i += 2;
247                continue;
248            }
249            i += 1;
250            continue;
251        }
252
253        if b == b'\'' || b == b'"' {
254            in_quote = Some(b);
255            segment_start = Some(i + 1);
256            i += 1;
257            continue;
258        }
259        if b == b'`' {
260            in_template = true;
261            segment_start = Some(i + 1);
262            i += 1;
263            continue;
264        }
265        i += 1;
266    }
267
268    if in_quote.is_some() {
269        return segment_start.map(|start| &before_cursor[start..]);
270    }
271    if in_template && template_expr_depth == 0 {
272        return segment_start.map(|start| &before_cursor[start..]);
273    }
274    None
275}
276
277pub fn find_context_colon(before_cursor: &str, allow_without_braces: bool) -> Option<usize> {
278    let mut in_braces = 0i32;
279    let mut in_parens = 0i32;
280    let mut last_colon: i32 = -1;
281    let mut last_semicolon: i32 = -1;
282    let mut last_brace: i32 = -1;
283
284    for (idx, ch) in before_cursor.char_indices().rev() {
285        match ch {
286            ')' => in_parens += 1,
287            '(' => {
288                in_parens -= 1;
289                if in_parens < 0 {
290                    in_parens = 0;
291                }
292            }
293            '}' => in_braces += 1,
294            '{' => {
295                in_braces -= 1;
296                if in_braces < 0 {
297                    last_brace = idx as i32;
298                    break;
299                }
300            }
301            ':' if in_parens == 0 && in_braces == 0 && last_colon == -1 => {
302                last_colon = idx as i32;
303            }
304            ';' if in_parens == 0 && in_braces == 0 && last_semicolon == -1 => {
305                last_semicolon = idx as i32;
306            }
307            _ => {}
308        }
309    }
310
311    if !allow_without_braces && last_brace == -1 {
312        return None;
313    }
314
315    if last_colon > last_semicolon && last_colon > last_brace {
316        Some(last_colon as usize)
317    } else {
318        None
319    }
320}
321
322pub fn get_value_context_info(before_cursor: &str, allow_without_braces: bool) -> ValueContext {
323    let colon_pos = match find_context_colon(before_cursor, allow_without_braces) {
324        Some(pos) => pos,
325        None => {
326            return ValueContext {
327                is_value_context: false,
328                property_name: None,
329            }
330        }
331    };
332    let before_colon = before_cursor[..colon_pos].trim_end();
333    if before_colon.is_empty() {
334        return ValueContext {
335            is_value_context: true,
336            property_name: None,
337        };
338    }
339
340    let mut start = before_colon.len();
341    for (idx, ch) in before_colon.char_indices().rev() {
342        if is_word_char(ch) {
343            start = idx;
344        } else {
345            break;
346        }
347    }
348
349    if start >= before_colon.len() {
350        return ValueContext {
351            is_value_context: true,
352            property_name: None,
353        };
354    }
355
356    ValueContext {
357        is_value_context: true,
358        property_name: Some(before_colon[start..].to_lowercase()),
359    }
360}
361
362pub fn score_variable_relevance(var_name: &str, property_name: Option<&str>) -> i32 {
363    let property_name = match property_name {
364        Some(name) => name,
365        None => return -1,
366    };
367
368    let lower_var_name = var_name.to_lowercase();
369
370    let color_properties = [
371        "color",
372        "background-color",
373        "background",
374        "border-color",
375        "outline-color",
376        "text-decoration-color",
377        "fill",
378        "stroke",
379    ];
380    if color_properties.contains(&property_name) {
381        if lower_var_name.contains("color")
382            || lower_var_name.contains("bg")
383            || lower_var_name.contains("background")
384            || lower_var_name.contains("primary")
385            || lower_var_name.contains("secondary")
386            || lower_var_name.contains("accent")
387            || lower_var_name.contains("text")
388            || lower_var_name.contains("border")
389            || lower_var_name.contains("link")
390        {
391            return 10;
392        }
393        if lower_var_name.contains("spacing")
394            || lower_var_name.contains("margin")
395            || lower_var_name.contains("padding")
396            || lower_var_name.contains("size")
397            || lower_var_name.contains("width")
398            || lower_var_name.contains("height")
399            || lower_var_name.contains("font")
400            || lower_var_name.contains("weight")
401            || lower_var_name.contains("radius")
402        {
403            return 0;
404        }
405        return 5;
406    }
407
408    let spacing_properties = [
409        "margin",
410        "margin-top",
411        "margin-right",
412        "margin-bottom",
413        "margin-left",
414        "padding",
415        "padding-top",
416        "padding-right",
417        "padding-bottom",
418        "padding-left",
419        "gap",
420        "row-gap",
421        "column-gap",
422    ];
423    if spacing_properties.contains(&property_name) {
424        if lower_var_name.contains("spacing")
425            || lower_var_name.contains("margin")
426            || lower_var_name.contains("padding")
427            || lower_var_name.contains("gap")
428        {
429            return 10;
430        }
431        if lower_var_name.contains("color")
432            || lower_var_name.contains("bg")
433            || lower_var_name.contains("background")
434        {
435            return 0;
436        }
437        return 5;
438    }
439
440    let size_properties = [
441        "width",
442        "height",
443        "max-width",
444        "max-height",
445        "min-width",
446        "min-height",
447        "font-size",
448    ];
449    if size_properties.contains(&property_name) {
450        if lower_var_name.contains("width")
451            || lower_var_name.contains("height")
452            || lower_var_name.contains("size")
453        {
454            return 10;
455        }
456        if lower_var_name.contains("color")
457            || lower_var_name.contains("bg")
458            || lower_var_name.contains("background")
459        {
460            return 0;
461        }
462        return 5;
463    }
464
465    if property_name.contains("radius") {
466        if lower_var_name.contains("radius") || lower_var_name.contains("rounded") {
467            return 10;
468        }
469        if lower_var_name.contains("color")
470            || lower_var_name.contains("bg")
471            || lower_var_name.contains("background")
472        {
473            return 0;
474        }
475        return 5;
476    }
477
478    let font_properties = ["font-family", "font-weight", "font-style"];
479    if font_properties.contains(&property_name) {
480        if lower_var_name.contains("font") {
481            return 10;
482        }
483        if lower_var_name.contains("color") || lower_var_name.contains("spacing") {
484            return 0;
485        }
486        return 5;
487    }
488
489    -1
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::document_kind::build_lookup_extension_map;
496    use crate::types::{offset_to_position, Config};
497    use ls_types::Uri;
498    use std::str::FromStr;
499
500    fn build_long_css_rule() -> String {
501        let decl = "  margin-top: 1px;\n";
502        let mut rule = String::from(".card {\n");
503        for _ in 0..30 {
504            rule.push_str(decl);
505        }
506        rule.push_str("  font: 400 16px/1.5 system-ui, sans-serif;\n");
507        rule.push_str("  color: var(--");
508        rule
509    }
510
511    #[test]
512    fn long_css_rule_detects_value_context_at_bottom() {
513        let text = build_long_css_rule();
514        assert!(text.len() > 500);
515
516        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
517        let uri = Uri::from_str("file:///styles.css").unwrap();
518        let position = offset_to_position(&text, text.len());
519
520        let context = completion_value_context_slice(&text, position, None, &uri, &lookup_map)
521            .expect("css document should yield a completion slice");
522        let value_context = get_value_context_info(context.slice, context.allow_without_braces);
523
524        assert!(
525            value_context.is_value_context,
526            "long rule blocks must still detect property value context"
527        );
528        assert_eq!(value_context.property_name.as_deref(), Some("color"));
529    }
530
531    #[test]
532    fn nested_css_rule_detects_inner_property() {
533        let text = ".outer { .inner { color: var(--";
534        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
535        let uri = Uri::from_str("file:///styles.css").unwrap();
536        let position = offset_to_position(text, text.len());
537
538        let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
539            .expect("expected css slice");
540        let value_context = get_value_context_info(context.slice, context.allow_without_braces);
541
542        assert!(value_context.is_value_context);
543        assert_eq!(value_context.property_name.as_deref(), Some("color"));
544    }
545
546    #[test]
547    fn html_style_block_lookback_includes_style_tag() {
548        let text = "<style>body { color: var(";
549        let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
550        let uri = Uri::from_str("file:///index.html").unwrap();
551        let position = offset_to_position(text, text.len());
552
553        let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
554            .expect("expected html style block slice");
555        assert_eq!(context.slice, "body { color: var(");
556        assert!(!context.allow_without_braces);
557    }
558}