Skip to main content

_formatparse/parser/
format_parser.rs

1use crate::parser::matching::FieldCaptureSlices;
2use crate::result::ParseResult;
3use fancy_regex::Regex;
4use formatparse_core::count_capturing_groups;
5use formatparse_core::parser::MAX_FIELDS;
6use formatparse_core::{FieldSpec, FieldType};
7use pyo3::exceptions::PyValueError;
8use pyo3::prelude::*;
9use std::collections::HashMap;
10use std::sync::Arc;
11
12/// Field layout produced at pattern-compile time (narrow interface for matchers).
13pub(crate) struct CompiledFields {
14    pub field_specs: Vec<FieldSpec>,
15    pub field_names: Vec<Option<String>>,
16    pub normalized_names: Vec<Option<String>>,
17    pub custom_type_groups: Vec<usize>,
18    pub has_nested_dict_fields: Vec<bool>,
19    pub nested_parsers: Vec<Option<Arc<FormatParser>>>,
20    pub field_count: usize,
21}
22
23impl CompiledFields {
24    pub fn capture_slices(&self) -> FieldCaptureSlices<'_> {
25        FieldCaptureSlices {
26            field_specs: &self.field_specs,
27            field_names: &self.field_names,
28            normalized_names: &self.normalized_names,
29            custom_type_groups: &self.custom_type_groups,
30            has_nested_dict_fields: &self.has_nested_dict_fields,
31            nested_parsers: &self.nested_parsers,
32        }
33    }
34}
35
36#[pyclass(module = "_formatparse", from_py_object)]
37/// Compiled format pattern for parsing strings.
38///
39/// Construct with :func:`formatparse.compile` (or ``FormatParser(pattern, extra_types=...)`` in Python).
40///
41/// **Custom types:** converters passed as ``extra_types`` at compile time are stored
42/// and merged with any ``extra_types`` passed per call to ``parse`` or ``search``
43/// (per-call keys override stored keys).
44///
45/// **Pickling:** Only the pattern string is serialized. If the parser was built
46/// with ``extra_types``, those converters are **not** restored after unpickling;
47/// call ``compile(pattern, extra_types=...)`` again with the same mapping.
48pub struct FormatParser {
49    #[pyo3(get)]
50    // Note: This field is actually used in __getstate__, format getter, and accessed from Python.
51    // The dead_code warning is a false positive - the compiler doesn't recognize PyO3 getter usage.
52    pub pattern: String,
53    pub(crate) regex: Regex,
54    pub(crate) regex_str: String, // Store the regex string for _expression property
55    pub(crate) regex_case_insensitive: Option<Regex>,
56    pub(crate) search_regex: Regex, // Pre-compiled search regex (case-sensitive, no anchors)
57    pub(crate) search_regex_case_insensitive: Option<Regex>, // Pre-compiled search regex (case-insensitive, no anchors)
58    pub(crate) fields: CompiledFields,
59    #[allow(dead_code)]
60    pub(crate) name_mapping: std::collections::HashMap<String, String>, // Map normalized -> original
61    pub(crate) stored_extra_types: Option<HashMap<String, Py<PyAny>>>, // Store extra_types for use during conversion
62    pub(crate) allows_empty_default_string_match: bool, // True iff parse("") can use empty-field fast path (issue #16)
63}
64
65impl FormatParser {
66    /// Returns true when this parser matches a cache lookup: same normalized pattern and
67    /// the same `extra_types` fingerprint as [`crate::extract_extra_types_identity`].
68    /// Used after an LRU hit on the hash key to rule out collisions.
69    pub(crate) fn matches_pattern_cache_request(
70        &self,
71        py: Python<'_>,
72        normalized_pattern: &str,
73        extra_types: &Option<HashMap<String, Py<PyAny>>>,
74    ) -> bool {
75        if self.pattern != normalized_pattern {
76            return false;
77        }
78        let requested = crate::extract_extra_types_identity(py, extra_types);
79        let stored = crate::extract_extra_types_identity(py, &self.stored_extra_types);
80        requested == stored
81    }
82
83    pub fn new(pattern: &str) -> PyResult<Self> {
84        Self::new_with_extra_types(pattern, None)
85    }
86
87    pub fn new_with_extra_types(
88        pattern: &str,
89        extra_types: Option<HashMap<String, Py<PyAny>>>,
90    ) -> PyResult<Self> {
91        let pattern_owned = crate::pattern_normalize::prepare_compiled_pattern(pattern)?;
92        let custom_patterns = Python::attach(|py| -> PyResult<HashMap<String, String>> {
93            let mut patterns = HashMap::new();
94            if let Some(ref extra_types_map) = extra_types {
95                for (name, converter_obj) in extra_types_map {
96                    // Try to get the pattern attribute from the converter function
97                    let converter_ref = converter_obj.bind(py);
98                    if let Ok(pattern_attr) = converter_ref.getattr("pattern") {
99                        if let Ok(pattern_str) = pattern_attr.extract::<String>() {
100                            patterns.insert(name.clone(), pattern_str);
101                        }
102                    }
103                }
104            }
105            Ok(patterns)
106        })?;
107
108        let (
109            regex_str_with_anchors,
110            regex_str,
111            field_specs,
112            field_names,
113            normalized_names,
114            name_mapping,
115            allows_empty_default_string_match,
116        ) = formatparse_core::parser::pattern::parse_pattern(
117            &pattern_owned,
118            &custom_patterns,
119            true,
120            0,
121        )
122        .map_err(crate::parser::pattern::pattern_compile_error_to_py)?;
123
124        // Search/findall use a separate compile path without "empty delimited" `.*?` groups so
125        // unanchored matching does not stop early (e.g. `{}, {}` on "Hello, World").
126        let (regex_str_search_anchored, _, _, _, _, _, _) =
127            formatparse_core::parser::pattern::parse_pattern(
128                &pattern_owned,
129                &custom_patterns,
130                false,
131                0,
132            )
133            .map_err(crate::parser::pattern::pattern_compile_error_to_py)?;
134
135        // Validate field count
136        if field_specs.len() > MAX_FIELDS {
137            return Err(PyValueError::new_err(format!(
138                "Pattern contains {} fields, which exceeds the maximum allowed count of {}",
139                field_specs.len(),
140                MAX_FIELDS
141            )));
142        }
143
144        let nested_parsers: Vec<Option<Arc<FormatParser>>> = Python::attach(|py| -> PyResult<_> {
145            let mut out = Vec::with_capacity(field_specs.len());
146            for spec in &field_specs {
147                if matches!(spec.field_type, FieldType::Nested) {
148                    let sub = spec.nested_subpattern.as_ref().ok_or_else(|| {
149                        PyValueError::new_err("internal error: nested field missing subpattern")
150                    })?;
151                    let cloned_et = extra_types.as_ref().map(|m| {
152                        m.iter()
153                            .map(|(k, v)| (k.clone(), v.clone_ref(py)))
154                            .collect::<HashMap<_, _>>()
155                    });
156                    out.push(Some(Arc::new(FormatParser::new_with_extra_types(
157                        sub, cloned_et,
158                    )?)));
159                } else {
160                    out.push(None);
161                }
162            }
163            Ok(out)
164        })?;
165        // Pre-compute custom type validation results (pattern_groups per field)
166        // This avoids calling validate_custom_type_pattern for every match
167        let custom_type_groups = Python::attach(|py| -> PyResult<Vec<usize>> {
168            let mut groups = Vec::with_capacity(field_specs.len());
169            let empty_map = std::collections::HashMap::new();
170            let custom_converters = extra_types
171                .as_ref()
172                .map(|et| et as &HashMap<String, Py<PyAny>>)
173                .unwrap_or(&empty_map);
174
175            for spec in &field_specs {
176                let pattern_groups = if matches!(spec.field_type, FieldType::Nested) {
177                    spec.nested_regex_body
178                        .as_ref()
179                        .map(|b| count_capturing_groups(b))
180                        .unwrap_or(0)
181                } else if !custom_converters.is_empty() {
182                    crate::parser::matching::validate_custom_type_pattern(
183                        spec,
184                        custom_converters,
185                        py,
186                    )?
187                } else {
188                    0
189                };
190                groups.push(pattern_groups);
191            }
192            Ok(groups)
193        })?;
194
195        // Pre-compute which fields have nested dict names (contain '[')
196        // This avoids checking original_name.contains('[') in the hot path
197        let has_nested_dict_fields: Vec<bool> = field_names
198            .iter()
199            .map(|name_opt| name_opt.as_ref().map(|n| n.contains('[')).unwrap_or(false))
200            .collect();
201
202        // Build regex with DOTALL flag
203        let regex = formatparse_core::build_regex(&regex_str_with_anchors)
204            .map_err(crate::error::core_error_to_py_err)?;
205
206        let regex_search_anchored = formatparse_core::build_regex(&regex_str_search_anchored)
207            .map_err(crate::error::core_error_to_py_err)?;
208
209        // Build case-insensitive regex
210        let regex_case_insensitive =
211            formatparse_core::build_case_insensitive_regex(&regex_str_with_anchors);
212
213        // Pre-compile search regex variants (without anchors)
214        let search_regex =
215            formatparse_core::build_search_regex(regex_search_anchored.as_str(), true)
216                .map_err(crate::error::core_error_to_py_err)?;
217        let search_regex_case_insensitive =
218            formatparse_core::build_search_regex(regex_search_anchored.as_str(), false).ok();
219
220        let field_count = field_specs.len();
221        Ok(Self {
222            pattern: pattern_owned,
223            regex,
224            regex_str,
225            regex_case_insensitive,
226            search_regex,
227            search_regex_case_insensitive,
228            fields: CompiledFields {
229                field_specs,
230                field_names,
231                normalized_names,
232                custom_type_groups,
233                has_nested_dict_fields,
234                nested_parsers,
235                field_count,
236            },
237            name_mapping,
238            stored_extra_types: extra_types,
239            allows_empty_default_string_match,
240        })
241    }
242
243    pub fn search_pattern(
244        &self,
245        string: &str,
246        case_sensitive: bool,
247        extra_types: Option<HashMap<String, Py<PyAny>>>,
248        evaluate_result: bool,
249    ) -> PyResult<Option<Py<PyAny>>> {
250        // Use pre-compiled search regex
251        let search_regex = if case_sensitive {
252            &self.search_regex
253        } else {
254            self.search_regex_case_insensitive
255                .as_ref()
256                .unwrap_or(&self.search_regex)
257        };
258
259        Python::attach(|py| {
260            if search_regex
261                .captures(string)
262                .map_err(crate::error::fancy_regex_match_error)?
263                .is_some()
264            {
265                let extra_types_ref = if let Some(ref et) = extra_types {
266                    et
267                } else {
268                    &HashMap::new()
269                };
270                let f = &self.fields;
271                return crate::parser::matching::match_with_regex(
272                    search_regex,
273                    &crate::parser::matching::RegexMatchContext {
274                        string,
275                        pattern: &self.pattern,
276                        field_specs: &f.field_specs,
277                        field_names: &f.field_names,
278                        normalized_names: &f.normalized_names,
279                        nested_parsers: &f.nested_parsers,
280                        py,
281                        custom_converters: extra_types_ref,
282                        evaluate_result,
283                    },
284                );
285            }
286            Ok(None)
287        })
288    }
289
290    pub(crate) fn parse_internal(
291        &self,
292        string: &str,
293        case_sensitive: bool,
294        extra_types: Option<&HashMap<String, Py<PyAny>>>,
295        evaluate_result: bool,
296    ) -> PyResult<Option<Py<PyAny>>> {
297        Python::attach(|py| {
298            let empty = HashMap::<String, Py<PyAny>>::new();
299            let extra_types_ref = extra_types.unwrap_or(&empty);
300
301            // Use existing regex (custom type handling is done in convert_value)
302            let regex = if case_sensitive {
303                &self.regex
304            } else {
305                self.regex_case_insensitive.as_ref().unwrap_or(&self.regex)
306            };
307
308            let f = &self.fields;
309            if string.is_empty()
310                && self.allows_empty_default_string_match
311                && !f.field_specs.is_empty()
312            {
313                if let Some(obj) = crate::parser::matching::match_empty_default_string_parse(
314                    &self.pattern,
315                    &f.field_specs,
316                    &f.field_names,
317                    &f.normalized_names,
318                    py,
319                    extra_types_ref,
320                    evaluate_result,
321                )? {
322                    return Ok(Some(obj));
323                }
324            }
325
326            crate::parser::matching::match_with_regex(
327                regex,
328                &crate::parser::matching::RegexMatchContext {
329                    string,
330                    pattern: &self.pattern,
331                    field_specs: &f.field_specs,
332                    field_names: &f.field_names,
333                    normalized_names: &f.normalized_names,
334                    nested_parsers: &f.nested_parsers,
335                    py,
336                    custom_converters: extra_types_ref,
337                    evaluate_result,
338                },
339            )
340        })
341    }
342
343    /// Get the search regex for a given case sensitivity
344    pub(crate) fn get_search_regex(&self, case_sensitive: bool) -> &Regex {
345        if case_sensitive {
346            &self.search_regex
347        } else {
348            self.search_regex_case_insensitive
349                .as_ref()
350                .unwrap_or(&self.search_regex)
351        }
352    }
353
354    /// Parse one capture slice with this parser's pattern (nested fields, issue #12).
355    pub(crate) fn parse_nested_capture(
356        &self,
357        py: Python<'_>,
358        slice: &str,
359        custom_converters: &HashMap<String, Py<PyAny>>,
360    ) -> PyResult<Option<Py<ParseResult>>> {
361        let mut merged = HashMap::new();
362        if let Some(ref stored) = self.stored_extra_types {
363            for (k, v) in stored {
364                merged.insert(k.clone(), v.clone_ref(py));
365            }
366        }
367        for (k, v) in custom_converters {
368            merged.insert(k.clone(), v.clone_ref(py));
369        }
370        let opt = self.parse_internal(slice, true, Some(&merged), true)?;
371        let Some(obj) = opt else {
372            return Ok(None);
373        };
374        let bound = obj.bind(py);
375        let pr = bound.cast::<ParseResult>().map_err(|_| {
376            PyValueError::new_err("internal error: nested parse did not return ParseResult")
377        })?;
378        Ok(Some(pr.clone().unbind()))
379    }
380}
381
382impl Clone for FormatParser {
383    fn clone(&self) -> Self {
384        Python::attach(|py| Self {
385            pattern: self.pattern.clone(),
386            regex: self.regex.clone(),
387            regex_str: self.regex_str.clone(),
388            regex_case_insensitive: self.regex_case_insensitive.clone(),
389            search_regex: self.search_regex.clone(),
390            search_regex_case_insensitive: self.search_regex_case_insensitive.clone(),
391            fields: CompiledFields {
392                field_specs: self.fields.field_specs.clone(),
393                field_names: self.fields.field_names.clone(),
394                normalized_names: self.fields.normalized_names.clone(),
395                custom_type_groups: self.fields.custom_type_groups.clone(),
396                has_nested_dict_fields: self.fields.has_nested_dict_fields.clone(),
397                nested_parsers: self.fields.nested_parsers.clone(),
398                field_count: self.fields.field_count,
399            },
400            name_mapping: self.name_mapping.clone(),
401            stored_extra_types: self.stored_extra_types.as_ref().map(|m| {
402                m.iter()
403                    .map(|(k, v)| (k.clone(), v.clone_ref(py)))
404                    .collect()
405            }),
406            allows_empty_default_string_match: self.allows_empty_default_string_match,
407        })
408    }
409}