css_variable_lsp/
runtime_config.rs

1use std::collections::HashMap;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum PathDisplayMode {
5    Relative,
6    Absolute,
7    Abbreviated,
8}
9
10#[derive(Debug, Clone)]
11pub struct RuntimeConfig {
12    pub enable_color_provider: bool,
13    pub color_only_on_variables: bool,
14    pub lookup_files: Option<Vec<String>>,
15    pub ignore_globs: Option<Vec<String>>,
16    pub path_display_mode: PathDisplayMode,
17    pub path_display_abbrev_length: usize,
18}
19
20fn get_arg_value(args: &[String], name: &str) -> Option<String> {
21    let flag = format!("--{name}");
22    if let Some(idx) = args.iter().position(|arg| arg == &flag) {
23        if let Some(candidate) = args.get(idx + 1) {
24            if !candidate.starts_with('-') {
25                return Some(candidate.to_string());
26            }
27        }
28        return None;
29    }
30
31    let prefix = format!("{}=", flag);
32    for arg in args {
33        if arg.starts_with(&prefix) {
34            return Some(arg[prefix.len()..].to_string());
35        }
36    }
37
38    None
39}
40
41fn parse_optional_int(value: Option<&str>) -> Option<i64> {
42    let raw = value?.trim();
43    if raw.is_empty() {
44        return None;
45    }
46    raw.parse::<i64>().ok()
47}
48
49fn normalize_path_display_mode(value: Option<&str>) -> Option<PathDisplayMode> {
50    let raw = value?.trim();
51    if raw.is_empty() {
52        return None;
53    }
54    match raw.to_lowercase().as_str() {
55        "relative" => Some(PathDisplayMode::Relative),
56        "absolute" => Some(PathDisplayMode::Absolute),
57        "abbreviated" | "abbr" | "fish" => Some(PathDisplayMode::Abbreviated),
58        _ => None,
59    }
60}
61
62fn parse_path_display(value: Option<&str>) -> (Option<PathDisplayMode>, Option<i64>) {
63    let raw = match value {
64        Some(v) if !v.trim().is_empty() => v,
65        _ => return (None, None),
66    };
67
68    let mut parts = raw.splitn(2, ':');
69    let mode_part = parts.next();
70    let length_part = parts.next();
71
72    (
73        normalize_path_display_mode(mode_part),
74        parse_optional_int(length_part),
75    )
76}
77
78fn split_lookup_list(value: &str) -> Vec<String> {
79    value
80        .split(',')
81        .map(|entry| entry.trim())
82        .filter(|entry| !entry.is_empty())
83        .map(|entry| entry.to_string())
84        .collect()
85}
86
87fn resolve_lookup_files(args: &[String], env: &HashMap<String, String>) -> Option<Vec<String>> {
88    let mut cli_files = Vec::new();
89    let mut i = 0;
90    while i < args.len() {
91        let arg = &args[i];
92        if arg == "--lookup-files" {
93            if let Some(next) = args.get(i + 1) {
94                if !next.starts_with('-') {
95                    cli_files.extend(split_lookup_list(next));
96                    i += 1;
97                }
98            }
99        } else if let Some(rest) = arg.strip_prefix("--lookup-files=") {
100            cli_files.extend(split_lookup_list(rest));
101        } else if arg == "--lookup-file" {
102            if let Some(next) = args.get(i + 1) {
103                if !next.starts_with('-') {
104                    cli_files.push(next.to_string());
105                    i += 1;
106                }
107            }
108        } else if let Some(rest) = arg.strip_prefix("--lookup-file=") {
109            cli_files.push(rest.to_string());
110        }
111        i += 1;
112    }
113
114    if !cli_files.is_empty() {
115        return Some(cli_files);
116    }
117
118    if let Some(env_value) = env.get("CSS_LSP_LOOKUP_FILES") {
119        let env_files = split_lookup_list(env_value);
120        if !env_files.is_empty() {
121            return Some(env_files);
122        }
123    }
124
125    None
126}
127
128fn resolve_ignore_globs(args: &[String], env: &HashMap<String, String>) -> Option<Vec<String>> {
129    let mut cli_globs = Vec::new();
130    let mut i = 0;
131    while i < args.len() {
132        let arg = &args[i];
133        if arg == "--ignore-globs" {
134            if let Some(next) = args.get(i + 1) {
135                if !next.starts_with('-') {
136                    cli_globs.extend(split_lookup_list(next));
137                    i += 1;
138                }
139            }
140        } else if let Some(rest) = arg.strip_prefix("--ignore-globs=") {
141            cli_globs.extend(split_lookup_list(rest));
142        } else if arg == "--ignore-glob" {
143            if let Some(next) = args.get(i + 1) {
144                if !next.starts_with('-') {
145                    cli_globs.push(next.to_string());
146                    i += 1;
147                }
148            }
149        } else if let Some(rest) = arg.strip_prefix("--ignore-glob=") {
150            cli_globs.push(rest.to_string());
151        }
152        i += 1;
153    }
154
155    if !cli_globs.is_empty() {
156        return Some(cli_globs);
157    }
158
159    if let Some(env_value) = env.get("CSS_LSP_IGNORE_GLOBS") {
160        let env_globs = split_lookup_list(env_value);
161        if !env_globs.is_empty() {
162            return Some(env_globs);
163        }
164    }
165
166    None
167}
168
169pub fn build_runtime_config_with_env(
170    args: &[String],
171    env: &HashMap<String, String>,
172) -> RuntimeConfig {
173    let enable_color_provider = !args.iter().any(|arg| arg == "--no-color-preview");
174    let color_only_on_variables = args.iter().any(|arg| arg == "--color-only-variables")
175        || env
176            .get("CSS_LSP_COLOR_ONLY_VARIABLES")
177            .map(|v| v == "1")
178            .unwrap_or(false);
179
180    let lookup_files = resolve_lookup_files(args, env);
181    let ignore_globs = resolve_ignore_globs(args, env);
182
183    let path_display_arg = get_arg_value(args, "path-display");
184    let path_display_env = env.get("CSS_LSP_PATH_DISPLAY").cloned();
185    let (mode_override, length_override) =
186        parse_path_display(path_display_arg.as_deref().or(path_display_env.as_deref()));
187
188    let path_display_mode = mode_override.unwrap_or(PathDisplayMode::Relative);
189
190    let length_arg = get_arg_value(args, "path-display-length");
191    let length_env = env.get("CSS_LSP_PATH_DISPLAY_LENGTH").cloned();
192    let length_raw = parse_optional_int(length_arg.as_deref().or(length_env.as_deref()))
193        .or(length_override)
194        .unwrap_or(1);
195    let path_display_abbrev_length = length_raw.max(0) as usize;
196
197    RuntimeConfig {
198        enable_color_provider,
199        color_only_on_variables,
200        lookup_files,
201        ignore_globs,
202        path_display_mode,
203        path_display_abbrev_length,
204    }
205}
206
207pub fn build_runtime_config(args: &[String]) -> RuntimeConfig {
208    let env: HashMap<String, String> = std::env::vars().collect();
209    build_runtime_config_with_env(args, &env)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn runtime_config_prefers_cli_over_env() {
218        let args = vec![
219            "--no-color-preview".to_string(),
220            "--color-only-variables".to_string(),
221            "--lookup-files".to_string(),
222            "a.css,b.html".to_string(),
223            "--ignore-glob=dist/**".to_string(),
224            "--path-display=abbreviated:2".to_string(),
225        ];
226        let mut env = HashMap::new();
227        env.insert(
228            "CSS_LSP_LOOKUP_FILES".to_string(),
229            "ignored.css".to_string(),
230        );
231        env.insert("CSS_LSP_IGNORE_GLOBS".to_string(), "ignored/**".to_string());
232        env.insert("CSS_LSP_PATH_DISPLAY".to_string(), "absolute".to_string());
233
234        let config = build_runtime_config_with_env(&args, &env);
235
236        assert!(!config.enable_color_provider);
237        assert!(config.color_only_on_variables);
238        assert_eq!(
239            config.lookup_files.as_ref().unwrap(),
240            &vec!["a.css".to_string(), "b.html".to_string()]
241        );
242        assert_eq!(
243            config.ignore_globs.as_ref().unwrap(),
244            &vec!["dist/**".to_string()]
245        );
246        assert_eq!(config.path_display_mode, PathDisplayMode::Abbreviated);
247        assert_eq!(config.path_display_abbrev_length, 2);
248    }
249
250    #[test]
251    fn runtime_config_uses_env_when_cli_missing() {
252        let args: Vec<String> = Vec::new();
253        let mut env = HashMap::new();
254        env.insert(
255            "CSS_LSP_LOOKUP_FILES".to_string(),
256            "one.css,two.html".to_string(),
257        );
258        env.insert(
259            "CSS_LSP_IGNORE_GLOBS".to_string(),
260            "dist/**,out/**".to_string(),
261        );
262        env.insert("CSS_LSP_PATH_DISPLAY".to_string(), "relative".to_string());
263        env.insert("CSS_LSP_PATH_DISPLAY_LENGTH".to_string(), "3".to_string());
264
265        let config = build_runtime_config_with_env(&args, &env);
266
267        assert!(config.enable_color_provider);
268        assert!(!config.color_only_on_variables);
269        assert_eq!(
270            config.lookup_files.as_ref().unwrap(),
271            &vec!["one.css".to_string(), "two.html".to_string()]
272        );
273        assert_eq!(
274            config.ignore_globs.as_ref().unwrap(),
275            &vec!["dist/**".to_string(), "out/**".to_string()]
276        );
277        assert_eq!(config.path_display_mode, PathDisplayMode::Relative);
278        assert_eq!(config.path_display_abbrev_length, 3);
279    }
280}