formatparse-pyo3 0.8.5

PyO3 bindings for formatparse (native _formatparse extension; use PyPI for Python installs)
Documentation
use crate::error;
use crate::result::ParseResult;
use crate::unicode_offsets::byte_to_char_index;
use crate::types::FieldSpec;
use pyo3::prelude::*;
use pyo3::IntoPyObjectExt;
use std::collections::HashMap;

/// Owned components for constructing a [`Match`].
pub struct MatchInit {
    pub pattern: String,
    pub field_specs: Vec<FieldSpec>,
    pub field_names: Vec<Option<String>>,
    pub normalized_names: Vec<Option<String>>,
    pub captures: Vec<Option<String>>,
    pub named_captures: HashMap<String, String>,
    pub span: (usize, usize),
    pub field_spans: HashMap<String, (usize, usize)>,
}

/// Match object that stores raw regex captures without type conversion
#[pyclass(skip_from_py_object)]
#[derive(Clone)]
pub struct Match {
    #[pyo3(get)]
    pattern: String,
    field_specs: Vec<FieldSpec>,
    field_names: Vec<Option<String>>,
    normalized_names: Vec<Option<String>>,
    captures: Vec<Option<String>>,           // Raw captured strings
    named_captures: HashMap<String, String>, // Raw captured strings by normalized name
    #[pyo3(get)]
    pub span: (usize, usize),
    field_spans: HashMap<String, (usize, usize)>, // Spans by original field name
}

#[pymethods]
impl Match {
    /// Evaluate the match to convert captured values to their types
    #[pyo3(signature = (*, extra_types=None))]
    fn evaluate_result(
        &self,
        py: Python,
        extra_types: Option<HashMap<String, Py<PyAny>>>,
    ) -> PyResult<Py<PyAny>> {
        let custom_converters = extra_types.unwrap_or_default();
        let mut fixed = Vec::new();
        let mut named: HashMap<String, Py<PyAny>> = HashMap::new();

        // Apply type conversions using stored field specs
        for (i, spec) in self.field_specs.iter().enumerate() {
            let value_str =
                if let Some(norm_name) = self.normalized_names.get(i).and_then(|n| n.as_ref()) {
                    self.named_captures
                        .get(norm_name.as_str())
                        .map(|s| s.as_str())
                } else {
                    self.captures
                        .get(i)
                        .and_then(|s| s.as_ref())
                        .map(|s| s.as_str())
                };

            if let Some(value_str) = value_str {
                if !crate::types::conversion::validate_alignment_precision_for_capture(
                    spec, value_str,
                ) {
                    return Err(pyo3::exceptions::PyValueError::new_err(
                        "capture failed alignment/precision validation",
                    ));
                }
                let converted = crate::types::conversion::convert_value(
                    spec,
                    value_str,
                    py,
                    &custom_converters,
                )?;

                if let Some(original_name) = self.field_names.get(i).and_then(|n| n.as_ref()) {
                    // Check if this is a dict-style field name (contains [])
                    if original_name.contains('[') {
                        // Parse the path and insert into nested dict structure
                        let path = crate::parser::parse_field_path(original_name);
                        crate::parser::matching::insert_nested_dict(
                            &mut named, &path, converted, py,
                        )?;
                    } else {
                        // Regular flat field name
                        // Check for repeated field names - values must match
                        if let Some(existing_value) = named.get(original_name.as_str()) {
                            let existing_obj = existing_value.clone_ref(py);
                            let converted_obj = converted.clone_ref(py);
                            let are_equal: bool =
                                existing_obj.bind(py).eq(converted_obj.bind(py))?;
                            if !are_equal {
                                return Err(error::repeated_name_error(original_name));
                            }
                        }
                        named.insert(original_name.clone(), converted);
                    }
                } else {
                    fixed.push(converted);
                }
            }
        }

        let parse_result =
            ParseResult::new_with_spans(fixed, named, self.span, self.field_spans.clone());
        // Py::new() is already optimized when GIL is held
        Py::new(py, parse_result)?.into_py_any(py)
    }
}

impl Match {
    pub fn new(init: MatchInit) -> Self {
        Self {
            pattern: init.pattern,
            field_specs: init.field_specs,
            field_names: init.field_names,
            normalized_names: init.normalized_names,
            captures: init.captures,
            named_captures: init.named_captures,
            span: init.span,
            field_spans: init.field_spans,
        }
    }

    pub fn with_offset(mut self, offset: usize) -> Self {
        self.span = (self.span.0 + offset, self.span.1 + offset);
        self.field_spans = self
            .field_spans
            .into_iter()
            .map(|(k, (start, end))| (k, (start + offset, end + offset)))
            .collect();
        self
    }

    pub fn spans_as_char_indices(mut self, haystack: &str) -> Self {
        self.span = (
            byte_to_char_index(haystack, self.span.0),
            byte_to_char_index(haystack, self.span.1),
        );
        self.field_spans = self
            .field_spans
            .into_iter()
            .map(|(k, (start, end))| {
                (
                    k,
                    (
                        byte_to_char_index(haystack, start),
                        byte_to_char_index(haystack, end),
                    ),
                )
            })
            .collect();
        self
    }
}