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;
20mod unicode_offsets;
21
22pub(crate) use pattern_cache::extract_extra_types_identity;
23use unicode_offsets::search_byte_range;
24use pattern_cache::get_or_create_parser;
25
26pub use datetime::FixedTzOffset;
27pub use parser::{FindallIter, Format, FormatParser};
28pub use result::*;
29pub use results::Results;
30pub use types::conversion::*;
31// Core types come from formatparse-core
32pub use formatparse_core::strftime_to_regex;
33pub use formatparse_core::{FieldSpec, FieldType};
34pub use match_rs::Match;
35
36pub use error::PatternParseMismatch;
37
38/// Parse a string using a format specification
39#[pyfunction]
40#[pyo3(signature = (pattern, string, extra_types=None, case_sensitive=false, evaluate_result=true))]
41fn parse(
42    pattern: &str,
43    string: &str,
44    extra_types: Option<HashMap<String, Py<PyAny>>>,
45    case_sensitive: bool,
46    evaluate_result: bool,
47) -> PyResult<Option<Py<PyAny>>> {
48    // Validate input lengths
49    formatparse_core::validate_input_length(string)
50        .map_err(pyo3::exceptions::PyValueError::new_err)?;
51
52    // Check for null bytes in inputs
53    if string.contains('\0') {
54        return Err(pyo3::exceptions::PyValueError::new_err(
55            "Input string contains null byte",
56        ));
57    }
58
59    // Use cached parser if available
60    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
61        extra_types.as_ref().map(|et| {
62            et.iter()
63                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
64                .collect()
65        })
66    });
67    match get_or_create_parser(pattern, extra_types_cloned) {
68        Ok(parser) => parser.parse_internal(
69            string,
70            case_sensitive,
71            extra_types.as_ref(),
72            evaluate_result,
73        ),
74        Err(e) => Python::attach(|py| {
75            if e.is_instance_of::<PyNotImplementedError>(py) {
76                return Err(e);
77            }
78            if e.is_instance_of::<crate::error::PatternParseMismatch>(py) {
79                return Ok(None);
80            }
81            Err(e)
82        }),
83    }
84}
85
86/// Parse many strings with the same pattern, compiling the pattern once.
87///
88/// Each input string uses the same semantics as `parse` (including
89/// `extra_types`, `case_sensitive`, and `evaluate_result`). Non-matches
90/// become Python `None` at that index in the returned list.
91#[pyfunction]
92#[pyo3(signature = (pattern, strings, extra_types=None, case_sensitive=false, evaluate_result=true))]
93fn parse_batch(
94    pattern: &str,
95    strings: Vec<String>,
96    extra_types: Option<HashMap<String, Py<PyAny>>>,
97    case_sensitive: bool,
98    evaluate_result: bool,
99) -> PyResult<Py<PyAny>> {
100    for s in &strings {
101        formatparse_core::validate_input_length(s)
102            .map_err(pyo3::exceptions::PyValueError::new_err)?;
103        if s.contains('\0') {
104            return Err(pyo3::exceptions::PyValueError::new_err(
105                "Input string contains null byte",
106            ));
107        }
108    }
109
110    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
111        extra_types.as_ref().map(|et| {
112            et.iter()
113                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
114                .collect()
115        })
116    });
117
118    let parser = match get_or_create_parser(pattern, extra_types_cloned) {
119        Ok(p) => p,
120        Err(e) => {
121            return Python::attach(|py| -> PyResult<Py<PyAny>> {
122                if e.is_instance_of::<PyNotImplementedError>(py) {
123                    return Err(e);
124                }
125                if e.is_instance_of::<crate::error::PatternParseMismatch>(py) {
126                    let none_obj = py.None().into_py_any(py)?;
127                    let mut out: Vec<Py<PyAny>> = Vec::with_capacity(strings.len());
128                    for _ in 0..strings.len() {
129                        out.push(none_obj.clone_ref(py));
130                    }
131                    let items: Vec<_> = out.iter().map(|o| o.bind(py)).collect();
132                    return PyList::new(py, items)?.into_py_any(py);
133                }
134                Err(e)
135            });
136        }
137    };
138
139    Python::attach(|py| -> PyResult<Py<PyAny>> {
140        let mut out: Vec<Py<PyAny>> = Vec::with_capacity(strings.len());
141        for s in &strings {
142            match parser.parse_internal(s, case_sensitive, extra_types.as_ref(), evaluate_result)? {
143                Some(obj) => out.push(obj),
144                None => out.push(py.None().into_py_any(py)?),
145            }
146        }
147        let items: Vec<_> = out.iter().map(|o| o.bind(py)).collect();
148        PyList::new(py, items)?.into_py_any(py)
149    })
150}
151
152/// Search for a pattern in a string
153#[pyfunction]
154#[pyo3(signature = (pattern, string, pos=0, endpos=None, extra_types=None, case_sensitive=true, evaluate_result=true))]
155fn search(
156    pattern: &str,
157    string: &str,
158    pos: usize,
159    endpos: Option<usize>,
160    extra_types: Option<HashMap<String, Py<PyAny>>>,
161    case_sensitive: bool,
162    evaluate_result: bool,
163) -> PyResult<Option<Py<PyAny>>> {
164    // `pos` / `endpos` are Python character indices (like str slicing), not byte offsets.
165    let Some((byte_start, byte_end)) = search_byte_range(string, pos, endpos) else {
166        return Ok(None);
167    };
168
169    // Validate input lengths
170    formatparse_core::validate_input_length(string)
171        .map_err(pyo3::exceptions::PyValueError::new_err)?;
172
173    // Check for null bytes in inputs
174    if string.contains('\0') {
175        return Err(pyo3::exceptions::PyValueError::new_err(
176            "Input string contains null byte",
177        ));
178    }
179
180    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
181        extra_types.as_ref().map(|et| {
182            et.iter()
183                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
184                .collect()
185        })
186    });
187    let parser = get_or_create_parser(pattern, extra_types_cloned)?;
188    let search_string = &string[byte_start..byte_end];
189
190    if let Some(result) =
191        parser.search_pattern(search_string, case_sensitive, extra_types, evaluate_result)?
192    {
193        // Adjust byte spans to absolute offsets, then expose character indices to Python.
194        Python::attach(|py| {
195            if let Ok(parse_result) = result.bind(py).cast::<ParseResult>() {
196                let result_value = parse_result.borrow();
197                let adjusted = result_value
198                    .clone()
199                    .with_offset(byte_start)
200                    .spans_as_char_indices(string);
201                Ok(Some(Py::new(py, adjusted)?.into_py_any(py)?))
202            } else if let Ok(match_obj) = result.bind(py).cast::<crate::match_rs::Match>() {
203                let adjusted = match_obj
204                    .borrow()
205                    .clone()
206                    .with_offset(byte_start)
207                    .spans_as_char_indices(string);
208                Ok(Some(Py::new(py, adjusted)?.into_py_any(py)?))
209            } else {
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}