Skip to main content

start_command/
substitution.rs

1//! Substitution Engine for start-command
2//!
3//! Parses .lino files and matches natural language commands to shell commands.
4//! Uses Links Notation style patterns with variables like $packageName, $version
5
6use regex::Regex;
7use std::env;
8use std::fs;
9use std::path::Path;
10
11/// A substitution rule parsed from a .lino file
12#[derive(Debug, Clone)]
13pub struct Rule {
14    /// The original pattern string
15    pub pattern: String,
16    /// The replacement template
17    pub replacement: String,
18    /// Compiled regex for matching
19    pub regex: Regex,
20    /// Variable names in order of appearance
21    pub variables: Vec<String>,
22}
23
24/// Result of matching and substituting a command
25#[derive(Debug)]
26pub struct SubstitutionResult {
27    /// Whether a match was found
28    pub matched: bool,
29    /// Original input
30    pub original: String,
31    /// Final command (substituted or original)
32    pub command: String,
33    /// The rule that matched (if any)
34    pub rule: Option<Rule>,
35}
36
37/// Parse a .lino substitutions file
38pub 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
45/// Parse .lino content string
46pub 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        // Skip empty lines and comments
55        if line.is_empty() || line.starts_with('#') {
56            i += 1;
57            continue;
58        }
59
60        // Look for opening parenthesis of doublet link
61        if line == "(" {
62            i += 1;
63
64            // Find the pattern line (first non-empty, non-comment line)
65            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            // Find the replacement line (second non-empty, non-comment line)
78            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            // Find closing parenthesis
93            while i < lines.len() {
94                let close_line = lines[i].trim();
95                if close_line == ")" {
96                    break;
97                }
98                i += 1;
99            }
100
101            // Create rule if both pattern and replacement found
102            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
115/// Create a rule object from pattern and replacement strings
116pub fn create_rule(pattern: &str, replacement: &str) -> Option<Rule> {
117    // Extract variables from pattern (words starting with $)
118    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    // Convert pattern to regex
128    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        // Replace first occurrence of this variable
135        temp_pattern = temp_pattern.replacen(&format!("${}", var_name), &placeholder, 1);
136    }
137
138    // Escape special regex characters in the remaining text
139    let mut regex_str = regex::escape(&temp_pattern);
140
141    // Replace placeholders with named capture groups
142    for (placeholder, var_name) in &placeholders {
143        regex_str = regex_str.replace(placeholder, &format!("(?P<{}>.+?)", var_name));
144    }
145
146    // Make the regex match the entire string with optional whitespace
147    regex_str = format!(r"^\s*{}\s*$", regex_str);
148
149    // Compile regex (case insensitive)
150    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
166/// Sort rules so more specific patterns (more variables, longer patterns) match first
167pub fn sort_rules_by_specificity(rules: &mut [Rule]) {
168    rules.sort_by(|a, b| {
169        // More variables = more specific, should come first
170        match b.variables.len().cmp(&a.variables.len()) {
171            std::cmp::Ordering::Equal => {
172                // Longer patterns = more specific
173                b.pattern.len().cmp(&a.pattern.len())
174            }
175            other => other,
176        }
177    });
178}
179
180/// Match input against rules and return the substituted command
181pub fn match_and_substitute(input: &str, rules: &[Rule]) -> SubstitutionResult {
182    let trimmed_input = input.trim();
183
184    // Sort rules by specificity
185    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            // Build the substituted command
191            let mut command = rule.replacement.clone();
192
193            // Replace variables with captured values
194            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    // No match found - return original input
210    SubstitutionResult {
211        matched: false,
212        original: input.to_string(),
213        command: input.to_string(),
214        rule: None,
215    }
216}
217
218/// Load default substitutions from the package's substitutions.lino file
219pub fn load_default_substitutions() -> Vec<Rule> {
220    // Look for substitutions.lino relative to the executable or in standard locations
221    let possible_paths = [
222        // Same directory as executable
223        std::env::current_exe()
224            .ok()
225            .and_then(|p| p.parent().map(|d| d.join("substitutions.lino"))),
226        // In src/lib relative to current dir (for development)
227        Some(std::path::PathBuf::from("src/lib/substitutions.lino")),
228        // In the Rust source directory
229        Some(std::path::PathBuf::from("rust/src/lib/substitutions.lino")),
230        // In js source directory (shared)
231        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
246/// Load user substitutions from custom path or home directory
247pub fn load_user_substitutions(custom_path: Option<&str>) -> Vec<Rule> {
248    // If custom path provided, use it
249    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    // Look in home directory for .start-command/substitutions.lino
257    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/// Options for processing a command
270#[derive(Debug, Default)]
271pub struct ProcessOptions {
272    /// Custom path to .lino file
273    pub custom_lino_path: Option<String>,
274    /// Enable verbose output
275    pub verbose: bool,
276}
277
278/// Process a command through the substitution engine
279pub fn process_command(input: &str, options: &ProcessOptions) -> SubstitutionResult {
280    // Load rules: user rules take precedence
281    let user_rules = load_user_substitutions(options.custom_lino_path.as_deref());
282    let default_rules = load_default_substitutions();
283
284    // User rules first, then default rules
285    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        // Most specific (2 vars) should be first
412        assert_eq!(rules[0].variables.len(), 2);
413        // Then 1 var with longer pattern
414        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        // Should match the more specific rule
429        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}