abnf-rust 2.8.2__tar.gz → 2.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/Cargo.lock +4 -4
  2. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/Cargo.toml +2 -2
  3. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/PKG-INFO +1 -1
  4. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/lib.rs +1 -1
  5. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/literal.rs +25 -11
  6. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/node.rs +47 -0
  7. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/rule.rs +31 -4
  8. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/visitor.rs +3 -0
  9. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/pyo3/src/nodes.rs +140 -30
  10. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/pyo3/src/parsers.rs +119 -20
  11. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/pyo3/src/recursion.rs +41 -2
  12. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/LICENSE.txt +0 -0
  13. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/README.md +0 -0
  14. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/pyproject.toml +0 -0
  15. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/Cargo.toml +0 -0
  16. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/alternation.rs +0 -0
  17. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/cache.rs +0 -0
  18. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/casefold.rs +0 -0
  19. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/concatenation.rs +0 -0
  20. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/core_rules.rs +0 -0
  21. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/error.rs +0 -0
  22. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/matcher.rs +0 -0
  23. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/meta_grammar.rs +0 -0
  24. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/option.rs +0 -0
  25. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/parser.rs +0 -0
  26. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/prose.rs +0 -0
  27. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/registry.rs +0 -0
  28. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/src/repetition.rs +0 -0
  29. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/tests/combinators.rs +0 -0
  30. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/tests/exclusion.rs +0 -0
  31. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/tests/meta_grammar.rs +0 -0
  32. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/core/tests/recursion.rs +0 -0
  33. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/pyo3/Cargo.toml +0 -0
  34. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/pyo3/src/bootstrap.rs +0 -0
  35. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/pyo3/src/bridge.rs +0 -0
  36. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/pyo3/src/errors.rs +0 -0
  37. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/pyo3/src/external.rs +0 -0
  38. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/pyo3/src/hooks.rs +0 -0
  39. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/pyo3/src/iter.rs +0 -0
  40. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/pyo3/src/lib.rs +0 -0
  41. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/rust/pyo3/src/source.rs +0 -0
  42. {abnf_rust-2.8.2 → abnf_rust-2.9.0}/src/abnf_rust/__init__.py +0 -0
@@ -4,7 +4,7 @@ version = 3
4
4
 
5
5
  [[package]]
6
6
  name = "abnf-core"
