textfsm-core 0.3.1

Core parsing library for TextFSM template-based state machine
Documentation
//! FSM parser for processing input text.

mod state;

pub use state::ValueState;

use std::collections::HashMap;

use crate::error::ParseError;
use crate::template::Template;
use crate::types::{LineOp, RecordOp, Transition, Value};

/// Parser for processing text with a compiled template.
pub struct Parser<'t> {
    /// Reference to the compiled template.
    template: &'t Template,

    /// Current state name.
    current_state: String,

    /// Runtime state for each value.
    value_states: Vec<ValueState>,

    /// Accumulated results.
    results: Vec<Vec<Value>>,
}

impl<'t> Parser<'t> {
    /// Create a new parser for the given template.
    pub fn new(template: &'t Template) -> Self {
        let value_states = template
            .values()
            .iter()
            .enumerate()
            .map(|(idx, def)| ValueState::new(def.clone(), idx))
            .collect();

        Self {
            template,
            current_state: "Start".to_string(),
            value_states,
            results: Vec::new(),
        }
    }

    /// Reset the parser state for reuse.
    pub fn reset(&mut self) {
        self.current_state = "Start".to_string();
        self.results.clear();
        for vs in &mut self.value_states {
            vs.clear_all();
        }
    }

    /// Parse text and return list of records.
    pub fn parse_text(&mut self, text: &str) -> Result<Vec<Vec<Value>>, ParseError> {
        self.parse_text_with_eof(text, true)
    }

    /// Parse text with explicit EOF control.
    pub fn parse_text_with_eof(
        &mut self,
        text: &str,
        eof: bool,
    ) -> Result<Vec<Vec<Value>>, ParseError> {
        for line in text.lines() {
            self.process_line(line)?;

            if self.current_state == "End" || self.current_state == "EOF" {
                break;
            }
        }

        // Implicit EOF behavior
        if self.current_state != "End"
            && self.template.get_state("EOF").is_none()
            && eof
        {
            self.append_record();
        }

        Ok(std::mem::take(&mut self.results))
    }

    /// Parse text and return results as list of dicts.
    pub fn parse_text_to_dicts(
        &mut self,
        text: &str,
    ) -> Result<Vec<HashMap<String, String>>, ParseError> {
        let results = self.parse_text(text)?;
        let header = self.template.header();

        Ok(results
            .into_iter()
            .map(|row| {
                header
                    .iter()
                    .zip(row)
                    .map(|(k, v)| (k.to_lowercase(), v.as_string()))
                    .collect()
            })
            .collect())
    }

    /// Parse text and deserialize results into typed structs.
    ///
    /// This method parses the input text and deserializes each record directly
    /// into the specified type `T` using serde.
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// use serde::Deserialize;
    ///
    /// #[derive(Deserialize)]
    /// struct Interface {
    ///     interface: String,
    ///     status: String,
    /// }
    ///
    /// let interfaces: Vec<Interface> = parser.parse_text_into(input)?;
    /// ```
    #[cfg(feature = "serde")]
    pub fn parse_text_into<T>(&mut self, text: &str) -> Result<Vec<T>, ParseError>
    where
        T: serde::de::DeserializeOwned,
    {
        let results = self.parse_text(text)?;

        // Pre-compute lowercased headers once, not per-record
        let header: Vec<String> = self
            .template
            .header()
            .iter()
            .map(|s| s.to_lowercase())
            .collect();

        results
            .into_iter()
            .map(|record| {
                // Use optimized path that borrows pre-lowercased headers
                crate::de::from_record_borrowed(&header, record)
                    .map_err(|e| ParseError::DeserializeError(e.to_string()))
            })
            .collect()
    }

    /// Process a single input line.
    fn process_line(&mut self, line: &str) -> Result<(), ParseError> {
        let state = match self.template.get_state(&self.current_state) {
            Some(s) => s,
            None => return Ok(()), // End/EOF state
        };

        for rule in &state.rules {
            if let Ok(Some(captures)) = rule.regex.captures(line) {
                // Extract matched values.
                //
                // For each value, if the named group matched, assign the captured
                // text. If the group exists in the rule but didn't capture (optional
                // group), call assign_none() to clear the value — matching Python's
                // behavior where groupdict() yields None and AssignVar(None) is called.
                for vs in &mut self.value_states {
                    if let Some(matched) = captures.name(&vs.def.name) {
                        vs.assign(matched.as_str().to_string(), &mut self.results);
                    } else if rule.regex_pattern.contains(&format!("(?P<{}>", vs.def.name)) {
                        vs.assign_none();
                    }
                }

                // Apply record operation
                match rule.record_op {
                    RecordOp::Record => self.append_record(),
                    RecordOp::Clear => self.clear_values(),
                    RecordOp::ClearAll => self.clear_all_values(),
                    RecordOp::NoRecord => {}
                }

                // Apply line operation
                match rule.line_op {
                    LineOp::Error => {
                        let message = match &rule.transition {
                            Transition::State(msg) => msg.clone(),
                            _ => "state error".into(),
                        };
                        return Err(ParseError::RuleError {
                            rule_line: rule.line_num,
                            message,
                        });
                    }
                    LineOp::Continue => {
                        // Don't break, continue checking rules
                        continue;
                    }
                    LineOp::Next => {
                        // Apply state transition and break
                        self.apply_transition(&rule.transition);
                        break;
                    }
                }
            }
        }

        Ok(())
    }

