Skip to main content

textfsm_core/template/
rule.rs

1//! Rule and State definitions.
2
3use fancy_regex::Regex;
4use std::collections::HashMap;
5use std::sync::LazyLock;
6
7use crate::error::TemplateError;
8use crate::types::{LineOp, RecordOp, Transition};
9
10use super::value::normalize_pattern;
11
12// Splits a rule line into match pattern and action by finding the last `\s->` delimiter.
13// Note that this pattern enforces a space before the arrow. I suspect this is because
14// the arrow could be part of the regex. A space is used as a delimmter.
15static MATCH_ACTION: LazyLock<fancy_regex::Regex> =
16    LazyLock::new(|| fancy_regex::Regex::new(r"(?P<match>.*)(\s->(?P<action>.*))").unwrap());
17
18/// Matches LineOp[.RecordOp] [NewState]
19static ACTION_RE: LazyLock<fancy_regex::Regex> = LazyLock::new(|| {
20    fancy_regex::Regex::new(
21          r#"\s+(?P<ln_op>Continue|Next|Error)(\.(?P<rec_op>Clear|Clearall|Record|NoRecord))?(\s+(?P<new_state>\w+|".*"))?$"#
22      ).unwrap()
23});
24
25/// Matches RecordOp [NewState]
26static ACTION2_RE: LazyLock<fancy_regex::Regex> = LazyLock::new(|| {
27    fancy_regex::Regex::new(
28        r#"\s+(?P<rec_op>Clear|Clearall|Record|NoRecord)(\s+(?P<new_state>\w+|".*"))?$"#,
29    )
30    .unwrap()
31});
32
33/// Matches optional [NewState] only
34static ACTION3_RE: LazyLock<fancy_regex::Regex> =
35    LazyLock::new(|| fancy_regex::Regex::new(r#"(\s+(?P<new_state>\w+|".*"))?$"#).unwrap());
36
37/// A rule within a state.
38#[derive(Debug, Clone)]
39pub struct Rule {
40    /// Original match pattern from template (before variable substitution).
41    pub match_pattern: String,
42
43    /// Regex pattern after ${var} substitution.
44    pub regex_pattern: String,
45
46    /// Compiled regex for matching.
47    pub(crate) regex: Regex,
48
49    /// Line operator.
50    pub line_op: LineOp,
51
52    /// Record operator.
53    pub record_op: RecordOp,
54
55    /// State transition.
56    pub transition: Transition,
57
58    /// Line number in template (for error reporting).
59    pub line_num: usize,
60}
61
62/// Reserved words that cannot be state names.
63const RESERVED_LINE_OPS: &[&str] = &["Continue", "Next", "Error"];
64const RESERVED_RECORD_OPS: &[&str] = &["Clear", "Clearall", "Record", "NoRecord"];
65
66impl Rule {
67    /// Parse a rule line: `  ^pattern -> LineOp.RecordOp NewState`
68    pub fn parse(
69        line: &str,
70        line_num: usize,
71        value_templates: &HashMap<String, String>,
72    ) -> Result<Self, TemplateError> {
73        // Google's implementation enforces spacing with 1 or 2 white spaces or a tab
74        if !line.starts_with(" ^") && !line.starts_with("  ^") && !line.starts_with("\t^") {
75            return Err(TemplateError::InvalidValue {
76                line: line_num,
77                message: "Rule must be indented with 1 or spaces or a tab and start with '^'"
78                    .into(),
79            });
80        }
81        let trimmed = line.trim();
82
83        let (match_pattern, action_str) = match MATCH_ACTION.captures(trimmed) {
84            Ok(Some(caps)) => {
85                // If the regex matches, match and action are guaranteed to exist,
86                // so okay to call unwrap()
87                let pattern = caps.name("match").unwrap().as_str();
88                let action = caps.name("action").unwrap().as_str();
89                (pattern, Some(action))
90            }
91            _ => (trimmed, None),
92        };
93
94        // Substitute ${var} with named capture patterns
95        let regex_pattern = Self::substitute_variables(match_pattern, value_templates, line_num)?;
96
97        // Normalize regex for Python-to-Rust compatibility.
98        //
99        // Two normalizations are applied:
100        //
101        // 1. `\<` and `\>` → literal `<` and `>`. Python's `re` treats these as
102        //    literal characters (unrecognized escapes). fancy-regex treats them as
103        //    word boundary assertions, which silently changes matching behavior.
104        //
105        // 2. Quantifiers on lookaround groups (e.g. `(?<=x)+`) are stripped.
106        //    Python ignores them silently; fancy-regex rejects them as invalid.
107        let regex_pattern = normalize_pattern(&regex_pattern);
108
109        let regex = Regex::new(&regex_pattern).map_err(|e| TemplateError::InvalidRegex {
110            pattern: regex_pattern.clone(),
111            message: e.to_string(),
112        })?;
113
114        // Parse action if present
115        let (line_op, record_op, transition) = if let Some(action) = action_str {
116            Self::parse_action(action, line_num)?
117        } else {
118            (
119                LineOp::default(),
120                RecordOp::default(),
121                Transition::default(),
122            )
123        };
124
125        // Continue cannot have state transition
126        if line_op == LineOp::Continue && !matches!(transition, Transition::Stay) {
127            return Err(TemplateError::ContinueWithTransition(line_num));
128        }
129
130        Ok(Self {
131            match_pattern: match_pattern.to_string(),
132            regex_pattern,
133            regex,
134            line_op,
135            record_op,
136            transition,
137            line_num,
138        })
139    }
140
141    // Python also supports bare $VarName via string.Template, but all
142    // real-world templates use ${VarName}. We only support the braced form.
143    // Bare $ is ambiguos in practice becuase in regex $ means end-of-line
144    fn substitute_variables(
145        pattern: &str,
146        templates: &HashMap<String, String>,
147        line_num: usize,
148    ) -> Result<String, TemplateError> {
149        let mut result = String::with_capacity(pattern.len());
150        let mut rest = pattern;
151
152        while let Some(start) = rest.find("${") {
153            result.push_str(&rest[..start]);
154
155            let after_dollar = &rest[start + 2..];
156            let end = after_dollar
157                .find('}')
158                .ok_or_else(|| TemplateError::InvalidRule {
159                    line: line_num,
160                    message: "unclosed variable substitution".into(),
161                })?;
162            let var_name = &after_dollar[..end];
163            let template =
164                templates
165                    .get(var_name)
166                    .ok_or_else(|| TemplateError::InvalidSubstitution {
167                        line: line_num,
168                        message: format!("unknown variable '{}'", var_name),
169                    })?;
170
171            result.push_str(template);
172            rest = &after_dollar[end + 1..];
173        }
174
175        result.push_str(rest);
176        Ok(result)
177    }
178
179    fn parse_action(
180        action: &str,
181        line_num: usize,
182    ) -> Result<(LineOp, RecordOp, Transition), TemplateError> {
183        if action.is_empty() {
184            return Ok((
185                LineOp::default(),
186                RecordOp::default(),
187                Transition::default(),
188            ));
189        }
190
191        // Try ACTION_RE: LineOp[.RecordOp] [NewState]
192        // Then ACTION2_RE: RecordOp [NewState]
193        // Then ACTION3_RE: [NewState]
194        let caps = ACTION_RE
195            .captures(action)
196            .ok()
197            .flatten()
198            .or_else(|| ACTION2_RE.captures(action).ok().flatten())
199            .or_else(|| ACTION3_RE.captures(action).ok().flatten())
200            .ok_or_else(|| TemplateError::InvalidRule {
201                line: line_num,
202                message: format!("badly formatted action '{}'", action),
203            })?;
204
205        let line_op = match caps.name("ln_op").map(|m| m.as_str()) {
206            Some(s) => Self::parse_line_op(s, line_num)?,
207            None => LineOp::default(),
208        };
209
210        let record_op = match caps.name("rec_op").map(|m| m.as_str()) {
211            Some(s) => Self::parse_record_op(s, line_num)?,
212            None => RecordOp::default(),
213        };
214
215        let transition = match caps.name("new_state").map(|m| m.as_str()) {
216            Some(s) => Self::parse_transition(s),
217            None => Transition::default(),
218        };
219
220        Ok((line_op, record_op, transition))
221    }
222
223    fn try_parse_line_op(s: &str) -> Option<LineOp> {
224        match s {
225            "Next" => Some(LineOp::Next),
226            "Continue" => Some(LineOp::Continue),
227            "Error" => Some(LineOp::Error),
228            _ => None,
229        }
230    }
231
232    fn parse_line_op(s: &str, line_num: usize) -> Result<LineOp, TemplateError> {
233        Self::try_parse_line_op(s).ok_or_else(|| TemplateError::InvalidRule {
234            line: line_num,
235            message: format!("invalid line operator '{}'", s),
236        })
237    }
238
239    fn try_parse_record_op(s: &str) -> Option<RecordOp> {
240        match s {
241            "NoRecord" => Some(RecordOp::NoRecord),
242            "Record" => Some(RecordOp::Record),
243            "Clear" => Some(RecordOp::Clear),
244            "Clearall" => Some(RecordOp::ClearAll),
245            _ => None,
246        }
247    }
248
249    fn parse_record_op(s: &str, line_num: usize) -> Result<RecordOp, TemplateError> {
250        Self::try_parse_record_op(s).ok_or_else(|| TemplateError::InvalidRule {
251            line: line_num,
252            message: format!("invalid record operator '{}'", s),
253        })
254    }
255
256    fn parse_transition(s: &str) -> Transition {
257        match s {
258            "End" => Transition::End,
259            "EOF" => Transition::Eof,
260            _ => {
261                // Handle quoted error messages
262                if s.starts_with('"') && s.ends_with('"') {
263                    Transition::State(s[1..s.len() - 1].to_string())
264                } else {
265                    Transition::State(s.to_string())
266                }
267            }
268        }
269    }
270}
271
272/// A state containing rules.
273#[derive(Debug, Clone)]
274pub struct State {
275    /// Name of this state.
276    pub name: String,
277
278    /// Rules in this state, checked in order.
279    pub rules: Vec<Rule>,
280}
281
282impl State {
283    /// Create a new empty state.
284    pub fn new(name: String) -> Self {
285        Self {
286            name,
287            rules: Vec::new(),
288        }
289    }
290
291    /// Check if a name is valid for a state.
292    pub fn is_valid_name(name: &str) -> bool {
293        if name.is_empty() || name.len() > 48 {
294            return false;
295        }
296
297        // Must be alphanumeric/underscore
298        if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
299            return false;
300        }
301
302        // Cannot be a reserved word
303        if RESERVED_LINE_OPS.contains(&name) || RESERVED_RECORD_OPS.contains(&name) {
304            return false;
305        }
306
307        true
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn empty_templates() -> HashMap<String, String> {
316        HashMap::new()
317    }
318
319    fn sample_templates() -> HashMap<String, String> {
320        let mut m = HashMap::new();
321        m.insert("Interface".into(), "(?P<Interface>\\S+)".into());
322        m.insert("Status".into(), "(?P<Status>up|down)".into());
323        m
324    }
325
326    #[test]
327    fn test_parse_simple_rule() {
328        let r = Rule::parse(" ^Interface: (\\S+)", 1, &empty_templates()).unwrap();
329        assert_eq!(r.match_pattern, "^Interface: (\\S+)");
330        assert_eq!(r.line_op, LineOp::Next);
331        assert_eq!(r.record_op, RecordOp::NoRecord);
332        assert!(matches!(r.transition, Transition::Stay));
333    }
334
335    #[test]
336    fn test_parse_rule_with_record() {
337        let r = Rule::parse(" ^End -> Record", 1, &empty_templates()).unwrap();
338        assert_eq!(r.line_op, LineOp::Next);
339        assert_eq!(r.record_op, RecordOp::Record);
340    }
341
342    #[test]
343    fn test_parse_rule_with_compound_action() {
344        let r = Rule::parse(" ^Line -> Next.Record", 1, &empty_templates()).unwrap();
345        assert_eq!(r.line_op, LineOp::Next);
346        assert_eq!(r.record_op, RecordOp::Record);
347    }
348
349    #[test]
350    fn test_parse_rule_with_state_transition() {
351        let r = Rule::parse(" ^Start -> Continue.Record NextState", 1, &empty_templates());
352        // Continue with state transition should fail
353        assert!(matches!(r, Err(TemplateError::ContinueWithTransition(_))));
354    }
355
356    #[test]
357    fn test_parse_rule_with_variable_substitution() {
358        let templates = sample_templates();
359        let r = Rule::parse(" ^Interface: ${Interface} is ${Status}", 1, &templates).unwrap();
360        assert!(r.regex_pattern.contains("(?P<Interface>"));
361        assert!(r.regex_pattern.contains("(?P<Status>"));
362    }
363
364    #[test]
365    fn test_state_valid_names() {
366        assert!(State::is_valid_name("Start"));
367        assert!(State::is_valid_name("State1"));
368        assert!(State::is_valid_name("my_state"));
369        assert!(!State::is_valid_name("Continue")); // reserved
370        assert!(!State::is_valid_name("Record")); // reserved
371        assert!(!State::is_valid_name("")); // empty
372    }
373}