Skip to main content

_formatparse/
lib.rs

1//! formatparse-pyo3: PyO3 bindings for formatparse
2//!
3//! formatparse-pyo3 provides Python bindings for the formatparse-core library.
4
5use pyo3::exceptions::PyNotImplementedError;
6use pyo3::prelude::*;
7use pyo3::types::{PyDict, PyList};
8use pyo3::IntoPyObjectExt;
9use std::collections::HashMap;
10
11mod datetime;
12mod error;
13mod match_rs;
14mod parser;
15mod pattern_cache;
16mod pattern_normalize;
17mod result;
18mod results;
19mod types;
20
21pub(crate) use pattern_cache::extract_extra_types_identity;
22use pattern_cache::get_or_create_parser;
23
24pub use datetime::FixedTzOffset;
25pub use parser::{FindallIter, Format, FormatParser};
26pub use result::*;
27pub use results::Results;
28pub use types::conversion::*;
29// Core types come from formatparse-core
30pub use formatparse_core::strftime_to_regex;
31pub use formatparse_core::{FieldSpec, FieldType};
32pub use match_rs::Match;
33
34pub use error::PatternParseMismatch;
35
36/// Parse a string using a format specification
37#[pyfunction]
38#[pyo3(signature = (pattern, string, extra_types=None, case_sensitive=false, evaluate_result=true))]
39fn parse(
40    pattern: &str,
41    string: &str,
42    extra_types: Option<HashMap<String, Py<PyAny>>>,
43    case_sensitive: bool,
44    evaluate_result: bool,
45) -> PyResult<Option<Py<PyAny>>> {
46    // Validate input lengths
47    formatparse_core::validate_input_length(string)
48        .map_err(pyo3::exceptions::PyValueError::new_err)?;
49
50    // Check for null bytes in inputs
51    if string.contains('\0') {
52        return Err(pyo3::exceptions::PyValueError::new_err(
53            "Input string contains null byte",
54        ));
55    }
56
57    // Use cached parser if available
58    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
59        extra_types.as_ref().map(|et| {
60            et.iter()
61                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
62                .collect()
63        })
64    });
65    match get_or_create_parser(pattern, extra_types_cloned) {
66        Ok(parser) => parser.parse_internal(
67            string,
68            case_sensitive,
69            extra_types.as_ref(),
70            evaluate_result,
71        ),
72        Err(e) => Python::attach(|py| {
73            if e.is_instance_of::<PyNotImplementedError>(py) {
74                return Err(e);
75            }
76            if e.is_instance_of::<crate::error::PatternParseMismatch>(py) {
77                return Ok(None);
78            }
79            Err(e)
80        }),
81    }
82}
83
84/// Parse many strings with the same pattern, compiling the pattern once.
85///
86/// Each input string uses the same semantics as `parse` (including
87/// `extra_types`, `case_sensitive`, and `evaluate_result`). Non-matches
88/// become Python `None` at that index in the returned list.
89#[pyfunction]
90#[pyo3(signature = (pattern, strings, extra_types=None, case_sensitive=false, evaluate_result=true))]
91fn parse_batch(
92    pattern: &str,
93    strings: Vec<String>,
94    extra_types: Option<HashMap<String, Py<PyAny>>>,
95    case_sensitive: bool,
96    evaluate_result: bool,
97) -> PyResult<Py<PyAny>> {
98    for s in &strings {
99        formatparse_core::validate_input_length(s)
100            .map_err(pyo3::exceptions::PyValueError::new_err)?;
101        if s.contains('\0') {
102            return Err(pyo3::exceptions::PyValueError::new_err(
103                "Input string contains null byte",
104            ));
105        }
106    }
107
108    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
109        extra_types.as_ref().map(|et| {
110            et.iter()
111                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
112                .collect()
113        })
114    });
115
116    let parser = match get_or_create_parser(pattern, extra_types_cloned) {
117        Ok(p) => p,
118        Err(e) => {
119            return Python::attach(|py| -> PyResult<Py<PyAny>> {
120                if e.is_instance_of::<PyNotImplementedError>(py) {
121                    return Err(e);
122                }
123                if e.is_instance_of::<crate::error::PatternParseMismatch>(py) {
124                    let none_obj = py.None().into_py_any(py)?;
125                    let mut out: Vec<Py<PyAny>> = Vec::with_capacity(strings.len());
126                    for _ in 0..strings.len() {
127                        out.push(none_obj.clone_ref(py));
128                    }
129                    let items: Vec<_> = out.iter().map(|o| o.bind(py)).collect();
130                    return PyList::new(py, items)?.into_py_any(py);
131                }
132                Err(e)
133            });
134        }
135    };
136
137    Python::attach(|py| -> PyResult<Py<PyAny>> {
138        let mut out: Vec<Py<PyAny>> = Vec::with_capacity(strings.len());
139        for s in &strings {
140            match parser.parse_internal(s, case_sensitive, extra_types.as_ref(), evaluate_result)? {
141                Some(obj) => out.push(obj),
142                None => out.push(py.None().into_py_any(py)?),
143            }
144        }
145        let items: Vec<_> = out.iter().map(|o| o.bind(py)).collect();
146        PyList::new(py, items)?.into_py_any(py)
147    })
148}
149
150/// Search for a pattern in a string
151#[pyfunction]
152#[pyo3(signature = (pattern, string, pos=0, endpos=None, extra_types=None, case_sensitive=true, evaluate_result=true))]
153fn search(
154    pattern: &str,
155    string: &str,
156    pos: usize,
157    endpos: Option<usize>,
158    extra_types: Option<HashMap<String, Py<PyAny>>>,
159    case_sensitive: bool,
160    evaluate_result: bool,
161) -> PyResult<Option<Py<PyAny>>> {
162    // Validate pos parameter
163    if pos > string.len() {
164        return Ok(None);
165    }
166
167    // Validate endpos parameter
168    let end = endpos.unwrap_or(string.len());
169    if end > string.len() {
170        return Ok(None);
171    }
172    if end < pos {
173        return Ok(None);
174    }
175
176    // Validate input lengths
177    formatparse_core::validate_input_length(string)
178        .map_err(pyo3::exceptions::PyValueError::new_err)?;
179
180    // Check for null bytes in inputs
181    if string.contains('\0') {
182        return Err(pyo3::exceptions::PyValueError::new_err(
183            "Input string contains null byte",
184        ));
185    }
186
187    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
188        extra_types.as_ref().map(|et| {
189            et.iter()
190                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
191                .collect()
192        })
193    });
194    let parser = get_or_create_parser(pattern, extra_types_cloned)?;
195    let search_string = &string[pos..end];
196
197    if let Some(result) =
198        parser.search_pattern(search_string, case_sensitive, extra_types, evaluate_result)?
199    {
200        // Adjust positions if it's a ParseResult (not Match)
201        Python::attach(|py| {
202            if let Ok(parse_result) = result.bind(py).cast::<ParseResult>() {
203                let result_value = parse_result.borrow();
204                let adjusted = result_value.clone().with_offset(pos);
205                // Py::new() is already optimized when GIL is held
206                Ok(Some(Py::new(py, adjusted)?.into_py_any(py)?))
207            } else {
208                // It's a Match object - we need to adjust its span
209                // For now, just return it as-is (Match spans are relative to search start)
210                Ok(Some(result))
211            }
212        })
213    } else {
214        Ok(None)
215    }
216}
217
218/// Find all matches of a pattern in a string
219#[pyfunction]
220#[pyo3(signature = (pattern, string, extra_types=None, case_sensitive=false, evaluate_result=true, max_matches=None))]
221fn findall(
222    pattern: &str,
223    string: &str,
224    extra_types: Option<HashMap<String, Py<PyAny>>>,
225    case_sensitive: bool,
226    evaluate_result: bool,
227    max_matches: Option<usize>,
228) -> PyResult<Py<PyAny>> {
229    // Validate input lengths
230    formatparse_core::validate_input_length(string)
231        .map_err(pyo3::exceptions::PyValueError::new_err)?;
232
233    // Check for null bytes in inputs
234    if string.contains('\0') {
235        return Err(pyo3::exceptions::PyValueError::new_err(
236            "Input string contains null byte",
237        ));
238    }
239
240    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
241        extra_types.as_ref().map(|et| {
242            et.iter()
243                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
244                .collect()
245        })
246    });
247    let parser = get_or_create_parser(pattern, extra_types_cloned)?;
248    crate::parser::findall_engine::findall_matches(
249        parser,
250        string,
251        extra_types.as_ref(),
252        case_sensitive,
253        evaluate_result,
254        max_matches,
255    )
256}
257
258/// Iterator over non-overlapping matches (same scan as :func:`findall`, one item per step).
259///
260/// See :class:`FindallIter` for memory semantics and limitations (issue #13 MVP).
261#[pyfunction]
262#[pyo3(signature = (pattern, string, extra_types=None, case_sensitive=false, evaluate_result=true, max_matches=None))]
263fn findall_iter(
264    py: Python<'_>,
265    pattern: &str,
266    string: &str,
267    extra_types: Option<HashMap<String, Py<PyAny>>>,
268    case_sensitive: bool,
269    evaluate_result: bool,
270    max_matches: Option<usize>,
271) -> PyResult<Py<FindallIter>> {
272    formatparse_core::validate_input_length(string)
273        .map_err(pyo3::exceptions::PyValueError::new_err)?;
274
275    if string.contains('\0') {
276        return Err(pyo3::exceptions::PyValueError::new_err(
277            "Input string contains null byte",
278        ));
279    }
280
281    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
282        extra_types.as_ref().map(|et| {
283            et.iter()
284                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
285                .collect()
286        })
287    });
288    let parser = get_or_create_parser(pattern, extra_types_cloned)?;
289
290    let et_map = Python::attach(|py| -> HashMap<String, Py<PyAny>> {
291        extra_types
292            .as_ref()
293            .map(|et| {
294                et.iter()
295                    .map(|(k, v)| (k.clone(), v.clone_ref(py)))
296                    .collect()
297            })
298            .unwrap_or_default()
299    });
300
301    Py::new(
302        py,
303        FindallIter::new(
304            parser,
305            string.to_string(),
306            case_sensitive,
307            evaluate_result,
308            et_map,
309            max_matches,
310        ),
311    )
312}
313
314/// Compile a pattern into a FormatParser for reuse.
315///
316/// Uses the same LRU cache as the `parse`, `search`, and `findall` bindings:
317/// `compile` with the same pattern and equivalent `extra_types` keys avoids
318/// rebuilding compiled regexes (see GitHub issue #29).
319#[pyfunction]
320#[pyo3(signature = (pattern, extra_types=None))]
321fn compile(
322    pattern: &str,
323    extra_types: Option<HashMap<String, Py<PyAny>>>,
324) -> PyResult<FormatParser> {
325    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
326        extra_types.as_ref().map(|et| {
327            et.iter()
328                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
329                .collect()
330        })
331    });
332    let arc = get_or_create_parser(pattern, extra_types_cloned)?;
333    Ok((*arc).clone())
334}
335
336/// Extract format specification components from a format string
337#[pyfunction]
338#[pyo3(signature = (format_string, _match_dict=None))]
339fn extract_format(
340    format_string: &str,
341    _match_dict: Option<&Bound<'_, PyDict>>,
342) -> PyResult<Py<PyAny>> {
343    use crate::types::FieldSpec;
344
345    // Parse the format spec string
346    let mut spec = FieldSpec::new();
347    formatparse_core::parser::pattern::parse_format_spec(format_string, &mut spec)
348        .map_err(crate::parser::pattern::pattern_compile_error_to_py)?;
349    formatparse_core::parser::pattern::validate_multiline_mvp(&spec)
350        .map_err(crate::parser::pattern::pattern_compile_error_to_py)?;
351
352    // Extract type from the original format_string (preserve original type chars like 'o', 'x', 'b')
353    // Parse the format spec to extract the type characters that come after width/precision/alignment
354    let type_str: String = if format_string == "%" {
355        "%".to_string()
356    } else {
357        // Parse format spec to find where type starts
358        // Format: [[fill]align][sign][#][0][width][,][.precision][type]
359        let chars: Vec<char> = format_string.chars().collect();
360        let mut i = 0;
361        let len = chars.len();
362
363        // Skip fill and align
364        if i < len && (chars[i] == '<' || chars[i] == '>' || chars[i] == '^' || chars[i] == '=') {
365            i += 1;
366        } else if i + 1 < len {
367            let ch = chars[i];
368            let next_ch = chars[i + 1];
369            if (next_ch == '<' || next_ch == '>' || next_ch == '^' || next_ch == '=')
370                && ch != next_ch
371            {
372                i += 2; // Skip fill + align
373            }
374        }
375
376        // Skip sign
377        if i < len && (chars[i] == '+' || chars[i] == '-' || chars[i] == ' ') {
378            i += 1;
379        }
380
381        // Skip #
382        if i < len && chars[i] == '#' {
383            i += 1;
384        }
385
386        // Skip 0
387        if i < len && chars[i] == '0' {
388            i += 1;
389        }
390
391        // Skip width (digits)
392        while i < len && chars[i].is_ascii_digit() {
393            i += 1;
394        }
395
396        // Skip comma
397        if i < len && chars[i] == ',' {
398            i += 1;
399        }
400
401        // Skip precision (.digits)
402        if i < len && chars[i] == '.' {
403            i += 1;
404            while i < len && chars[i].is_ascii_digit() {
405                i += 1;
406            }
407        }
408
409        // Type is the rest
410        if i < len {
411            format_string[i..].to_string()
412        } else {
413            "s".to_string() // Default
414        }
415    };
416
417    // Build result dictionary
418    Python::attach(|py| {
419        let result = PyDict::new(py);
420        result.set_item("type", type_str)?;
421
422        // Extract width
423        if let Some(width) = spec.width {
424            result.set_item("width", width.to_string())?;
425        }
426
427        // Extract precision
428        if let Some(precision) = spec.precision {
429            result.set_item("precision", precision.to_string())?;
430        }
431
432        // Extract alignment
433        if let Some(align) = spec.alignment {
434            result.set_item("align", align.to_string())?;
435        }
436
437        // Extract fill
438        if let Some(fill) = spec.fill {
439            result.set_item("fill", fill.to_string())?;
440        }
441
442        // Extract zero padding
443        if spec.zero_pad {
444            result.set_item("zero", true)?;
445        }
446
447        result.into_py_any(py)
448    })
449}
450
451/// Python module definition
452#[pymodule]
453fn _formatparse(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
454    m.add(
455        "PatternParseMismatch",
456        py.get_type::<crate::error::PatternParseMismatch>(),
457    )?;
458    m.add_function(wrap_pyfunction!(parse, m)?)?;
459    m.add_function(wrap_pyfunction!(parse_batch, m)?)?;
460    m.add_function(wrap_pyfunction!(search, m)?)?;
461    m.add_function(wrap_pyfunction!(findall, m)?)?;
462    m.add_function(wrap_pyfunction!(findall_iter, m)?)?;
463    m.add_function(wrap_pyfunction!(compile, m)?)?;
464    m.add_function(wrap_pyfunction!(extract_format, m)?)?;
465    m.add_class::<ParseResult>()?;
466    m.add_class::<FormatParser>()?;
467    m.add_class::<Format>()?;
468    m.add_class::<FixedTzOffset>()?;
469    m.add_class::<Match>()?;
470    m.add_class::<Results>()?;
471    m.add_class::<FindallIter>()?;
472    Ok(())
473}