    /// Append current record to results.
    fn append_record(&mut self) {
        // Check Required constraints
        for vs in &self.value_states {
            if !vs.satisfies_required() {
                // Skip record
                self.clear_values();
                return;
            }
        }

        // Build record
        let record: Vec<Value> = self
            .value_states
            .iter_mut()
            .map(|vs| vs.take_for_record())
            .collect();

        // Don't record if all empty
        if record.iter().all(|v| v.is_empty()) {
            return;
        }

        self.results.push(record);
        self.clear_values();
    }

    /// Clear non-Filldown values.
    fn clear_values(&mut self) {
        for vs in &mut self.value_states {
            vs.clear();
        }
    }

    /// Clear all values including Filldown.
    fn clear_all_values(&mut self) {
        for vs in &mut self.value_states {
            vs.clear_all();
        }
    }

    /// Apply a state transition.
    fn apply_transition(&mut self, transition: &Transition) {
        match transition {
            Transition::Stay => {}
            Transition::State(name) => {
                self.current_state = name.clone();
            }
            Transition::End => {
                self.current_state = "End".to_string();
            }
            Transition::Eof => {
                self.current_state = "EOF".to_string();
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::template::Template;

    #[test]
    fn test_simple_parse() {
        let template_str = r#"Value Interface (\S+)
Value Status (up|down)

Start
  ^Interface: ${Interface} is ${Status} -> Record
"#;

        let template = Template::parse_str(template_str).unwrap();
        let mut parser = template.parser();

        let input = "Interface: eth0 is up\nInterface: eth1 is down\n";
        let results = parser.parse_text(input).unwrap();

        assert_eq!(results.len(), 2);
        assert_eq!(results[0][0], Value::Single("eth0".into()));
        assert_eq!(results[0][1], Value::Single("up".into()));
        assert_eq!(results[1][0], Value::Single("eth1".into()));
        assert_eq!(results[1][1], Value::Single("down".into()));
    }

    #[test]
    fn test_parse_to_dicts() {
        let template_str = r#"Value Name (\S+)
Value Age (\d+)

Start
  ^Name: ${Name}, Age: ${Age} -> Record
"#;

        let template = Template::parse_str(template_str).unwrap();
        let mut parser = template.parser();

        let input = "Name: Alice, Age: 30\nName: Bob, Age: 25\n";
        let results = parser.parse_text_to_dicts(input).unwrap();

        assert_eq!(results.len(), 2);
        assert_eq!(results[0].get("name"), Some(&"Alice".to_string()));
        assert_eq!(results[0].get("age"), Some(&"30".to_string()));
    }

    #[test]
    fn test_required_skips_empty() {
        let template_str = r#"Value Required Name (\S+)
Value Optional (\S+)

Start
  ^Name: ${Name}
  ^Optional: ${Optional}
  ^--- -> Record
"#;

        let template = Template::parse_str(template_str).unwrap();
        let mut parser = template.parser();

        // Record with no Name should be skipped
        let input = "Optional: foo\n---\nName: bar\n---\n";
        let results = parser.parse_text(input).unwrap();

        assert_eq!(results.len(), 1);
        assert_eq!(results[0][0], Value::Single("bar".into()));
    }

    #[test]
    fn test_filldown_clears_when_optional_group_unmatched() {
        // Simulates the bug with templates like arista_eos_show_ip_bgp_detail
        // where a Filldown value with an optional capture should be cleared
        // when the group doesn't participate in a match.
        let template_str = r#"Value Filldown PREFIX (\S+)
Value Filldown PREFIX_LENGTH (\d+)

Start
  ^Prefix:\s+${PREFIX}\s*(len:\s*${PREFIX_LENGTH})?
  ^--- -> Record

EOF
"#;

        let template = Template::parse_str(template_str).unwrap();
        let mut parser = template.parser();

        // First record: both groups match (PREFIX and PREFIX_LENGTH)
        // Second record: PREFIX matches but PREFIX_LENGTH optional group doesn't
        let input = "\
Prefix: 10.0.0.0 len: 24
---
Prefix: 192.168.1.0
---
";
        let results = parser.parse_text(input).unwrap();

        assert_eq!(results.len(), 2);
        // First record: both values captured
        assert_eq!(results[0][0], Value::Single("10.0.0.0".into()));
        assert_eq!(results[0][1], Value::Single("24".into()));
        // Second record: PREFIX_LENGTH should be Empty (not stale "24" from Filldown)
        assert_eq!(results[1][0], Value::Single("192.168.1.0".into()));
        assert_eq!(results[1][1], Value::Empty);
    }

    #[test]
    fn test_rule_with_escaped_angle_brackets() {
        // Templates like fortinet_get_system_ha_status use \< in rules
        let template_str = r#"Value DateTime (\S+)

Start
  ^\s*<${DateTime}> -> Record
"#;

        let template = Template::parse_str(template_str).unwrap();
        let mut parser = template.parser();

        let input = "  <2020/11/18> some text\n";
        let results = parser.parse_text(input).unwrap();

        assert_eq!(results.len(), 1);
        assert_eq!(results[0][0], Value::Single("2020/11/18".into()));
    }
}