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}