7
- version = "2.8.2"
7
+ version = "2.9.0"
8
8
  dependencies = [
9
9
  "caseless",
10
10
  "lru",
@@ -14,7 +14,7 @@ dependencies = [
14
14
 
15
15
  [[package]]
16
16
  name = "abnf-rust-ext"
17
- version = "2.8.2"
17
+ version = "2.9.0"
18
18
  dependencies = [
19
19
  "abnf-core",
20
20
  "mimalloc",
@@ -100,9 +100,9 @@ dependencies = [
100
100
 
101
101
  [[package]]
102
102
  name = "lru"
103
- version = "0.18.1"
103
+ version = "0.18.2"
104
104
  source = "registry+https://github.com/rust-lang/crates.io-index"
105
- checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6"
105
+ checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
106
106
  dependencies = [
107
107
  "hashbrown",
108
108
  ]
@@ -6,7 +6,7 @@ resolver = "2"
6
6
  # Lock-stepped with the `abnf` distribution; both packages release
7
7
  # under the same git tag so the wheels at `pip install abnf==X` and
8
8
  # `pip install abnf-rust==X` always come from the same source state.
9
- version = "2.8.2"
9
+ version = "2.9.0"
10
10
  edition = "2021"
11
11
  rust-version = "1.75"
12
12
  license = "MIT"
@@ -14,7 +14,7 @@ authors = ["Charles Yeomans <charles@declaresub.com>"]
14
14
  repository = "https://github.com/declaresub/abnf"
15
15
 
16
16
  [workspace.dependencies]
17
- abnf-core = { path = "rust/core", version = "2.8.2" }
17
+ abnf-core = { path = "rust/core", version = "2.9.0" }
18
18
  caseless = "0.2"
19
19
  lru = "0.18"
20
20
  mimalloc = { version = "0.1", default-features = false }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: abnf-rust
3
- Version: 2.8.2
3
+ Version: 2.9.0
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -47,7 +47,7 @@ pub use error::ParseError;
47
47
  pub use literal::{Literal, LiteralKind};
48
48
  pub use matcher::Match;
49
49
  pub use meta_grammar::{build_meta_grammar, install_meta_grammar};
50
- pub use node::{LiteralNode, Node, NodeKind};
50
+ pub use node::{ForeignNode, LiteralNode, Node, NodeKind};
51
51
  pub use option::OptionParser;
52
52
  pub use parser::{arc, ArcParser, ExternalParser, MatchList, NodeList, ParseResult, Parser, Src};
53
53
  pub use prose::Prose;
@@ -126,14 +126,17 @@ impl Literal {
126
126
  }
127
127
  }
128
128
  LiteralKind::String { value, pattern } => {
129
- // Mirror Python's `if start < len(source)` guard (see
130
- // `_lparse_value`), which raises even for an empty
131
- // literal at EOF. Without it the length check below
132
- // would let a zero-length pattern match at any
133
- // out-of-range start.
134
- if start >= source.len() {
135
- return Err(self.parse_error(start));
136
- }
129
+ // Enough source must remain for the whole literal. This
130
+ // one check covers both cases: a non-empty literal needs
131
+ // room, and a zero-length one needs only `start <=
132
+ // source.len()`, which is what `end > source.len()` says
133
+ // when `plen` is 0.
134
+ //
135
+ // There used to be a `start >= source.len()` guard above
136
+ // this, mirroring Python's, which also refused a
137
+ // zero-length literal at end of input -- so `""` matched
138
+ // everywhere except there. See
139
+ // https://github.com/declaresub/abnf/issues/260 .
137
140
  let plen = value.len();
138
141
  let end = start + plen;
139
142
  if end > source.len() {
@@ -210,11 +213,22 @@ mod tests {
210
213
  assert!(lit.lparse(&[0xD800, 0x62], 0).is_err());
211
214
  }
212
215
 
216
+ /// #260: a zero-length literal matches the empty string wherever the
217
+ /// source reaches, end of input included -- but not past it.
213
218
  #[test]
214
- fn empty_literal_raises_at_eof() {
219
+ fn empty_literal_matches_at_eof_but_not_beyond() {
215
220
  let lit = Literal::string("", false);
216
- assert!(lit.lparse(&[], 0).is_err());
217
- assert!(lit.lparse(&cps("x"), 0).is_ok());
221
+ assert!(lit.lparse(&[], 0).is_ok(), "empty source, offset 0");
222
+ assert!(lit.lparse(&cps("x"), 0).is_ok(), "start of input");
223
+ assert!(lit.lparse(&cps("x"), 1).is_ok(), "end of input");
224
+ assert!(lit.lparse(&cps("x"), 2).is_err(), "past end of input");
225
+ assert!(lit.lparse(&[], 1).is_err(), "past end of empty source");
226
+
227
+ // A non-empty literal still needs room for all of itself.
228
+ let lit = Literal::string("ab", false);
229
+ assert!(lit.lparse(&cps("ab"), 0).is_ok());
230
+ assert!(lit.lparse(&cps("a"), 0).is_err());
231
+ assert!(lit.lparse(&cps("ab"), 2).is_err());
218
232
  }
219
233
 
220
234
  #[test]
@@ -75,11 +75,45 @@ impl LiteralNode {
75
75
  }
76
76
  }
77
77
 
78
+ /// A terminal node supplied by an embedded Python parser, carrying the
79
+ /// text that parser reported.
80
+ ///
81
+ /// Engine-built terminals are spans of the source ([`LiteralNode`]),
82
+ /// which is what lets the PyO3 layer produce their values by slicing.
83
+ /// A node handed *in* by a `PyCallbackParser` need not correspond to
84
+ /// any span: a custom parser may legitimately return a normalised or
85
+ /// synthesised value, and the pure-Python backend keeps it. Slicing
86
+ /// the source for one of these replaced it with unrelated text
87
+ /// (issue #220), so these carry their own.
88
+ ///
89
+ /// Code points rather than a Rust string, for the same reason the
90
+ /// source is: the value may contain a lone surrogate.
91
+ #[derive(Debug, Clone)]
92
+ pub struct ForeignNode {
93
+ pub value: Box<[u32]>,
94
+ /// Offset the parser reported. Not necessarily where `value`
95
+ /// appears in the source -- it need not appear there at all.
96
+ pub offset: usize,
97
+ pub length: usize,
98
+ }
99
+
100
+ impl ForeignNode {
101
+ pub fn new(value: Box<[u32]>, offset: usize, length: usize) -> Self {
102
+ Self { value, offset, length }
103
+ }
104
+ }
105
+
78
106
  /// Sum type for parse-tree children.
79
107
  #[derive(Debug, Clone)]
80
108
  pub enum NodeKind {
81
109
  Internal(Node),
82
110
  Literal(LiteralNode),
111
+ /// Behind an `Arc`, as `Node`'s children are, and for the same
112
+ /// reason: node lists are cloned on every match extension, so this
113
+ /// variant's clone lands on every parse whether or not a grammar
114
+ /// ever produces one. Inline it also made `NodeKind` 40 bytes
115
+ /// rather than 32.
116
+ Foreign(Arc<ForeignNode>),
83
117
  }
84
118
 
85
119
  impl NodeKind {
@@ -98,6 +132,13 @@ impl NodeKind {
98
132
  .map(|cp| char::from_u32(*cp).unwrap_or(char::REPLACEMENT_CHARACTER)),
99
133
  );
100
134
  }
135
+ NodeKind::Foreign(f) => {
136
+ out.extend(
137
+ f.value
138
+ .iter()
139
+ .map(|cp| char::from_u32(*cp).unwrap_or(char::REPLACEMENT_CHARACTER)),
140
+ );
141
+ }
101
142
  }
102
143
  }
103
144
 
@@ -113,6 +154,11 @@ impl NodeKind {
113
154
  pub fn span_bounds(&self) -> Option<(usize, usize)> {
114
155
  match self {
115
156
  NodeKind::Literal(l) => Some((l.offset, l.offset + l.length)),
157
+ // A foreign node's text is its own, so its span says where
158
+ // the parser claims it sat, not where its value can be
159
+ // read from. Callers computing a value from a span must
160
+ // check `contains_foreign` first.
161
+ NodeKind::Foreign(f) => Some((f.offset, f.offset + f.length)),
116
162
  NodeKind::Internal(n) => n.children.iter().fold(None, |acc, c| {
117
163
  match (acc, c.span_bounds()) {
118
164
  (None, s) => s,
@@ -123,3 +169,4 @@ impl NodeKind {
123
169
  }
124
170
  }
125
171
  }
172
+
@@ -268,7 +268,17 @@ impl NamedRule {
268
268
  self.name
269
269
  ),
270
270
  });
271
- let def = self.definition().ok_or_else(|| self.parse_error(start))?;
271
+ // A rule that was never defined is a broken grammar, not input
272
+ // that failed to match. Returning a `ParseError` here would be
273
+ // indistinguishable from "this alternative did not match", so
274
+ // an enclosing `Alternation` or `Repetition` would swallow it
275
+ // as backtracking and a typo'd rule name would silently delete
276
+ // that branch of the grammar. The pure-Python backend raises
277
+ // `GrammarError`; panic with the phrase the PyO3 layer maps to
278
+ // it, the same route `is_excluded` and the recursion guard use.
279
+ let Some(def) = self.definition() else {
280
+ panic!("Undefined rule \"{}\"", self.name);
281
+ };
272
282
  let inner = def.lparse(source, start)?;
273
283
 
274
284
  let excluded = self.exclude();
@@ -296,9 +306,26 @@ impl NamedRule {
296
306
  if let Some(ref excluded) = excluded {
297
307
  // `m.start` is the end of the match; the span is what
298
308
  // the Python side rebuilds by joining node values.
299
- if Self::is_excluded(excluded, &source[start..m.start]) {
300
- seen.remove(&m.start);
301
- continue;
309
+ //
310
+ // It is not necessarily a real span. When this rule's
311
+ // definition is a `PyCallbackParser`, the offset comes
312
+ // from a Python parser and can be anything -- past the
313
+ // end of the source, or before `start`. Slicing on it
314
+ // panicked, and a panic crosses the FFI as
315
+ // `PanicException`, a `BaseException` that `except
316
+ // ParseError` (or even `except Exception`) does not
317
+ // catch (issue #218).
318
+ //
319
+ // Text that is not in the source cannot be text the
320
+ // excluded rule matches, so a nonsensical span means
321
+ // "not excluded". The bad offset then fails naturally
322
+ // further up, which is what the pure-Python backend
323
+ // does with the same input.
324
+ if let Some(span) = source.get(start..m.start) {
325
+ if Self::is_excluded(excluded, span) {
326
+ seen.remove(&m.start);
327
+ continue;
328
+ }
302
329
  }
303
330
  }
304
331
  let node = Node::new(self.name.clone(), m.nodes.into_vec());
@@ -153,6 +153,9 @@ fn visit_repeat(node: &Node, src: Src<'_>) -> Repeat {
153
153
  saw_star = true;
154
154
  }
155
155
  }
156
+ // The meta-grammar builds its own trees, so no node here
157
+ // came from an embedded Python parser.
158
+ NodeKind::Foreign(_) => {}
156
159
  }
157
160
  }
158
161
  let min = if min_src.is_empty() {
@@ -25,15 +25,15 @@ use pyo3::prelude::*;
25
25
  use pyo3::types::PyString;
26
26
  use pyo3::types::PyList;
27
27
 
28
- use abnf_core::{LiteralNode, Match, Node, NodeKind};
28
+ use abnf_core::{ForeignNode, LiteralNode, Match, Node, NodeKind};
29
29
 
30
- use crate::source::substring;
30
+ use crate::source::{from_code_points, substring, CodePoints};
31
31
 
32
32
  // ----------------------------------------------------------------
33
33
  // LiteralNode
34
34
  // ----------------------------------------------------------------
35
35
 
36
- #[pyclass(name = "LiteralNode", module = "abnf_rust._ext", from_py_object)]
36
+ #[pyclass(name = "LiteralNode", module = "abnf_rust._ext", from_py_object, subclass)]
37
37
  #[derive(Debug)]
38
38
  pub struct PyLiteralNode {
39
39
  /// The matched text, as a Python `str` sliced out of the source.
@@ -94,13 +94,16 @@ impl PyLiteralNode {
94
94
  && self.value.bind(py).as_any().eq(other.value.bind(py).as_any())?)
95
95
  }
96
96
 
97
- fn __hash__(&self, py: Python<'_>) -> PyResult<isize> {
98
- // Hash on the same fields `__eq__` compares, and let Python
99
- // hash the text so the two agree for every string it can hold.
100
- let mut h = self.value.bind(py).as_any().hash()?;
101
- h = h.wrapping_mul(1_000_003).wrapping_add(self.offset as isize);
102
- h = h.wrapping_mul(1_000_003).wrapping_add(self.length as isize);
103
- Ok(h)
97
+ /// Deliberately absent: defining `__eq__` without `__hash__`
98
+ /// makes a class unhashable in Python, and the pure-Python
99
+ /// `LiteralNode` does exactly that. `Node` is unhashable on both
100
+ /// backends too, so no parse-tree node is hashable anywhere.
101
+ /// Adding one here made `set(literal_nodes)` work under the Rust
102
+ /// backend and raise `TypeError` under the other.
103
+ #[classattr]
104
+ #[pyo3(name = "__hash__")]
105
+ fn __hash__() -> Option<Py<PyAny>> {
106
+ None
104
107
  }
105
108
  }
106
109
 
@@ -122,7 +125,7 @@ impl PyLiteralNode {
122
125
  // Node
123
126
  // ----------------------------------------------------------------
124
127
 
125
- #[pyclass(name = "Node", module = "abnf_rust._ext")]
128
+ #[pyclass(name = "Node", module = "abnf_rust._ext", subclass)]
126
129
  #[derive(Debug)]
127
130
  pub struct PyNode {
128
131
  #[pyo3(get)]
@@ -167,8 +170,24 @@ impl PyNode {
167
170
  }
168
171
 
169
172
  fn __richcmp__(&self, py: Python<'_>, other: &Self, op: CompareOp) -> PyResult<bool> {
173
+ // Structural, as the pure-Python `Node.__eq__` is: name and
174
+ // children, compared recursively. Comparing concatenated
175
+ // values instead called two different parse trees equal
176
+ // whenever they spanned the same text -- `"xy" / ("x" "y")`
177
+ // against `("x" "y") / "xy"`, say -- which silently changed
178
+ // what a user's assertions meant depending on the backend.
170
179
  let eq = self.name == other.name
171
- && self.value.bind(py).as_any().eq(other.value.bind(py).as_any())?;
180
+ && self.children.len() == other.children.len()
181
+ && {
182
+ let mut equal = true;
183
+ for (a, b) in self.children.iter().zip(other.children.iter()) {
184
+ if !a.bind(py).eq(b.bind(py))? {
185
+ equal = false;
186
+ break;
187
+ }
188
+ }
189
+ equal
190
+ };
172
191
  Ok(match op {
173
192
  CompareOp::Eq => eq,
174
193
  CompareOp::Ne => !eq,
@@ -187,18 +206,55 @@ impl PyNode {
187
206
  /// rather than a concatenation of the children's values: a node
188
207
  /// covers a contiguous run of the source, so the two are the same
189
208
  /// text.
190
- pub fn from_rust(py: Python<'_>, n: &Node, source: &Bound<'_, PyString>) -> PyResult<Self> {
209
+ pub fn from_rust(
210
+ py: Python<'_>,
211
+ n: &Node,
212
+ source: &Bound<'_, PyString>,
213
+ ) -> PyResult<(Self, bool)> {
191
214
  let mut children: Vec<Py<PyAny>> = Vec::with_capacity(n.children.len());
215
+ let mut foreign = false;
192
216
  for child in n.children.iter() {
193
- children.push(node_kind_to_py(py, child, source)?);
217
+ let (obj, child_foreign) = node_kind_to_py(py, child, source)?;
218
+ foreign |= child_foreign;
219
+ children.push(obj);
194
220
  }
195
- let value = span_value(py, n.children.iter(), source)?;
196
- Ok(Self {
197
- name: n.name.as_ref().to_string(),
198
- value: value.unbind(),
199
- children,
200
- })
221
+ // One slice of the source covers this node -- unless something
222
+ // below it carries its own text, in which case join the parts,
223
+ // which the children have already computed (issue #220).
224
+ let value = if foreign {
225
+ join_child_values(py, &children)?
226
+ } else {
227
+ span_value(py, n.children.iter(), source)?
228
+ };
229
+ Ok((
230
+ Self {
231
+ name: n.name.as_ref().to_string(),
232
+ value: value.unbind(),
233
+ children,
234
+ },
235
+ foreign,
236
+ ))
237
+ }
238
+ }
239
+
240
+ /// Concatenate the `value` of each already-converted child. Used only
241
+ /// for a node with a foreign descendant, where slicing the source
242
+ /// would not reproduce the text.
243
+ fn join_child_values<'py>(
244
+ py: Python<'py>,
245
+ children: &[Py<PyAny>],
246
+ ) -> PyResult<Bound<'py, PyString>> {
247
+ let mut joined = PyString::new(py, "").unbind();
248
+ for child in children {
249
+ let part = child.bind(py).getattr("value")?;
250
+ joined = joined
251
+ .bind(py)
252
+ .as_any()
253
+ .add(part)?
254
+ .cast_into::<PyString>()?
255
+ .unbind();
201
256
  }
257
+ Ok(joined.into_bound(py))
202
258
  }
203
259
 
204
260
  /// The text spanned by `nodes`: from the first descendant literal to
@@ -225,15 +281,44 @@ where
225
281
  }
226
282
  }
227
283
 
228
- /// Convert a `NodeKind` to a Python object.
284
+ /// Convert a `NodeKind` to a Python object, reporting whether the
285
+ /// subtree contains a node whose text is not a span of the source.
286
+ ///
287
+ /// The flag is accumulated on the way up rather than recomputed by
288
+ /// walking each subtree, which would make building a tree quadratic --
289
+ /// measured at 13-17% on the parse benchmarks when this fix was first
290
+ /// written that way.
229
291
  fn node_kind_to_py(
230
292
  py: Python<'_>,
231
293
  kind: &NodeKind,
232
294
  source: &Bound<'_, PyString>,
233
- ) -> PyResult<Py<PyAny>> {
295
+ ) -> PyResult<(Py<PyAny>, bool)> {
234
296
  Ok(match kind {
235
- NodeKind::Internal(n) => Py::new(py, PyNode::from_rust(py, n, source)?)?.into_any(),
236
- NodeKind::Literal(l) => Py::new(py, PyLiteralNode::from_rust(l, source)?)?.into_any(),
297
+ NodeKind::Internal(n) => {
298
+ let (node, foreign) = PyNode::from_rust(py, n, source)?;
299
+ (Py::new(py, node)?.into_any(), foreign)
300
+ }
301
+ NodeKind::Literal(l) => (
302
+ Py::new(py, PyLiteralNode::from_rust(l, source)?)?.into_any(),
303
+ false,
304
+ ),
305
+ // Carries its own text, so it is handed back verbatim rather
306
+ // than re-sliced from the source (issue #220).
307
+ NodeKind::Foreign(f) => {
308
+ let value = from_code_points(py, &f.value)?;
309
+ (
310
+ Py::new(
311
+ py,
312
+ PyLiteralNode {
313
+ value: value.unbind(),
314
+ offset: f.offset,
315
+ length: f.length,
316
+ },
317
+ )?
318
+ .into_any(),
319
+ true,
320
+ )
321
+ }
237
322
  })
238
323
  }
239
324
 
@@ -242,7 +327,7 @@ fn node_kind_to_py(
242
327
  // Match
243
328
  // ----------------------------------------------------------------
244
329
 
245
- #[pyclass(name = "Match", module = "abnf_rust._ext")]
330
+ #[pyclass(name = "Match", module = "abnf_rust._ext", subclass)]
246
331
  #[derive(Debug)]
247
332
  pub struct PyMatch {
248
333
  /// Match nodes as Python objects.
@@ -301,10 +386,17 @@ impl PyMatch {
301
386
  impl PyMatch {
302
387
  pub fn from_rust(py: Python<'_>, m: &Match, source: &Bound<'_, PyString>) -> PyResult<Self> {
303
388
  let mut nodes = Vec::with_capacity(m.nodes.len());
389
+ let mut foreign = false;
304
390
  for nk in &m.nodes {
305
- nodes.push(node_kind_to_py(py, nk, source)?);
391
+ let (obj, node_foreign) = node_kind_to_py(py, nk, source)?;
392
+ foreign |= node_foreign;
393
+ nodes.push(obj);
306
394
  }
307
- let cached_value = span_value(py, m.nodes.iter(), source)?;
395
+ let cached_value = if foreign {
396
+ join_child_values(py, &nodes)?
397
+ } else {
398
+ span_value(py, m.nodes.iter(), source)?
399
+ };
308
400
  Ok(Self {
309
401
  nodes,
310
402
  start: m.start,
@@ -339,11 +431,23 @@ fn py_to_node_kind(obj: &Bound<'_, PyAny>) -> PyResult<NodeKind> {
339
431
  //
340
432
  // A terminal contributes only its span; the text itself is read
341
433
  // back out of the source when the tree is rebuilt for Python.
434
+ // Terminals coming *in* from Python keep their own text. The
435
+ // engine's own terminals are spans of the source, which is what
436
+ // lets their values be produced by slicing -- but a custom parser
437
+ // may return a normalised or synthesised value, and discarding it
438
+ // silently replaced it with unrelated source text (issue #220).
342
439
  if let Ok(lit) = obj.cast::<PyLiteralNode>() {
343
440
  let lit = lit.borrow();
344
- return Ok(NodeKind::Literal(LiteralNode::new(lit.offset, lit.length)));
441
+ let value = CodePoints::new(lit.value.bind(obj.py()))?;
442
+ return Ok(NodeKind::Foreign(Arc::new(ForeignNode::new(
443
+ value.as_slice().into(),
444
+ lit.offset,
445
+ lit.length,
446
+ ))));
345
447
  }
346
- if let (Ok(offset_obj), Ok(length_obj)) = (obj.getattr("offset"), obj.getattr("length")) {
448
+ if let (Ok(value_obj), Ok(offset_obj), Ok(length_obj)) =
449
+ (obj.getattr("value"), obj.getattr("offset"), obj.getattr("length"))
450
+ {
347
451
  if let (Ok(offset), Ok(length)) = (
348
452
  offset_obj.extract::<usize>(),
349
453
  length_obj.extract::<usize>(),
@@ -353,7 +457,13 @@ fn py_to_node_kind(obj: &Bound<'_, PyAny>) -> PyResult<NodeKind> {
353
457
  .and_then(|n| n.extract())
354
458
  .unwrap_or_else(|_| "literal".to_string());
355
459
  if name == "literal" {
356
- return Ok(NodeKind::Literal(LiteralNode::new(offset, length)));
460
+ let value_str = value_obj.cast::<PyString>()?;
461
+ let value = CodePoints::new(value_str)?;
462
+ return Ok(NodeKind::Foreign(Arc::new(ForeignNode::new(
463
+ value.as_slice().into(),
464
+ offset,
465
+ length,
466
+ ))));
357
467
  }
358
468
  }
359
469
  }
@@ -8,7 +8,8 @@ use std::sync::Arc;
8
8
 
9
9
  use pyo3::exceptions::PyTypeError;
10
10
  use pyo3::prelude::*;
11
- use pyo3::types::{PyString, PyTuple};
11
+ use pyo3::sync::PyOnceLock;
12
+ use pyo3::types::{PyFloat, PyInt, PyString, PyTuple, PyType};
12
13
 
13
14
  use abnf_core::{
14
15
  arc, Alternation, ArcParser, Concatenation, Literal, LiteralKind, OptionParser, Parser, Prose,
@@ -37,8 +38,78 @@ pub struct PyRepeat {
37
38
  #[pymethods]
38
39
  impl PyRepeat {
39
40
  #[new]
40
- #[pyo3(signature = (min=0, max=None))]
41
- fn new(py: Python<'_>, min: usize, max: Option<usize>) -> PyResult<Self> {
41
+ #[pyo3(signature = (min=None, max=None))]
42
+ fn new(
43
+ py: Python<'_>,
44
+ min: Option<&Bound<'_, PyAny>>,
45
+ max: Option<&Bound<'_, PyAny>>,
46
+ ) -> PyResult<Self> {
47
+ // A negative `min` is not an error. The pure-Python `Repeat`
48
+ // accepts one, and `Repetition` then builds no mandatory
49
+ // prefix at all -- so it behaves as zero, which is what this
50
+ // clamps to (issue #221). Rejecting it made the backends
51
+ // disagree over a value neither treats as meaningful.
52
+ let min: usize = match min {
53
+ None => 0,
54
+ Some(obj) => match obj.extract::<usize>() {
55
+ Ok(value) => value,
56
+ Err(err) => {
57
+ if obj.cast::<PyInt>().is_ok() && obj.lt(0i64)? {
58
+ 0
59
+ } else {
60
+ return Err(err);
61
+ }
62
+ }
63
+ },
64
+ };
65
+ // A bound too large for `usize` saturates rather than raising.
66
+ // Python's ints are unbounded, so `2*99999999999999999999"x"`
67
+ // is odd but valid ABNF that the pure-Python backend parses
68
+ // happily -- the bound is simply never reached. Raising
69
+ // `OverflowError` here made the two backends disagree, and
70
+ // `OverflowError` is neither `GrammarError` nor `ParseError`,
71
+ // so it escaped the documented exception contract as well.
72
+ // No input can reach `usize::MAX` repetitions, so saturating
73
+ // is indistinguishable from the unbounded value.
74
+ let max: Option<usize> = match max {
75
+ None => None,
76
+ Some(obj) if obj.is_none() => None,
77
+ Some(obj) => match obj.extract::<usize>() {
78
+ Ok(value) => Some(value),
79
+ // A float bound is not an error either. Pure Python
80
+ // compares `max` against the repetition count, so a
81
+ // non-integral float never equals it and the
82
+ // repetition is unbounded; an integral one caps where
83
+ // the integer would. Mirror both.
84
+ // Test for a *float* specifically: an int converts to
85
+ // f64 too, and routing negative ints through here would
86
+ // swallow the `max < min` check below.
87
+ Err(_) if obj.is_instance_of::<PyFloat>() => {
88
+ let value = obj.extract::<f64>()?;
89
+ if value.is_finite() && value.fract() == 0.0 && value >= 0.0 {
90
+ Some(value as usize)
91
+ } else {
92
+ None
93
+ }
94
+ }
95
+ // Not an integer at all: report that, not a bound problem.
96
+ Err(err) if obj.cast::<PyInt>().is_err() => return Err(err),
97
+ // An integer outside `usize`. Negative falls through to
98
+ // the `max < min` check below, exactly as in Python;
99
+ // anything else is bigger than any input can reach.
100
+ Err(_) if obj.lt(0i64)? => {
101
+ let msg = format!(
102
+ "Repeat max ({}) is less than min ({min}); \
103
+ a repetition of {min}*{} can never match.",
104
+ obj.str()?,
105
+ obj.str()?
106
+ );
107
+ return Err(crate::errors::grammar_error(py, &msg));
108
+ }
109
+ Err(_) => Some(usize::MAX),
110
+ },
111
+ };
112
+
42
113
  // Mirrors the check in the pure-Python `Repeat.__init__`:
43
114
  // `3*2` is an impossible range, and leaving it unvalidated
44
115
  // silently behaves as `3*`. Both backends must reject it
@@ -97,7 +168,9 @@ impl PyAlternation {
97
168
  start: usize,
98
169
  ) -> PyResult<Py<LparseIter>> {
99
170
  let cps = CodePoints::new(source)?;
100
- let result = crate::recursion::call_lparse(|| self.inner.lparse(cps.as_slice(), start))?;
171
+ let result = crate::recursion::call_lparse(source.as_ptr() as usize, || {
172
+ self.inner.lparse(cps.as_slice(), start)
173
+ })?;
101
174
  lparse_iter(py, result, source)
102
175
  }
103
176
 
@@ -150,7 +223,9 @@ impl PyConcatenation {
150
223
  start: usize,
151
224
  ) -> PyResult<Py<LparseIter>> {
152
225
  let cps = CodePoints::new(source)?;
153
- let result = crate::recursion::call_lparse(|| self.inner.lparse(cps.as_slice(), start))?;
226
+ let result = crate::recursion::call_lparse(source.as_ptr() as usize, || {
227
+ self.inner.lparse(cps.as_slice(), start)
228
+ })?;
154
229
  lparse_iter(py, result, source)
155
230
  }
156
231
 
@@ -186,7 +261,9 @@ impl PyRepetition {
186
261
  start: usize,
187
262
  ) -> PyResult<Py<LparseIter>> {
188
263
  let cps = CodePoints::new(source)?;
189
- let result = crate::recursion::call_lparse(|| self.inner.lparse(cps.as_slice(), start))?;
264
+ let result = crate::recursion::call_lparse(source.as_ptr() as usize, || {
265
+ self.inner.lparse(cps.as_slice(), start)
266
+ })?;
190
267
  lparse_iter(py, result, source)
191
268
  }
192
269
 
@@ -222,7 +299,9 @@ impl PyOption {
222
299
  start: usize,
223
300
  ) -> PyResult<Py<LparseIter>> {
224
301
  let cps = CodePoints::new(source)?;
225
- let result = crate::recursion::call_lparse(|| self.inner.lparse(cps.as_slice(), start))?;
302
+ let result = crate::recursion::call_lparse(source.as_ptr() as usize, || {
303
+ self.inner.lparse(cps.as_slice(), start)
304
+ })?;
226
305
  lparse_iter(py, result, source)
227
306
  }
228
307
 
@@ -269,7 +348,11 @@ impl PyLiteral {
269
348
  };
270
349
  return Ok(Self {
271
350
  inner: Literal::range(*lo, *hi).into(),
272
- case_sensitive: true,
351
+ // A range compares by code point either way, so
352
+ // this attribute is inert -- but the pure-Python
353
+ // constructor leaves it at its default, and the
354
+ // two backends should read alike.
355
+ case_sensitive,
273
356
  });
274
357
  }
275
358
  }
@@ -285,7 +368,9 @@ impl PyLiteral {
285
368
  start: usize,
286
369
  ) -> PyResult<Py<LparseIter>> {
287
370
  let cps = CodePoints::new(source)?;
288
- let result = crate::recursion::call_lparse(|| self.inner.lparse(cps.as_slice(), start))?;
371
+ let result = crate::recursion::call_lparse(source.as_ptr() as usize, || {
372
+ self.inner.lparse(cps.as_slice(), start)
373
+ })?;
289
374
  lparse_iter(py, result, source)
290
375
  }
291
376
 
@@ -347,7 +432,9 @@ impl PyProse {
347
432
  start: usize,
348
433
  ) -> PyResult<Py<LparseIter>> {
349
434
  let cps = CodePoints::new(source)?;
350
- match crate::recursion::call_lparse(|| self.inner.lparse(cps.as_slice(), start))? {
435
+ match crate::recursion::call_lparse(source.as_ptr() as usize, || {
436
+ self.inner.lparse(cps.as_slice(), start)
437
+ })? {
351
438
  Ok(_) => lparse_iter(py, Ok(smallvec::SmallVec::new()), source),
352
439
  Err(err) => Err(parse_error_to_pyerr(py, err)),
353
440
  }
@@ -386,19 +473,31 @@ pub fn extract_parser(obj: &Bound<'_, PyAny>) -> PyResult<ArcParser> {
386
473
  if let Ok(p) = obj.cast::<PyProse>() {
387
474
  return Ok(p.borrow().inner.clone());
388
475
  }
389
- // If the value is a Python Rule (or anything else carrying a
390
- // `name`+`lparse` shape that fits the parser-by-name contract),
391
- // look up — or lazily create — its shadow Rust `NamedRule` in
392
- // the bridge registry. This is the fast path that keeps rule
393
- // references purely in Rust at parse time, instead of dispatching
394
- // every reference through Python.
395
- if obj.hasattr("name")? && obj.hasattr("lparse")? {
476
+ // If the value is a Python `Rule`, look up — or lazily create —
477
+ // its shadow Rust `NamedRule` in the bridge registry. This is the
478
+ // fast path that keeps rule references purely in Rust at parse
479
+ // time, instead of dispatching every reference through Python.
480
+ //
481
+ // The test is `isinstance`, not the presence of `name` + `lparse`.
482
+ // Attribute shape caught anything with a `name`, which was wrong
483
+ // twice over (issue #203): such an object became a
484
+ // definition-less `NamedRule` whose own `lparse` was then never
485
+ // called, and it was entered into a registry keyed by the object's
486
+ // *address* — sound only because `Rule._obj_map` makes rules
487
+ // immortal, which is not true of an arbitrary user object. A
488
+ // freed one's address could be handed to a new `Rule`, which then
489
+ // inherited its stale handle; defining that rule wrote into a
490
+ // parser tree built from an unrelated object.
491
+ static RULE_TYPE: PyOnceLock<Py<PyType>> = PyOnceLock::new();
492
+ let rule_type = RULE_TYPE.import(obj.py(), "abnf._parser_python", "Rule")?;
493
+ if obj.is_instance(rule_type)? {
396
494
  let handle = crate::bridge::get_or_create(obj)?;
397
495
  return Ok(arc(handle));
398
496
  }
399
- // Last-resort fallback: any other object with an `lparse` method
400
- // is wrapped as a `PyCallbackParser`. Reserved for callers that
401
- // pass duck-typed parsers without a `name` attribute.
497
+ // Any other object with an `lparse` method is wrapped as a
498
+ // `PyCallbackParser`, which holds a reference to it -- so unlike a
499
+ // bridge entry, it cannot outlive the object it refers to. This
500
+ // is where every duck-typed parser goes, with or without a `name`.
402
501
  if obj.hasattr("lparse")? {
403
502
  let callback = PyCallbackParser::new(obj.clone().unbind(), "PyCallback");
404
503
  let parser: Parser = Parser::External(Arc::new(callback));
@@ -24,7 +24,7 @@
24
24
  //! tags into the right Python exception. Other panics resume so
25
25
  //! they keep surfacing as `PanicException` via PyO3's defaults.
26
26
 
27
- use std::cell::RefCell;
27
+ use std::cell::{Cell, RefCell};
28
28
  use std::panic::{self, AssertUnwindSafe};
29
29
  use std::sync::Once;
30
30
 
@@ -48,6 +48,17 @@ const PYERR_PANIC_TAG: &str = "pycallback-python-error";
48
48
  const UNDEFINED_RULE_TAG: &str = "Undefined rule";
49
49
 
50
50
  thread_local! {
51
+ /// Identity of the source the innermost active parse is running
52
+ /// over: the address of the Python `str` object, which is what
53
+ /// the pure-Python backend compares (`ctx[0] is source`) before
54
+ /// trusting a memo entry. Zero when no parse is active.
55
+ ///
56
+ /// Not the code-point buffer's address: a nested call widens the
57
+ /// same `str` into a different pooled buffer, so that would report
58
+ /// "different source" for every re-entrant call and throw away the
59
+ /// enclosing parse's memo each time.
60
+ static CURRENT_SOURCE_ID: Cell<usize> = const { Cell::new(0) };
61
+
51
62
  /// Holds the most recent `PyErr` raised by a `PyCallbackParser`
52
63
  /// callback that should propagate (i.e. not a `ParseError`).
53
64
  /// Taken by `call_lparse` when it recognises the
@@ -57,6 +68,16 @@ thread_local! {
57
68
 
58
69
  static HOOK_INSTALLED: Once = Once::new();
59
70
 
71
+ /// Restores the enclosing parse's source identity on the way out, so
72
+ /// a nested call cannot leave the wrong one behind.
73
+ struct RestoreSourceId(usize);
74
+
75
+ impl Drop for RestoreSourceId {
76
+ fn drop(&mut self) {
77
+ CURRENT_SOURCE_ID.with(|id| id.set(self.0));
78
+ }
79
+ }
80
+
60
81
  /// Install the panic hook exactly once per process.
61
82
  pub fn install_panic_hook() {
62
83
  HOOK_INSTALLED.call_once(|| {
@@ -99,7 +120,7 @@ pub fn propagate_pyerr(err: PyErr) -> ! {
99
120
  /// PyCallbackParser-stash panic to the saved `PyErr`. Any other
100
121
  /// panic is re-raised so it surfaces normally via PyO3's
101
122
  /// `PanicException`.
102
- pub fn call_lparse<F>(f: F) -> PyResult<abnf_core::ParseResult>
123
+ pub fn call_lparse<F>(source_id: usize, f: F) -> PyResult<abnf_core::ParseResult>
103
124
  where
104
125
  F: FnOnce() -> abnf_core::ParseResult,
105
126
  {
@@ -112,6 +133,24 @@ where
112
133
  // identity. Nested entries -- a `PyCallbackParser` calling back
113
134
  // in -- share the epoch, so intra-parse reuse is preserved.
114
135
  let _scope = abnf_core::ParseScope::enter();
136
+
137
+ // ...but only while they are parsing the same text. A custom
138
+ // parser may call back in on any source it likes, and cache
139
+ // entries are keyed by position alone, so sharing an epoch across
140
+ // two sources let the inner parse answer the outer one's lookups
141
+ // and the outer parse returned a wrong result (issue #202).
142
+ //
143
+ // Compare the `str` object's identity, exactly as the pure-Python
144
+ // memo does, and give a genuinely different source its own epoch.
145
+ // Same-source re-entry -- the common case -- keeps sharing, which
146
+ // matters because an epoch change resets the caches it touches:
147
+ // claiming one unconditionally would wipe the enclosing parse's
148
+ // memo on every callback.
149
+ let previous_source = CURRENT_SOURCE_ID.with(Cell::get);
150
+ let _source_scope = (previous_source != source_id)
151
+ .then(abnf_core::SourceScope::enter);
152
+ CURRENT_SOURCE_ID.with(|id| id.set(source_id));
153
+ let _restore = RestoreSourceId(previous_source);
115
154
  match panic::catch_unwind(AssertUnwindSafe(f)) {
116
155
  Ok(result) => Ok(result),
117
156
  Err(payload) => {
File without changes
File without changes
File without changes