intelli_shell/utils/variable.rs
1use std::sync::LazyLock;
2
3use regex::{CaptureMatches, Captures, Regex};
4
5use crate::model::Variable;
6
7const VARIABLE_REGEX: &str = r"\{\{((?:\{[^}]+\}|[^}]+))\}\}";
8const ALT_VARIABLE_REGEX: &str = r"<([\w.*-]+)>";
9
10// Regex to match exclusively variables in the alternative syntax `<name>`
11static ALT_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(ALT_VARIABLE_REGEX).unwrap());
12
13/// Regex to match variables from a command, with a capturing group for the name
14pub static COMMAND_VARIABLE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(VARIABLE_REGEX).unwrap());
15
16/// Regex to match variables from a command, with two capturing groups for the name
17/// - Group 1: Will exist if intelli-shell's variable syntax like `{{name}}` is matched
18/// - Group 2: Will exist if an alternative syntax like `<name>` is matched
19pub static COMMAND_VARIABLE_REGEX_ALT: LazyLock<Regex> =
20 LazyLock::new(|| Regex::new(&format!(r#"{VARIABLE_REGEX}|{ALT_VARIABLE_REGEX}"#)).unwrap());
21
22/// Regex to match variables from a command, with a capturing group for the name.
23///
24/// This regex identifies if variables are unquoted, single-quoted, or double-quoted:
25/// - Group 1: Will exist if a single-quoted placeholder like '{{name}}' is matched
26/// - Group 2: Will exist if a double-quoted placeholder like "{{name}}" is matched
27/// - Group 3: Will exist if an unquoted placeholder like {{name}} is matched
28pub static COMMAND_VARIABLE_REGEX_QUOTES: LazyLock<Regex> =
29 LazyLock::new(|| Regex::new(&format!(r#"'{VARIABLE_REGEX}'|"{VARIABLE_REGEX}"|{VARIABLE_REGEX}"#)).unwrap());
30
31/// Converts alternative variable syntax `<var>` to the regular `{{var}}` syntax
32pub fn convert_alt_to_regular(command: &str) -> String {
33 ALT_REGEX.replace_all(command, "{{$1}}").into_owned()
34}
35
36/// Extracts all [`Variable`] instances from a given command string
37pub fn extract_variables(command: &str) -> Vec<Variable> {
38 COMMAND_VARIABLE_REGEX
39 .captures_iter(command)
40 .filter_map(|cap| cap.get(1))
41 .map(|v| v.as_str())
42 .map(Variable::parse)
43 .collect()
44}
45
46/// An iterator that splits a text string based on a regular expression, yielding both the substrings that _don't_ match
47/// the regex and the `Captures` objects for the parts that _do_ match.
48///
49/// This is useful when you need to process parts of a string separated by delimiters defined by a regex, but you also
50/// need access to the captured groups within those delimiters.
51///
52/// # Examples
53///
54/// ```rust
55/// # use intelli_shell::utils::{SplitCaptures, SplitItem};
56/// # use regex::Regex;
57/// let regex = Regex::new(r"\{(\w+)\}").unwrap();
58/// let text = "Hello {name}, welcome to {place}!";
59/// let mut parts = vec![];
60/// for item in SplitCaptures::new(®ex, text) {
61/// match item {
62/// SplitItem::Unmatched(s) => parts.push(format!("Unmatched: '{}'", s)),
63/// SplitItem::Captured(caps) => {
64/// parts.push(format!("Captured: '{}', Group 1: '{}'", &caps[0], &caps[1]))
65/// }
66/// }
67/// }
68/// assert_eq!(
69/// parts,
70/// vec![
71/// "Unmatched: 'Hello '",
72/// "Captured: '{name}', Group 1: 'name'",
73/// "Unmatched: ', welcome to '",
74/// "Captured: '{place}', Group 1: 'place'",
75/// "Unmatched: '!'",
76/// ]
77/// );
78/// ```
79pub struct SplitCaptures<'r, 't> {
80 /// Iterator over regex captures
81 finder: CaptureMatches<'r, 't>,
82 /// The original text being split
83 text: &'t str,
84 /// The byte index marking the end of the last match/unmatched part
85 last: usize,
86 /// Holds the captures of the _next_ match to be returned
87 caps: Option<Captures<'t>>,
88}
89
90impl<'r, 't> SplitCaptures<'r, 't> {
91 /// Creates a new [SplitCaptures] iterator.
92 pub fn new(regex: &'r Regex, text: &'t str) -> SplitCaptures<'r, 't> {
93 SplitCaptures {
94 finder: regex.captures_iter(text),
95 text,
96 last: 0,
97 caps: None,
98 }
99 }
100}
101
102/// Represents an item yielded by the [SplitCaptures] iterator.
103///
104/// It can be either a part of the string that did not match the regex ([Unmatched](SplitItem::Unmatched)) or the
105/// [Captures] object from a part that did match ([Captured](SplitItem::Captured)).
106#[derive(Debug)]
107pub enum SplitItem<'t> {
108 /// A string slice that did not match the regex separator
109 Unmatched(&'t str),
110 /// The [Captures] object resulting from a regex match
111 Captured(Captures<'t>),
112}
113
114impl<'t> Iterator for SplitCaptures<'_, 't> {
115 type Item = SplitItem<'t>;
116
117 /// Advances the iterator, returning the next unmatched slice or captured group.
118 ///
119 /// The iterator alternates between returning `SplitItem::Unmatched` and `SplitItem::Captured`,
120 /// starting and ending with `Unmatched` (unless the string is empty or fully matched).
121 fn next(&mut self) -> Option<SplitItem<'t>> {
122 // If we have pending captures from the previous iteration, return them now
123 if let Some(caps) = self.caps.take() {
124 return Some(SplitItem::Captured(caps));
125 }
126 // Find the next match using the internal captures iterator
127 match self.finder.next() {
128 // No more matches found
129 None => {
130 if self.last >= self.text.len() {
131 None
132 } else {
133 // If there's remaining text after the last match (or if the string was never matched), return it.
134 // Get the final unmatched slice
135 let s = &self.text[self.last..];
136 // Mark the end of the string as processed
137 self.last = self.text.len();
138 Some(SplitItem::Unmatched(s))
139 }
140 }
141 // A match was found
142 Some(caps) => {
143 // Get the match bounds
144 let m = caps.get(0).unwrap();
145 // Extract the text between the end of the last item and the start of this match
146 let unmatched = &self.text[self.last..m.start()];
147 // Update the position to the end of the current match
148 self.last = m.end();
149 // Store the captures to be returned in the next iteration
150 self.caps = Some(caps);
151 // Return the unmatched part before the captures
152 Some(SplitItem::Unmatched(unmatched))
153 }
154 }
155 }
156}