Skip to main content

par_term/snippets/
mod.rs

1//! Snippet variable substitution and insertion engine.
2//!
3//! This module provides:
4//! - Variable substitution for snippets (built-in and custom variables)
5//! - Session variable access (hostname, username, path, job, etc.)
6//! - Snippet text processing with \(variable) syntax
7//! - Integration with the terminal for text insertion
8
9use crate::badge::SessionVariables;
10use crate::config::snippets::BuiltInVariable;
11use regex::Regex;
12use std::collections::HashMap;
13
14/// Error type for snippet substitution failures.
15#[derive(Debug, Clone)]
16pub enum SubstitutionError {
17    /// Variable name is empty or invalid
18    InvalidVariable(String),
19    /// Variable is not defined (built-in or custom)
20    UndefinedVariable(String),
21}
22
23impl std::fmt::Display for SubstitutionError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Self::InvalidVariable(name) => write!(f, "Invalid variable name: {}", name),
27            Self::UndefinedVariable(name) => write!(f, "Undefined variable: {}", name),
28        }
29    }
30}
31
32impl std::error::Error for SubstitutionError {}
33
34/// Result type for substitution operations.
35pub type SubstitutionResult<T> = Result<T, SubstitutionError>;
36
37/// Variable substitution engine for snippets.
38///
39/// Substitutes variables in the format \(variable_name) with their values.
40/// Supports both built-in variables (date, time, hostname, etc.) and custom
41/// variables defined per-snippet.
42pub struct VariableSubstitutor {
43    /// Regex pattern to match \(variable) syntax
44    pattern: Regex,
45}
46
47impl VariableSubstitutor {
48    /// Create a new variable substitutor.
49    pub fn new() -> Self {
50        // Match \(variable_name) where variable_name is alphanumeric + underscore + dot
51        // Dot allows session.hostname style variables
52        let pattern = Regex::new(r"\\\(([a-zA-Z_][a-zA-Z0-9_.]*)\)").expect(
53            "VariableSubstitutor: snippet variable pattern is valid and should always compile",
54        );
55
56        Self { pattern }
57    }
58
59    /// Substitute all variables in the given text.
60    ///
61    /// # Arguments
62    /// * `text` - The text containing variables to substitute
63    /// * `custom_vars` - Custom variables defined for this snippet
64    ///
65    /// # Returns
66    /// The text with all variables replaced by their values.
67    pub fn substitute(
68        &self,
69        text: &str,
70        custom_vars: &HashMap<String, String>,
71    ) -> SubstitutionResult<String> {
72        self.substitute_with_session(text, custom_vars, None)
73    }
74
75    /// Substitute all variables in the given text, including session variables.
76    ///
77    /// # Arguments
78    /// * `text` - The text containing variables to substitute
79    /// * `custom_vars` - Custom variables defined for this snippet
80    /// * `session_vars` - Optional session variables (hostname, path, job, etc.)
81    ///
82    /// # Returns
83    /// The text with all variables replaced by their values.
84    pub fn substitute_with_session(
85        &self,
86        text: &str,
87        custom_vars: &HashMap<String, String>,
88        session_vars: Option<&SessionVariables>,
89    ) -> SubstitutionResult<String> {
90        let mut result = text.to_string();
91
92        // Find all variable placeholders
93        for cap in self.pattern.captures_iter(text) {
94            let full_match = cap
95                .get(0)
96                .expect("capture group 0 (full match) must be present after a match")
97                .as_str();
98            let var_name = cap
99                .get(1)
100                .expect("capture group 1 (variable name) must be present after a match")
101                .as_str();
102
103            // Resolve the variable value
104            let value = self.resolve_variable_with_session(var_name, custom_vars, session_vars)?;
105
106            // Replace the placeholder with the value
107            result = result.replace(full_match, &value);
108        }
109
110        Ok(result)
111    }
112
113    /// Resolve a single variable to its value, including session variables.
114    fn resolve_variable_with_session(
115        &self,
116        name: &str,
117        custom_vars: &HashMap<String, String>,
118        session_vars: Option<&SessionVariables>,
119    ) -> SubstitutionResult<String> {
120        // Check custom variables first (highest priority)
121        if let Some(value) = custom_vars.get(name) {
122            return Ok(value.clone());
123        }
124
125        // Check session variables (second priority)
126        if let Some(value) = session_vars.and_then(|session| session.get(name)) {
127            return Ok(value);
128        }
129
130        // Check built-in variables (third priority)
131        if let Some(builtin) = BuiltInVariable::parse(name) {
132            return Ok(builtin.resolve());
133        }
134
135        // Variable not found
136        Err(SubstitutionError::UndefinedVariable(name.to_string()))
137    }
138
139    /// Check if text contains any variables.
140    pub fn has_variables(&self, text: &str) -> bool {
141        self.pattern.is_match(text)
142    }
143
144    /// Extract all variable names from the text.
145    pub fn extract_variables(&self, text: &str) -> Vec<String> {
146        self.pattern
147            .captures_iter(text)
148            .map(|cap| {
149                cap.get(1)
150                    .expect("capture group 1 (variable name) must be present after a match")
151                    .as_str()
152                    .to_string()
153            })
154            .collect()
155    }
156}
157
158impl Default for VariableSubstitutor {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_substitute_builtin_variables() {
170        let substitutor = VariableSubstitutor::new();
171        let custom_vars = HashMap::new();
172
173        // Test date variable (should produce something like YYYY-MM-DD)
174        let result = substitutor
175            .substitute("Today is \\(date)", &custom_vars)
176            .unwrap();
177        assert!(result.starts_with("Today is "));
178        assert!(!result.contains("\\(date)"));
179
180        // Test user variable
181        let result = substitutor
182            .substitute("User: \\(user)", &custom_vars)
183            .unwrap();
184        assert!(result.starts_with("User: "));
185        assert!(!result.contains("\\(user)"));
186    }
187
188    #[test]
189    fn test_substitute_custom_variables() {
190        let substitutor = VariableSubstitutor::new();
191        let mut custom_vars = HashMap::new();
192        custom_vars.insert("name".to_string(), "Alice".to_string());
193        custom_vars.insert("project".to_string(), "par-term".to_string());
194
195        let result = substitutor
196            .substitute("Hello \\(name), welcome to \\(project)!", &custom_vars)
197            .unwrap();
198
199        assert_eq!(result, "Hello Alice, welcome to par-term!");
200    }
201
202    #[test]
203    fn test_substitute_mixed_variables() {
204        let substitutor = VariableSubstitutor::new();
205        let mut custom_vars = HashMap::new();
206        custom_vars.insert("greeting".to_string(), "Hello".to_string());
207
208        let result = substitutor
209            .substitute("\\(greeting) \\(user), today is \\(date)", &custom_vars)
210            .unwrap();
211
212        assert!(result.starts_with("Hello "));
213        assert!(!result.contains("\\("));
214    }
215
216    #[test]
217    fn test_undefined_variable() {
218        let substitutor = VariableSubstitutor::new();
219        let custom_vars = HashMap::new();
220
221        let result = substitutor.substitute("Value: \\(undefined)", &custom_vars);
222
223        assert!(result.is_err());
224        match result.unwrap_err() {
225            SubstitutionError::UndefinedVariable(name) => assert_eq!(name, "undefined"),
226            _ => panic!("Expected UndefinedVariable error"),
227        }
228    }
229
230    #[test]
231    fn test_has_variables() {
232        let substitutor = VariableSubstitutor::new();
233
234        assert!(substitutor.has_variables("Hello \\(user)"));
235        assert!(!substitutor.has_variables("Hello world"));
236    }
237
238    #[test]
239    fn test_extract_variables() {
240        let substitutor = VariableSubstitutor::new();
241
242        let vars = substitutor.extract_variables("Hello \\(user), today is \\(date)");
243        assert_eq!(vars, vec!["user", "date"]);
244    }
245
246    #[test]
247    fn test_no_variables() {
248        let substitutor = VariableSubstitutor::new();
249        let custom_vars = HashMap::new();
250
251        let result = substitutor
252            .substitute("Just plain text with no variables", &custom_vars)
253            .unwrap();
254
255        assert_eq!(result, "Just plain text with no variables");
256    }
257
258    #[test]
259    fn test_empty_custom_vars() {
260        let substitutor = VariableSubstitutor::new();
261        let custom_vars = HashMap::new();
262
263        let result = substitutor
264            .substitute("User: \\(user), Path: \\(path)", &custom_vars)
265            .unwrap();
266
267        // Should successfully substitute built-in variables
268        assert!(result.contains("User:"));
269        assert!(result.contains("Path:"));
270        assert!(!result.contains("\\("));
271    }
272
273    #[test]
274    fn test_duplicate_variables() {
275        let substitutor = VariableSubstitutor::new();
276        let mut custom_vars = HashMap::new();
277        custom_vars.insert("name".to_string(), "Alice".to_string());
278
279        let result = substitutor
280            .substitute("\\(name) and \\(name) again", &custom_vars)
281            .unwrap();
282
283        assert_eq!(result, "Alice and Alice again");
284    }
285
286    #[test]
287    fn test_escaped_backslash() {
288        let substitutor = VariableSubstitutor::new();
289        let custom_vars = HashMap::new();
290
291        // Test that \( is the variable syntax, not just an escaped paren
292        let result = substitutor
293            .substitute("Use \\(user) for the username", &custom_vars)
294            .unwrap();
295
296        assert!(!result.contains("\\("));
297        assert!(!result.contains("\\)"));
298    }
299}