From a408f5155f4b76d62ab6d83718947c8d456b9755 Mon Sep 17 00:00:00 2001 From: "Alexey O. Shigarov" Date: Sat, 11 Jul 2026 13:23:01 +0800 Subject: [PATCH] core: python cargo feature, compile_permissive, error positions on RtlCompileError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prerequisites for rtl-lsp (vscode-rtl plan §3.2): - New cargo feature `python` (default on) gates every pyo3 touch point: the py module, CoreErr::Py, PyFunc (uninhabited enum without the feature), EvalEnv.py_table (via the PyTableHandle alias) and the External/Custom callback dispatch. `cargo build --no-default-features` yields the pure-Rust core; CI runs clippy + tests in that mode. - Enum pyclass attributes moved to cfg_attr with class-level rename_all = "SCREAMING_SNAKE_CASE" (variant-level #[pyo3(name)] helpers do not survive cfg_attr); Python-visible names are unchanged (pytest: 1908). - compile_permissive(rtl): unbound EXT('name') becomes the always-true ExternalUnbound stub instead of a compile error, for standalone editing without host-runtime Bindings; everything else is checked as in compile. - RtlCompileError now exposes .line (1-based) / .col (0-based) attributes, None when unknown. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 5 +++ Cargo.toml | 9 ++++- src/interp.rs | 16 ++++---- src/lib.rs | 3 ++ src/py.rs | 12 +++++- src/rtl/build.rs | 20 ++++++++++ src/rtl/mod.rs | 19 ++++++++- src/spec.rs | 85 ++++++++++++++++++++++++---------------- src/syntax.rs | 17 ++------ src/tests.rs | 28 ++++++++++++- src/util.rs | 2 + 11 files changed, 157 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a57b2e4..bf13881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,11 @@ jobs: - name: Lint (clippy, blocking) if: matrix.os == 'ubuntu-latest' run: cargo clippy --all-targets -- -D warnings + - name: Pure-Rust core (no python feature, consumed by rtl-lsp) + if: matrix.os == 'ubuntu-latest' + run: | + cargo clippy --all-targets --no-default-features -- -D warnings + cargo test --no-default-features - name: Build and install run: | pip install maturin pytest diff --git a/Cargo.toml b/Cargo.toml index db6a40e..ccc4bbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,15 @@ license = "MIT" name = "pyregtab" crate-type = ["cdylib", "rlib"] +[features] +# The Python bindings (pyo3 module, External/Custom callbacks). Disable with +# `--no-default-features` to build the pure-Rust core (RTL compiler, matcher, +# interpreter) for embedding, e.g. in rtl-lsp. +default = ["python"] +python = ["dep:pyo3"] + [dependencies] -pyo3 = { version = "0.23", features = ["abi3-py310"] } +pyo3 = { version = "0.23", features = ["abi3-py310"], optional = true } regex = "1" indexmap = "2" diff --git a/src/interp.rs b/src/interp.rs index 909b0c0..6b25c89 100644 --- a/src/interp.rs +++ b/src/interp.rs @@ -7,21 +7,17 @@ use crate::syntax::SyntaxCore; use crate::util::CoreResult; use std::collections::HashMap; -#[pyo3::pyclass(eq, eq_int, name = "SchemaConstructionStrategy")] +#[cfg_attr(feature = "python", pyo3::pyclass(eq, eq_int, name = "SchemaConstructionStrategy", rename_all = "SCREAMING_SNAKE_CASE"))] #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum SchemaStrategy { - #[pyo3(name = "RECORD_FIRST")] RecordFirst, - #[pyo3(name = "POSITION_FIRST")] PositionFirst, } -#[pyo3::pyclass(eq, eq_int, name = "ActionApplicationStrategy")] +#[cfg_attr(feature = "python", pyo3::pyclass(eq, eq_int, name = "ActionApplicationStrategy", rename_all = "SCREAMING_SNAKE_CASE"))] #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ActionStrategy { - #[pyo3(name = "ROW_FIRST")] RowFirst, - #[pyo3(name = "COLUMN_FIRST")] ColumnFirst, } @@ -49,7 +45,7 @@ pub fn interpret( cfg: &InterpreterCfg, syntax: &SyntaxCore, sem: &SemanticsCore, - py_table: Option<&pyo3::Py>, + py_table: Option<&crate::spec::PyTableHandle>, ) -> CoreResult { let env = EvalEnv { syntax, py_table }; @@ -318,6 +314,12 @@ fn generate_records( fn handle_missing(cfg: &InterpreterCfg, attribute: &str) -> CoreResult> { match &cfg.missing_value_handler { None => Ok(None), + #[cfg(feature = "python")] Some(f) => crate::py::call_missing_handler(f, attribute), + #[cfg(not(feature = "python"))] + Some(f) => { + let _ = attribute; + match *f {} + } } } diff --git a/src/lib.rs b/src/lib.rs index 3934ee4..8b949ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod interp; pub mod matcher; +#[cfg(feature = "python")] pub mod py; pub mod recordset; pub mod rtl; @@ -13,8 +14,10 @@ pub mod syntax; mod tests; pub mod util; +#[cfg(feature = "python")] use pyo3::prelude::*; +#[cfg(feature = "python")] #[pymodule] fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { // syntax diff --git a/src/py.rs b/src/py.rs index 32d412a..6480f8e 100644 --- a/src/py.rs +++ b/src/py.rs @@ -33,7 +33,17 @@ fn rtl_err(e: RtlErr) -> PyErr { } else { format!("RTL compile error: {}", e.msg) }; - RtlCompileError::new_err(msg) + let err = RtlCompileError::new_err(msg); + // Expose the source position programmatically: err.line (1-based) and + // err.col (0-based), both None when unknown. + Python::with_gil(|py| { + let value = err.value(py); + let line = if e.line >= 0 { Some(e.line) } else { None }; + let col = if e.col >= 0 { Some(e.col) } else { None }; + let _ = value.setattr("line", line); + let _ = value.setattr("col", col); + }); + err } // ================================================================ callbacks diff --git a/src/rtl/build.rs b/src/rtl/build.rs index 55ad136..a33d41e 100644 --- a/src/rtl/build.rs +++ b/src/rtl/build.rs @@ -10,6 +10,10 @@ use std::sync::Arc; pub struct ATPBuilder<'a> { bindings: &'a BindingsCore, + /// Permissive mode: an `EXT('name')` with no binding becomes an + /// always-true `ExternalUnbound` stub instead of a compile error + /// (standalone editing without a host runtime, e.g. rtl-lsp). + permissive: bool, inherited_stack: Vec>, cell_frags: HashMap>, row_frags: HashMap, @@ -45,8 +49,13 @@ fn build_extractor(steps: &[PStep]) -> Extractor { impl<'a> ATPBuilder<'a> { pub fn new(bindings: &'a BindingsCore) -> Self { + Self::with_permissive(bindings, false) + } + + pub fn with_permissive(bindings: &'a BindingsCore, permissive: bool) -> Self { ATPBuilder { bindings, + permissive, inherited_stack: Vec::new(), cell_frags: HashMap::new(), row_frags: HashMap::new(), @@ -647,7 +656,13 @@ impl<'a> ATPBuilder<'a> { )); } match self.bindings.filter.get(name) { + // Unreachable without the `python` feature: PyFunc is + // uninhabited there, so the map is always empty. + #[allow(unreachable_code)] Some(f) => FilterTerm::External { name: name.clone(), func: f.clone() }, + None if self.permissive => { + FilterTerm::ExternalUnbound { name: name.clone() } + } None => { let hint = if self.bindings.cell.contains_key(name) { format!( @@ -690,7 +705,12 @@ impl<'a> ATPBuilder<'a> { )); } match self.bindings.cell.get(name) { + // Unreachable without the `python` feature (see above). + #[allow(unreachable_code)] Some(f) => CellPredicate::External { name: name.clone(), func: f.clone() }, + None if self.permissive => { + CellPredicate::ExternalUnbound { name: name.clone() } + } None => { let hint = if self.bindings.filter.contains_key(name) { format!( diff --git a/src/rtl/mod.rs b/src/rtl/mod.rs index 0610239..e415cb6 100644 --- a/src/rtl/mod.rs +++ b/src/rtl/mod.rs @@ -32,6 +32,7 @@ impl From for RtlErr { fn from(e: CoreErr) -> Self { match e { CoreErr::Msg(m) => RtlErr::new(m), + #[cfg(feature = "python")] CoreErr::Py(e) => RtlErr::new(format!("{e}")), } } @@ -46,6 +47,22 @@ pub struct BindingsCore { /// Port of `RtlCompiler.compile(rtl, bindings)`. pub fn compile(rtl: &str, bindings: &BindingsCore) -> Result { + compile_inner(rtl, bindings, false) +} + +/// Permissive compilation for standalone editing (rtl-lsp): any `EXT('name')` +/// is considered bound via an always-true stub, so `.rtl` files that rely on +/// host-runtime `Bindings` do not produce false errors. Everything else is +/// checked exactly as in [`compile`]. +pub fn compile_permissive(rtl: &str) -> Result { + compile_inner(rtl, &BindingsCore::default(), true) +} + +fn compile_inner( + rtl: &str, + bindings: &BindingsCore, + permissive: bool, +) -> Result { let tokens = lexer::lex(rtl)?; let tree = parser::parse(tokens)?; @@ -121,7 +138,7 @@ pub fn compile(rtl: &str, bindings: &BindingsCore) -> Result>); +#[cfg(feature = "python")] impl PartialEq for PyFunc { fn eq(&self, other: &Self) -> bool { self.0.as_ptr() == other.0.as_ptr() } } +/// Without the `python` feature there are no Python callables. The type stays +/// so enum shapes are identical in both modes, but it is uninhabited: variants +/// holding a `PyFunc` provably cannot be constructed. +#[cfg(not(feature = "python"))] +#[derive(Clone, Debug, PartialEq)] +pub enum PyFunc {} + +/// Handle to the Python-side `pyregtab._core.TableSyntax`, passed to +/// External/Custom callbacks. Uninhabited without the `python` feature. +#[cfg(feature = "python")] +pub type PyTableHandle = pyo3::Py; +#[cfg(not(feature = "python"))] +pub type PyTableHandle = std::convert::Infallible; + /// Evaluation environment threaded through predicate evaluation. /// `py_table` (a `pyregtab._core.TableSyntax` handle) is required only when /// External/Custom Python callbacks participate. pub struct EvalEnv<'a> { pub syntax: &'a SyntaxCore, - pub py_table: Option<&'a pyo3::Py>, + pub py_table: Option<&'a PyTableHandle>, } // ---------------------------------------------------------------- CellPredicate @@ -189,6 +180,8 @@ pub enum CellPredicate { NotContains(String), External { name: String, func: PyFunc }, Custom { description: String, func: PyFunc }, + /// Permissive-mode stub for `EXT('name')` with no binding: always true. + ExternalUnbound { name: String }, } impl CellPredicate { @@ -200,9 +193,16 @@ impl CellPredicate { CellPredicate::NotRegex(p) => Ok(!full_match(p, &cell.text)?), CellPredicate::Contains(s) => Ok(cell.text.contains(s.as_str())), CellPredicate::NotContains(s) => Ok(!cell.text.contains(s.as_str())), + #[cfg(feature = "python")] CellPredicate::External { func, .. } | CellPredicate::Custom { func, .. } => { crate::py::call_cell_predicate(func, env, cell.row, cell.col) } + #[cfg(not(feature = "python"))] + CellPredicate::External { func, .. } | CellPredicate::Custom { func, .. } => { + let _ = env; + match *func {} + } + CellPredicate::ExternalUnbound { .. } => Ok(true), } } @@ -214,7 +214,9 @@ impl CellPredicate { CellPredicate::NotRegex(p) => format!("!\"{p}\""), CellPredicate::Contains(s) => format!("~\"{s}\""), CellPredicate::NotContains(s) => format!("!~\"{s}\""), - CellPredicate::External { name, .. } => format!("EXT('{name}')"), + CellPredicate::External { name, .. } | CellPredicate::ExternalUnbound { name } => { + format!("EXT('{name}')") + } CellPredicate::Custom { description, .. } => { return Err(format!("Custom CellPredicate has no RTL analog: {description}").into()) } @@ -258,6 +260,8 @@ pub enum FilterTerm { SameStr, External { name: String, func: PyFunc }, Custom { description: String, func: PyFunc }, + /// Permissive-mode stub for `EXT('name')` with no binding: always true. + ExternalUnbound { name: String }, } fn same_subrow(a: &CellItem, c: &CellItem, env: &EvalEnv) -> bool { @@ -321,9 +325,13 @@ impl FilterTerm { FilterTerm::Tagged(t) => c.tags.iter().any(|x| x == t), FilterTerm::NotTagged(t) => !c.tags.iter().any(|x| x == t), FilterTerm::SameStr => c.s == a.s, + #[cfg(feature = "python")] FilterTerm::External { func, .. } | FilterTerm::Custom { func, .. } => { return crate::py::call_item_filter(func, env, a, c); } + #[cfg(not(feature = "python"))] + FilterTerm::External { func, .. } | FilterTerm::Custom { func, .. } => match *func {}, + FilterTerm::ExternalUnbound { .. } => true, }) } @@ -384,7 +392,9 @@ impl FilterTerm { FilterTerm::Tagged(t) => quoted_tag(t), FilterTerm::NotTagged(t) => format!("!{}", quoted_tag(t)), FilterTerm::SameStr => "STR".into(), - FilterTerm::External { name, .. } => format!("EXT('{name}')"), + FilterTerm::External { name, .. } | FilterTerm::ExternalUnbound { name } => { + format!("EXT('{name}')") + } FilterTerm::Custom { .. } => { return Err("Custom constraint has no RTL analog".into()); } @@ -430,7 +440,10 @@ impl FilterCond { } Ok(false) } + #[cfg(feature = "python")] FilterCond::Custom { func, .. } => crate::py::call_item_filter(func, env, a, c), + #[cfg(not(feature = "python"))] + FilterCond::Custom { func, .. } => match *func {}, } } @@ -496,7 +509,10 @@ impl Extractor { } cur } + #[cfg(feature = "python")] Extractor::Custom { func, .. } => crate::py::call_extractor(func, input)?, + #[cfg(not(feature = "python"))] + Extractor::Custom { func, .. } => match *func {}, }) } @@ -886,6 +902,7 @@ impl TablePattern { impl CellPredicate { pub fn has_py(&self) -> bool { + // ExternalUnbound is a native always-true stub: no GIL needed. matches!(self, CellPredicate::External { .. } | CellPredicate::Custom { .. }) } } diff --git a/src/syntax.rs b/src/syntax.rs index e01af99..5814e50 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -6,40 +6,29 @@ use crate::util::{java_is_blank, CoreResult}; -#[pyo3::pyclass(eq, eq_int)] +#[cfg_attr(feature = "python", pyo3::pyclass(eq, eq_int, rename_all = "SCREAMING_SNAKE_CASE"))] #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum FontFamily { - #[pyo3(name = "SERIF")] Serif, - #[pyo3(name = "SANS_SERIF")] SansSerif, - #[pyo3(name = "MONOSPACED")] Monospaced, } -#[pyo3::pyclass(eq, eq_int)] +#[cfg_attr(feature = "python", pyo3::pyclass(eq, eq_int, rename_all = "SCREAMING_SNAKE_CASE"))] #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum HorizontalAlignment { - #[pyo3(name = "LEFT")] Left, - #[pyo3(name = "CENTER")] Center, - #[pyo3(name = "RIGHT")] Right, - #[pyo3(name = "JUSTIFY")] Justify, } -#[pyo3::pyclass(eq, eq_int)] +#[cfg_attr(feature = "python", pyo3::pyclass(eq, eq_int, rename_all = "SCREAMING_SNAKE_CASE"))] #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum VerticalAlignment { - #[pyo3(name = "TOP")] Top, - #[pyo3(name = "CENTER")] Center, - #[pyo3(name = "BOTTOM")] Bottom, - #[pyo3(name = "JUSTIFY")] Justify, } diff --git a/src/tests.rs b/src/tests.rs index f00ef69..06111f8 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -3,7 +3,7 @@ use crate::interp::{interpret, InterpreterCfg}; use crate::matcher::match_atp; -use crate::rtl::{compile, serialize::serialize, BindingsCore}; +use crate::rtl::{compile, compile_permissive, serialize::serialize, BindingsCore}; use crate::syntax::SyntaxCore; use std::fs; use std::path::PathBuf; @@ -63,6 +63,32 @@ fn conformance_negative_rejected() { assert!(n >= 15, "expected at least 15 negative cases, got {n}"); } +#[test] +fn permissive_binds_ext_as_always_true_stub() { + // Strict compilation rejects unbound EXT names… + let rtl = "[ [EXT('mypred') ? VAL : (LT & EXT('myfilter'))->REC] ]+"; + assert!(compile(rtl, &BindingsCore::default()).is_err()); + + // …permissive compilation accepts them and serializes them back verbatim. + let pattern = compile_permissive(rtl).expect("permissive compile must succeed"); + let canonical = serialize(&pattern).unwrap(); + assert!(canonical.contains("EXT('mypred')"), "canonical: {canonical}"); + assert!(canonical.contains("EXT('myfilter')"), "canonical: {canonical}"); + + // Everything else is still checked: a genuinely broken pattern fails + // with a position even in permissive mode. + let err = compile_permissive("[ [VAL : ->REC] ]").unwrap_err(); + assert_eq!((err.line, err.col), (1, 3)); + + // And the EXT stub behaves as always-true end to end: the cell condition + // does not reject any cell. + let mut syntax = SyntaxCore::new(1, 1).unwrap(); + syntax.cell_mut(0, 0).set_text("x".to_string()); + let pattern = compile_permissive("[ [EXT('anything') ? VAL : ()->REC] ]").unwrap(); + let sem = match_atp(&pattern, &mut syntax, Vec::new()).unwrap(); + assert!(sem.is_some(), "EXT stub must not reject the cell"); +} + #[test] fn end_to_end_match_and_interpret() { let mut syntax = SyntaxCore::new(3, 3).unwrap(); diff --git a/src/util.rs b/src/util.rs index 734e9db..922b7c4 100644 --- a/src/util.rs +++ b/src/util.rs @@ -13,6 +13,7 @@ use std::sync::{Arc, Mutex, OnceLock}; #[derive(Debug)] pub enum CoreErr { Msg(String), + #[cfg(feature = "python")] Py(pyo3::PyErr), } @@ -26,6 +27,7 @@ impl From<&str> for CoreErr { CoreErr::Msg(s.to_string()) } } +#[cfg(feature = "python")] impl From for CoreErr { fn from(e: pyo3::PyErr) -> Self { CoreErr::Py(e)