1use regex::Regex;
7use std::env;
8use std::fs;
9use std::path::Path;
10
11#[derive(Debug, Clone)]
13pub struct Rule {
14 pub pattern: String,
16 pub replacement: String,
18 pub regex: Regex,
20 pub variables: Vec<String>,
22}
23
24#[derive(Debug)]
26pub struct SubstitutionResult {
27 pub matched: bool,
29 pub original: String,
31 pub command: String,
33 pub rule: Option<Rule>,
35}
36
37pub fn parse_lino_file(file_path: &Path) -> Vec<Rule> {
39 match fs::read_to_string(file_path) {
40 Ok(content) => parse_lino_content(&content),
41 Err(_) => Vec::new(),
42 }
43}
44
45pub fn parse_lino_content(content: &str) -> Vec<Rule> {
47 let mut rules = Vec::new();
48 let lines: Vec<&str> = content.lines().collect();
49
50 let mut i = 0;
51 while i < lines.len() {
52 let line = lines[i].trim();
53
54 if line.is_empty() || line.starts_with('#') {
56 i += 1;
57 continue;
58 }
59
60 if line == "(" {
62 i += 1;
63
64 let mut pattern: Option<&str> = None;
66 while i < lines.len() {
67 let pattern_line = lines[i].trim();
68 if !pattern_line.is_empty() && !pattern_line.starts_with('#') && pattern_line != ")"
69 {
70 pattern = Some(pattern_line);
71 i += 1;
72 break;
73 }
74 i += 1;
75 }
76
77 let mut replacement: Option<&str> = None;
79 while i < lines.len() {
80 let replacement_line = lines[i].trim();
81 if !replacement_line.is_empty()
82 && !replacement_line.starts_with('#')
83 && replacement_line != ")"
84 {
85 replacement = Some(replacement_line);
86 i += 1;
87 break;
88 }
89 i += 1;
90 }
91
92 while i < lines.len() {
94 let close_line = lines[i].trim();
95 if close_line == ")" {
96 break;
97 }
98 i += 1;
99 }
100
101 if let (Some(p), Some(r)) = (pattern, replacement) {
103 if let Some(rule) = create_rule(p, r) {
104 rules.push(rule);
105 }
106 }
107 }
108
109 i += 1;
110 }
111
112 rules
113}
114
115pub fn create_rule(pattern: &str, replacement: &str) -> Option<Rule> {
117 let var_regex = Regex::new(r"\$(\w+)").ok()?;
119 let mut variables: Vec<String> = Vec::new();
120
121 for cap in var_regex.captures_iter(pattern) {
122 if let Some(var_name) = cap.get(1) {
123 variables.push(var_name.as_str().to_string());
124 }
125 }
126
127 let mut temp_pattern = pattern.to_string();
129 let mut placeholders: Vec<(String, String)> = Vec::new();
130
131 for (i, var_name) in variables.iter().enumerate() {
132 let placeholder = format!("__VAR_{}__", i);
133 placeholders.push((placeholder.clone(), var_name.clone()));
134 temp_pattern = temp_pattern.replacen(&format!("${}", var_name), &placeholder, 1);
136 }
137
138 let mut regex_str = regex::escape(&temp_pattern);
140
141 for (placeholder, var_name) in &placeholders {
143 regex_str = regex_str.replace(placeholder, &format!("(?P<{}>.+?)", var_name));
144 }
145
146 regex_str = format!(r"^\s*{}\s*$", regex_str);
148
149 match Regex::new(&format!("(?i){}", regex_str)) {
151 Ok(regex) => Some(Rule {
152 pattern: pattern.to_string(),
153 replacement: replacement.to_string(),
154 regex,
155 variables,
156 }),
157 Err(e) => {
158 if is_debug() {
159 eprintln!("Invalid pattern: {} - {}", pattern, e);
160 }
161 None
162 }
163 }
164}
165
166pub fn sort_rules_by_specificity(rules: &mut [Rule]) {
168 rules.sort_by(|a, b| {
169 match b.variables.len().cmp(&a.variables.len()) {
171 std::cmp::Ordering::Equal => {
172 b.pattern.len().cmp(&a.pattern.len())
174 }
175 other => other,
176 }
177 });
178}
179
180pub fn match_and_substitute(input: &str, rules: &[Rule]) -> SubstitutionResult {
182 let trimmed_input = input.trim();
183
184 let mut sorted_rules = rules.to_vec();
186 sort_rules_by_specificity(&mut sorted_rules);
187
188 for rule in &sorted_rules {
189 if let Some(captures) = rule.regex.captures(trimmed_input) {
190 let mut command = rule.replacement.clone();
192
193 for var_name in &rule.variables {
195 if let Some(value) = captures.name(var_name) {
196 command = command.replace(&format!("${}", var_name), value.as_str());
197 }
198 }
199
200 return SubstitutionResult {
201 matched: true,
202 original: input.to_string(),
203 command,
204 rule: Some(rule.clone()),
205 };
206 }
207 }
208
209 SubstitutionResult {
211 matched: false,
212 original: input.to_string(),
213 command: input.to_string(),
214 rule: None,
215 }
216}
217
218pub fn load_default_substitutions() -> Vec<Rule> {
220 let possible_paths = [
222 std::env::current_exe()
224 .ok()
225 .and_then(|p| p.parent().map(|d| d.join("substitutions.lino"))),
226 Some(std::path::PathBuf::from("src/lib/substitutions.lino")),
228 Some(std::path::PathBuf::from("rust/src/lib/substitutions.lino")),
230 Some(std::path::PathBuf::from("js/src/lib/substitutions.lino")),
232 ];
233
234 for path in possible_paths.iter().flatten() {
235 if path.exists() {
236 let rules = parse_lino_file(path);
237 if !rules.is_empty() {
238 return rules;
239 }
240 }
241 }
242
243 Vec::new()
244}
245
246pub fn load_user_substitutions(custom_path: Option<&str>) -> Vec<Rule> {
248 if let Some(path) = custom_path {
250 let path = Path::new(path);
251 if path.exists() {
252 return parse_lino_file(path);
253 }
254 }
255
256 if let Some(home_dir) = env::var_os("HOME").or_else(|| env::var_os("USERPROFILE")) {
258 let user_lino_path = Path::new(&home_dir)
259 .join(".start-command")
260 .join("substitutions.lino");
261 if user_lino_path.exists() {
262 return parse_lino_file(&user_lino_path);
263 }
264 }
265
266 Vec::new()
267}
268
269#[derive(Debug, Default)]
271pub struct ProcessOptions {
272 pub custom_lino_path: Option<String>,
274 pub verbose: bool,
276}
277
278pub fn process_command(input: &str, options: &ProcessOptions) -> SubstitutionResult {
280 let user_rules = load_user_substitutions(options.custom_lino_path.as_deref());
282 let default_rules = load_default_substitutions();
283
284 let mut all_rules = user_rules;
286 all_rules.extend(default_rules);
287
288 if all_rules.is_empty() {
289 return SubstitutionResult {
290 matched: false,
291 original: input.to_string(),
292 command: input.to_string(),
293 rule: None,
294 };
295 }
296
297 let result = match_and_substitute(input, &all_rules);
298
299 if options.verbose && result.matched {
300 if let Some(ref rule) = result.rule {
301 println!("Pattern matched: \"{}\"", rule.pattern);
302 println!("Translated to: {}", result.command);
303 }
304 }
305
306 result
307}
308
309fn is_debug() -> bool {
310 env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true")
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn test_parse_lino_content() {
319 let content = r#"
320# Test comment
321(
322 install $packageName npm package
323 npm install $packageName
324)
325
326(
327 clone $url
328 git clone $url
329)
330"#;
331 let rules = parse_lino_content(content);
332 assert_eq!(rules.len(), 2);
333 assert_eq!(rules[0].pattern, "install $packageName npm package");
334 assert_eq!(rules[0].replacement, "npm install $packageName");
335 assert_eq!(rules[0].variables, vec!["packageName"]);
336 }
337
338 #[test]
339 fn test_create_rule() {
340 let rule = create_rule(
341 "install $version version of $packageName npm package",
342 "npm install $packageName@$version",
343 )
344 .unwrap();
345
346 assert_eq!(rule.variables, vec!["version", "packageName"]);
347 }
348
349 #[test]
350 fn test_match_and_substitute_basic() {
351 let rules = vec![create_rule(
352 "install $packageName npm package",
353 "npm install $packageName",
354 )
355 .unwrap()];
356
357 let result = match_and_substitute("install lodash npm package", &rules);
358 assert!(result.matched);
359 assert_eq!(result.command, "npm install lodash");
360 }
361
362 #[test]
363 fn test_match_and_substitute_multiple_vars() {
364 let rules = vec![create_rule(
365 "install $version version of $packageName npm package",
366 "npm install $packageName@$version",
367 )
368 .unwrap()];
369
370 let result = match_and_substitute("install 4.17.21 version of lodash npm package", &rules);
371 assert!(result.matched);
372 assert_eq!(result.command, "npm install lodash@4.17.21");
373 }
374
375 #[test]
376 fn test_match_and_substitute_no_match() {
377 let rules = vec![create_rule(
378 "install $packageName npm package",
379 "npm install $packageName",
380 )
381 .unwrap()];
382
383 let result = match_and_substitute("echo hello", &rules);
384 assert!(!result.matched);
385 assert_eq!(result.command, "echo hello");
386 }
387
388 #[test]
389 fn test_case_insensitive_matching() {
390 let rules = vec![create_rule("LIST FILES", "ls -la").unwrap()];
391
392 let result = match_and_substitute("list files", &rules);
393 assert!(result.matched);
394 assert_eq!(result.command, "ls -la");
395 }
396
397 #[test]
398 fn test_sort_rules_by_specificity() {
399 let mut rules = vec![
400 create_rule("install $pkg npm package", "npm i $pkg").unwrap(),
401 create_rule(
402 "install $ver version of $pkg npm package globally",
403 "npm i -g $pkg@$ver",
404 )
405 .unwrap(),
406 create_rule("install $pkg", "npm i $pkg").unwrap(),
407 ];
408
409 sort_rules_by_specificity(&mut rules);
410
411 assert_eq!(rules[0].variables.len(), 2);
413 assert!(rules[1].pattern.len() > rules[2].pattern.len());
415 }
416
417 #[test]
418 fn test_specificity_matching() {
419 let rules = vec![
420 create_rule("install $pkg npm package", "npm i $pkg").unwrap(),
421 create_rule(
422 "install $ver version of $pkg npm package globally",
423 "npm i -g $pkg@$ver",
424 )
425 .unwrap(),
426 ];
427
428 let result = match_and_substitute(
430 "install 1.0.0 version of express npm package globally",
431 &rules,
432 );
433 assert!(result.matched);
434 assert_eq!(result.command, "npm i -g express@1.0.0");
435 }
436}