Skip to main content

_formatparse/parser/
findall_iter.rs

1use crate::parser::format_parser::FormatParser;
2use crate::parser::matching::{match_with_captures, match_with_captures_raw, CapturedMatchContext};
3use formatparse_core::FieldType;
4use pyo3::prelude::*;
5use pyo3::IntoPyObjectExt;
6use std::collections::HashMap;
7use std::sync::Arc;
8
9/// Incremental iterator over ``findall``-style matches (issue #13 MVP).
10///
11/// Yields one match at a time using the same non-overlapping scan as :func:`findall`,
12/// without building a full :class:`Results` or list first. This lowers peak memory when
13/// you only consume matches sequentially. It does **not** implement arbitrary chunked
14/// file I/O or cross-chunk backtracking; use line-by-line reads only when your pattern
15/// cannot span physical line breaks.
16#[pyclass(module = "_formatparse", name = "FindallIter")]
17pub struct FindallIter {
18    parser: Arc<FormatParser>,
19    haystack: String,
20    case_sensitive: bool,
21    evaluate_result: bool,
22    fast_path: bool,
23    extra_types: HashMap<String, Py<PyAny>>,
24    max_matches: Option<usize>,
25    matches_emitted: usize,
26    last_end: usize,
27    search_pos: usize,
28}
29
30impl FindallIter {
31    pub fn new(
32        parser: Arc<FormatParser>,
33        haystack: String,
34        case_sensitive: bool,
35        evaluate_result: bool,
36        extra_types: HashMap<String, Py<PyAny>>,
37        max_matches: Option<usize>,
38    ) -> Self {
39        let has_custom_converters = !extra_types.is_empty();
40        let has_nested_dicts = parser.fields.has_nested_dict_fields.iter().any(|&b| b);
41        let has_nested_format_fields = parser
42            .fields
43            .field_specs
44            .iter()
45            .any(|s| matches!(s.field_type, FieldType::Nested));
46        let fast_path = !has_custom_converters
47            && evaluate_result
48            && !has_nested_dicts
49            && !has_nested_format_fields;
50        Self {
51            parser,
52            haystack,
53            case_sensitive,
54            evaluate_result,
55            fast_path,
56            extra_types,
57            max_matches,
58            matches_emitted: 0,
59            last_end: 0,
60            search_pos: 0,
61        }
62    }
63
64    fn at_match_limit(&self) -> bool {
65        self.max_matches
66            .map(|m| self.matches_emitted >= m)
67            .unwrap_or(false)
68    }
69}
70
71#[pymethods]
72impl FindallIter {
73    fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
74        slf
75    }
76
77    fn __next__(mut slf: PyRefMut<'_, Self>, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
78        if slf.at_match_limit() {
79            return Ok(None);
80        }
81        if slf.fast_path {
82            loop {
83                if slf.at_match_limit() || slf.search_pos > slf.haystack.len() {
84                    return Ok(None);
85                }
86                let search_regex = slf.parser.get_search_regex(slf.case_sensitive);
87                let Some(caps) = search_regex
88                    .captures_from_pos(&slf.haystack, slf.search_pos)
89                    .map_err(crate::error::fancy_regex_match_error)?
90                else {
91                    return Ok(None);
92                };
93                let Some(m0) = caps.get(0) else {
94                    return Err(pyo3::exceptions::PyRuntimeError::new_err(
95                        "regex match missing capture group 0",
96                    ));
97                };
98                let match_start = m0.start();
99                let match_end = m0.end();
100
101                if match_start < slf.last_end {
102                    slf.search_pos = slf.last_end.max(match_start.saturating_add(1));
103                    continue;
104                }
105
106                let slices = slf.parser.fields.capture_slices();
107
108                match match_with_captures_raw(&caps, &slf.haystack, match_start, &slices) {
109                    Ok(Some(raw_data)) => {
110                        slf.last_end = match_end;
111                        if match_start == match_end {
112                            slf.last_end += 1;
113                        }
114                        slf.search_pos = slf.last_end;
115                        slf.matches_emitted += 1;
116                        let pr = raw_data.to_parse_result(py)?;
117                        return Ok(Some(pr.into_py_any(py)?));
118                    }
119                    Ok(None) => {
120                        slf.search_pos = match_start.saturating_add(1);
121                        continue;
122                    }
123                    Err(_) => {
124                        slf.fast_path = false;
125                        if slf.last_end == 0 {
126                            slf.search_pos = 0;
127                        }
128                        break;
129                    }
130                }
131            }
132        }
133
134        loop {
135            if slf.at_match_limit() || slf.search_pos > slf.haystack.len() {
136                return Ok(None);
137            }
138            let search_regex = slf.parser.get_search_regex(slf.case_sensitive);
139            let Some(caps) = search_regex
140                .captures_from_pos(&slf.haystack, slf.search_pos)
141                .map_err(crate::error::fancy_regex_match_error)?
142            else {
143                return Ok(None);
144            };
145            let Some(m0) = caps.get(0) else {
146                return Err(pyo3::exceptions::PyRuntimeError::new_err(
147                    "regex match missing capture group 0",
148                ));
149            };
150            let match_start = m0.start();
151            let match_end = m0.end();
152
153            if match_start < slf.last_end {
154                slf.search_pos = slf.last_end.max(match_start.saturating_add(1));
155                continue;
156            }
157
158            let ctx = CapturedMatchContext {
159                pattern: &slf.parser.pattern,
160                fields: slf.parser.fields.capture_slices(),
161                py,
162                custom_converters: &slf.extra_types,
163                evaluate_result: slf.evaluate_result,
164            };
165
166            match match_with_captures(&caps, &ctx)? {
167                Some(result) => {
168                    slf.last_end = match_end;
169                    if match_start == match_end {
170                        slf.last_end += 1;
171                    }
172                    slf.search_pos = slf.last_end;
173                    slf.matches_emitted += 1;
174                    return Ok(Some(result));
175                }
176                None => {
177                    slf.search_pos = match_start.saturating_add(1);
178                    continue;
179                }
180            }
181        }
182    }
183
184    fn __repr__(&self) -> String {
185        "<FindallIter>".to_string()
186    }
187}