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