Skip to main content

alf/parser/
mod.rs

1//! Shell file parsing for aliases and functions.
2
3use anyhow::Result;
4use regex::Regex;
5use std::path::Path;
6
7use crate::models::{AliasEntry, EntryType};
8
9/// Parse a shell file and extract aliases and functions
10///
11/// # Arguments
12/// * `path` - Path to the shell file to parse
13///
14/// # Returns
15/// A vector of parsed alias and function entries
16pub fn parse_shell_file(path: &Path) -> Result<Vec<AliasEntry>> {
17   let content = std::fs::read_to_string(path)?;
18   let mut entries = Vec::new();
19
20   // Extract aliases
21   entries.extend(extract_aliases(&content, path));
22
23   // Extract functions
24   entries.extend(extract_functions(&content, path));
25
26   Ok(entries)
27}
28
29/// Extract alias definitions from shell file content
30fn extract_aliases(
31   content: &str,
32   source: &Path,
33) -> Vec<AliasEntry> {
34   let mut entries = Vec::new();
35   let lines: Vec<&str> = content.lines().collect();
36
37   // Regex patterns for alias definitions
38   // Matches: alias name='command' or alias name="command"
39   // Alias names can include dots, hyphens, and underscores (e.g., ll, my.alias)
40   let alias_pattern =
41      Regex::new(r#"^\s*alias\s+([a-zA-Z_][a-zA-Z0-9._-]*)=(?:'([^']*)'|"([^"]*)"|([^\s]+))"#).unwrap();
42
43   for (line_num, line) in lines.iter().enumerate() {
44      if let Some(captures) = alias_pattern.captures(line) {
45         let name = captures.get(1).unwrap().as_str().to_string();
46         // The value is in one of these three groups (whichever matched)
47         let value = captures
48            .get(2)
49            .or_else(|| captures.get(3))
50            .or_else(|| captures.get(4))
51            .map(|m| m.as_str())
52            .unwrap_or("")
53            .to_string();
54
55         let comments = extract_comments(&lines, line_num);
56
57         entries.push(AliasEntry {
58            name,
59            entry_type: EntryType::Alias,
60            value,
61            comments,
62            source_file: source.to_path_buf(),
63         });
64      }
65   }
66
67   entries
68}
69
70/// Extract function definitions from shell file content
71fn extract_functions(
72   content: &str,
73   source: &Path,
74) -> Vec<AliasEntry> {
75   let mut entries = Vec::new();
76   let lines: Vec<&str> = content.lines().collect();
77
78   // Regex pattern for function definitions
79   // Matches: function name() { or name() {
80   // Function names can include dots, hyphens, and underscores (e.g., t.command, my-func)
81   let func_pattern = Regex::new(r#"^\s*(?:function\s+)?([a-zA-Z_][a-zA-Z0-9._-]*)\s*\(\)\s*\{"#).unwrap();
82
83   for (line_num, line) in lines.iter().enumerate() {
84      if let Some(captures) = func_pattern.captures(line) {
85         let name = captures.get(1).unwrap().as_str().to_string();
86
87         // Extract function body (from opening { to closing })
88         let mut body_lines = vec![line.to_string()];
89         let mut brace_count = 1;
90
91         for next_line in lines.iter().skip(line_num + 1) {
92            body_lines.push(next_line.to_string());
93            brace_count += next_line.matches('{').count() as i32;
94            brace_count -= next_line.matches('}').count() as i32;
95
96            if brace_count == 0 {
97               break;
98            }
99         }
100
101         let value = body_lines.join("\n");
102         let comments = extract_comments(&lines, line_num);
103
104         entries.push(AliasEntry {
105            name,
106            entry_type: EntryType::Function,
107            value,
108            comments,
109            source_file: source.to_path_buf(),
110         });
111      }
112   }
113
114   entries
115}
116
117/// Extract alf-friendly comments preceding a definition
118///
119/// Supports two formats:
120/// 1. Multi-line format:
121///    # alf
122///    # description line 1
123///    # description line 2
124///    # fla
125///    alias name='value'
126///
127/// 2. Concise format (line before definition):
128///    #@: description here :f#
129///    alias name='value'
130///
131/// If neither format is found, returns None (ignores other comments)
132fn extract_comments(
133   lines: &[&str],
134   line_number: usize,
135) -> Option<Vec<String>> {
136   // First check for concise format on the line BEFORE the definition
137   if line_number > 0 {
138      if let Some(description) = extract_concise_comment(lines[line_number - 1]) {
139         return Some(vec![description]);
140      }
141   }
142
143   // Then check for multi-line format above the definition
144   extract_multiline_comment(lines, line_number)
145}
146
147/// Extract concise alf-friendly comment: #@: description :f#
148/// Returns the description if found
149fn extract_concise_comment(line: &str) -> Option<String> {
150   let pattern = Regex::new(r#"#@:\s*(.+?)\s*:f#"#).ok()?;
151   pattern.captures(line).and_then(|caps| caps.get(1).map(|m| m.as_str().trim().to_string()))
152}
153
154/// Extract multi-line alf-friendly comment block
155/// Format:
156/// # alf
157/// # line 1
158/// # line 2
159/// # fla
160fn extract_multiline_comment(
161   lines: &[&str],
162   mut line_number: usize,
163) -> Option<Vec<String>> {
164   if line_number == 0 {
165      return None;
166   }
167
168   line_number = line_number.saturating_sub(1);
169
170   // Look for closing marker "# fla" first
171   let mut closing_found = false;
172   let mut description_lines = Vec::new();
173   let mut temp_line_num = line_number;
174
175   // Scan backwards to find the markers
176   loop {
177      let line = lines[temp_line_num].trim();
178
179      // Check for closing marker
180      if line == "# fla" {
181         closing_found = true;
182      } else if line == "# alf" && closing_found {
183         // Found opening marker, we have a valid block
184         return if description_lines.is_empty() {
185            None
186         } else {
187            description_lines.reverse();
188            Some(description_lines)
189         };
190      } else if closing_found && line.starts_with('#') && line != "# fla" && line != "# alf" {
191         // Collect description lines between markers
192         let text = line.trim_start_matches('#').trim();
193         if !text.is_empty() {
194            description_lines.push(text.to_string());
195         }
196      } else if closing_found && !line.starts_with('#') && !line.is_empty() {
197         // Hit non-comment before finding opening marker, invalid block
198         return None;
199      } else if closing_found && line.is_empty() {
200         // Empty line after closing marker, block is incomplete
201         return None;
202      } else if !closing_found && line.is_empty() {
203         // Empty line before finding closing marker, no alf block here
204         return None;
205      } else if !closing_found && line.starts_with('#') && line != "# alf" {
206         // Found comment that's not an alf marker before closing marker
207         // This is not a valid alf block
208         return None;
209      }
210
211      if temp_line_num == 0 {
212         break;
213      }
214      temp_line_num -= 1;
215   }
216
217   None
218}
219
220#[cfg(test)]
221mod parser_tests;