Skip to main content

lindera/
schema.rs

1//! Dictionary schema definitions.
2//!
3//! This module provides schema structures that define the format and fields
4//! of dictionary entries. The field-management logic is delegated to
5//! [`lindera_binding_core::CoreSchema`]; this module only adds the PyO3 wrappers.
6//!
7//! # Examples
8//!
9//! ```python
10//! # Create a custom schema
11//! schema = lindera.Schema([
12//!     "surface",
13//!     "left_context_id",
14//!     "right_context_id",
15//!     "cost",
16//!     "part_of_speech"
17//! ])
18//!
19//! # Use default schema
20//! schema = lindera.Schema.create_default()
21//!
22//! # Access field information
23//! index = schema.get_field_index("surface")
24//! field = schema.get_field_by_name("part_of_speech")
25//! ```
26
27use pyo3::prelude::*;
28
29use lindera::dictionary::{FieldDefinition, FieldType, Schema};
30use lindera_binding_core::{CoreFieldDefinition, CoreFieldType, CoreSchema};
31
32use crate::error::to_py_error;
33
34/// Field type in dictionary schema.
35///
36/// Defines the type of a field in the dictionary entry.
37#[pyclass(name = "FieldType", from_py_object)]
38#[derive(Debug, Clone)]
39pub enum PyFieldType {
40    /// Surface form (word text)
41    Surface,
42    /// Left context ID for morphological analysis
43    LeftContextId,
44    /// Right context ID for morphological analysis
45    RightContextId,
46    /// Word cost (used in path selection)
47    Cost,
48    /// Custom field (morphological features)
49    Custom,
50}
51
52#[pymethods]
53impl PyFieldType {
54    fn __str__(&self) -> &str {
55        match self {
56            PyFieldType::Surface => "surface",
57            PyFieldType::LeftContextId => "left_context_id",
58            PyFieldType::RightContextId => "right_context_id",
59            PyFieldType::Cost => "cost",
60            PyFieldType::Custom => "custom",
61        }
62    }
63
64    fn __repr__(&self) -> String {
65        format!("FieldType.{self:?}")
66    }
67}
68
69impl From<CoreFieldType> for PyFieldType {
70    fn from(field_type: CoreFieldType) -> Self {
71        match field_type {
72            CoreFieldType::Surface => PyFieldType::Surface,
73            CoreFieldType::LeftContextId => PyFieldType::LeftContextId,
74            CoreFieldType::RightContextId => PyFieldType::RightContextId,
75            CoreFieldType::Cost => PyFieldType::Cost,
76            CoreFieldType::Custom => PyFieldType::Custom,
77        }
78    }
79}
80
81impl From<PyFieldType> for CoreFieldType {
82    fn from(field_type: PyFieldType) -> Self {
83        match field_type {
84            PyFieldType::Surface => CoreFieldType::Surface,
85            PyFieldType::LeftContextId => CoreFieldType::LeftContextId,
86            PyFieldType::RightContextId => CoreFieldType::RightContextId,
87            PyFieldType::Cost => CoreFieldType::Cost,
88            PyFieldType::Custom => CoreFieldType::Custom,
89        }
90    }
91}
92
93impl From<FieldType> for PyFieldType {
94    fn from(field_type: FieldType) -> Self {
95        PyFieldType::from(CoreFieldType::from(field_type))
96    }
97}
98
99impl From<PyFieldType> for FieldType {
100    fn from(field_type: PyFieldType) -> Self {
101        FieldType::from(CoreFieldType::from(field_type))
102    }
103}
104
105/// Field definition in dictionary schema.
106///
107/// Describes a single field in the dictionary entry format.
108#[pyclass(name = "FieldDefinition", from_py_object)]
109#[derive(Debug, Clone)]
110pub struct PyFieldDefinition {
111    #[pyo3(get)]
112    pub index: usize,
113    #[pyo3(get)]
114    pub name: String,
115    #[pyo3(get)]
116    pub field_type: PyFieldType,
117    #[pyo3(get)]
118    pub description: Option<String>,
119}
120
121#[pymethods]
122impl PyFieldDefinition {
123    #[new]
124    pub fn new(
125        index: usize,
126        name: String,
127        field_type: PyFieldType,
128        description: Option<String>,
129    ) -> Self {
130        Self {
131            index,
132            name,
133            field_type,
134            description,
135        }
136    }
137
138    fn __str__(&self) -> String {
139        format!("FieldDefinition(index={}, name={})", self.index, self.name)
140    }
141
142    fn __repr__(&self) -> String {
143        format!(
144            "FieldDefinition(index={}, name='{}', field_type={:?}, description={:?})",
145            self.index, self.name, self.field_type, self.description
146        )
147    }
148}
149
150impl From<CoreFieldDefinition> for PyFieldDefinition {
151    fn from(field_def: CoreFieldDefinition) -> Self {
152        PyFieldDefinition {
153            index: field_def.index,
154            name: field_def.name,
155            field_type: field_def.field_type.into(),
156            description: field_def.description,
157        }
158    }
159}
160
161impl From<PyFieldDefinition> for CoreFieldDefinition {
162    fn from(field_def: PyFieldDefinition) -> Self {
163        CoreFieldDefinition {
164            index: field_def.index,
165            name: field_def.name,
166            field_type: field_def.field_type.into(),
167            description: field_def.description,
168        }
169    }
170}
171
172impl From<FieldDefinition> for PyFieldDefinition {
173    fn from(field_def: FieldDefinition) -> Self {
174        PyFieldDefinition::from(CoreFieldDefinition::from(field_def))
175    }
176}
177
178impl From<PyFieldDefinition> for FieldDefinition {
179    fn from(field_def: PyFieldDefinition) -> Self {
180        FieldDefinition::from(CoreFieldDefinition::from(field_def))
181    }
182}
183
184/// Dictionary schema definition.
185///
186/// A thin PyO3 wrapper over [`lindera_binding_core::CoreSchema`], which owns the
187/// field storage, the name-to-index map, and the field lookups.
188///
189/// # Examples
190///
191/// ```python
192/// # Create schema
193/// schema = lindera.Schema(["surface", "pos", "reading"])
194///
195/// # Query field information
196/// index = schema.get_field_index("pos")
197/// field = schema.get_field_by_name("reading")
198/// ```
199#[pyclass(name = "Schema", from_py_object)]
200#[derive(Debug, Clone)]
201pub struct PySchema {
202    /// The backing binding-core schema.
203    pub inner: CoreSchema,
204}
205
206#[pymethods]
207impl PySchema {
208    #[new]
209    pub fn new(fields: Vec<String>) -> Self {
210        Self {
211            inner: CoreSchema::new(fields),
212        }
213    }
214
215    #[staticmethod]
216    pub fn create_default() -> Self {
217        Self {
218            inner: CoreSchema::create_default(),
219        }
220    }
221
222    #[getter]
223    pub fn fields(&self) -> Vec<String> {
224        self.inner.fields().to_vec()
225    }
226
227    pub fn get_field_index(&self, field_name: &str) -> Option<usize> {
228        self.inner.get_field_index(field_name)
229    }
230
231    pub fn field_count(&self) -> usize {
232        self.inner.field_count()
233    }
234
235    pub fn get_field_name(&self, index: usize) -> Option<&str> {
236        self.inner.get_field_name(index)
237    }
238
239    pub fn get_custom_fields(&self) -> Vec<String> {
240        self.inner.get_custom_fields().to_vec()
241    }
242
243    pub fn get_all_fields(&self) -> Vec<String> {
244        self.inner.fields().to_vec()
245    }
246
247    pub fn get_field_by_name(&self, name: &str) -> Option<PyFieldDefinition> {
248        self.inner
249            .get_field_by_name(name)
250            .map(PyFieldDefinition::from)
251    }
252
253    pub fn validate_record(&self, record: Vec<String>) -> PyResult<()> {
254        self.inner.validate_record(&record).map_err(to_py_error)
255    }
256
257    fn __str__(&self) -> String {
258        format!("Schema(fields={})", self.inner.field_count())
259    }
260
261    fn __repr__(&self) -> String {
262        format!("Schema(fields={:?})", self.inner.fields())
263    }
264
265    fn __len__(&self) -> usize {
266        self.inner.field_count()
267    }
268}
269
270impl From<CoreSchema> for PySchema {
271    fn from(schema: CoreSchema) -> Self {
272        PySchema { inner: schema }
273    }
274}
275
276impl From<PySchema> for CoreSchema {
277    fn from(schema: PySchema) -> Self {
278        schema.inner
279    }
280}
281
282impl From<PySchema> for Schema {
283    fn from(schema: PySchema) -> Self {
284        schema.inner.into()
285    }
286}
287
288impl From<Schema> for PySchema {
289    fn from(schema: Schema) -> Self {
290        PySchema {
291            inner: CoreSchema::from(schema),
292        }
293    }
294}
295
296pub fn register(parent_module: &Bound<'_, PyModule>) -> PyResult<()> {
297    let py = parent_module.py();
298    let m = PyModule::new(py, "schema")?;
299    m.add_class::<PySchema>()?;
300    m.add_class::<PyFieldDefinition>()?;
301    m.add_class::<PyFieldType>()?;
302    parent_module.add_submodule(&m)?;
303    Ok(())
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use lindera::dictionary::{FieldDefinition, FieldType, Schema};
310
311    #[test]
312    fn test_pyfieldtype_to_fieldtype_all_variants() {
313        for (py, ft) in [
314            (PyFieldType::Surface, FieldType::Surface),
315            (PyFieldType::LeftContextId, FieldType::LeftContextId),
316            (PyFieldType::RightContextId, FieldType::RightContextId),
317            (PyFieldType::Cost, FieldType::Cost),
318            (PyFieldType::Custom, FieldType::Custom),
319        ] {
320            let converted: FieldType = py.into();
321            assert_eq!(converted, ft);
322        }
323    }
324
325    #[test]
326    fn test_fieldtype_to_pyfieldtype_all_variants() {
327        assert!(matches!(
328            PyFieldType::from(FieldType::Surface),
329            PyFieldType::Surface
330        ));
331        assert!(matches!(
332            PyFieldType::from(FieldType::Custom),
333            PyFieldType::Custom
334        ));
335    }
336
337    #[test]
338    fn test_pyfielddefinition_to_fielddefinition() {
339        let py_fd = PyFieldDefinition {
340            index: 0,
341            name: "surface".to_string(),
342            field_type: PyFieldType::Surface,
343            description: Some("Surface form".to_string()),
344        };
345        let fd: FieldDefinition = py_fd.into();
346        assert_eq!(fd.index, 0);
347        assert_eq!(fd.name, "surface");
348        assert!(matches!(fd.field_type, FieldType::Surface));
349        assert_eq!(fd.description, Some("Surface form".to_string()));
350    }
351
352    #[test]
353    fn test_fielddefinition_to_pyfielddefinition() {
354        let fd = FieldDefinition {
355            index: 4,
356            name: "pos".to_string(),
357            field_type: FieldType::Custom,
358            description: None,
359        };
360        let py_fd: PyFieldDefinition = fd.into();
361        assert_eq!(py_fd.index, 4);
362        assert_eq!(py_fd.name, "pos");
363        assert!(matches!(py_fd.field_type, PyFieldType::Custom));
364        assert!(py_fd.description.is_none());
365    }
366
367    #[test]
368    fn test_pyschema_to_schema() {
369        let py_schema = PySchema::new(vec![
370            "surface".to_string(),
371            "left_context_id".to_string(),
372            "right_context_id".to_string(),
373            "cost".to_string(),
374            "pos".to_string(),
375        ]);
376        let schema: Schema = py_schema.into();
377        let fields = schema.get_all_fields();
378        assert_eq!(fields.len(), 5);
379        assert_eq!(fields[0], "surface");
380        assert_eq!(fields[4], "pos");
381    }
382
383    #[test]
384    fn test_schema_to_pyschema() {
385        let schema = Schema::new(vec![
386            "surface".to_string(),
387            "left_context_id".to_string(),
388            "right_context_id".to_string(),
389            "cost".to_string(),
390        ]);
391        let py_schema: PySchema = schema.into();
392        assert_eq!(py_schema.fields().len(), 4);
393        assert_eq!(py_schema.fields()[0], "surface");
394    }
395
396    #[test]
397    fn test_pyschema_index_and_name_lookups() {
398        let schema = PySchema::new(vec![
399            "surface".to_string(),
400            "pos".to_string(),
401            "reading".to_string(),
402        ]);
403        assert_eq!(schema.get_field_index("surface"), Some(0));
404        assert_eq!(schema.get_field_index("reading"), Some(2));
405        assert_eq!(schema.get_field_index("nonexistent"), None);
406        assert_eq!(schema.get_field_name(1), Some("pos"));
407        assert_eq!(schema.get_field_name(9), None);
408        assert_eq!(schema.field_count(), 3);
409    }
410
411    #[test]
412    fn test_pyschema_custom_fields() {
413        let schema = PySchema::new(vec![
414            "surface".to_string(),
415            "left_context_id".to_string(),
416            "right_context_id".to_string(),
417            "cost".to_string(),
418            "major_pos".to_string(),
419            "reading".to_string(),
420        ]);
421        let custom = schema.get_custom_fields();
422        assert_eq!(custom, ["major_pos", "reading"]);
423    }
424
425    #[test]
426    fn test_pyschema_create_default() {
427        let schema = PySchema::create_default();
428        assert_eq!(schema.field_count(), 13);
429        assert_eq!(schema.fields()[0], "surface");
430        assert_eq!(schema.fields()[5], "pos_detail_1");
431        assert_eq!(schema.fields()[12], "pronunciation");
432        assert_eq!(schema.get_field_index("cost"), Some(3));
433    }
434
435    #[test]
436    fn test_pyschema_get_field_by_name() {
437        let schema = PySchema::create_default();
438        let surface = schema.get_field_by_name("surface").unwrap();
439        assert_eq!(surface.index, 0);
440        assert!(matches!(surface.field_type, PyFieldType::Surface));
441
442        let custom = schema.get_field_by_name("major_pos").unwrap();
443        assert_eq!(custom.index, 4);
444        assert!(matches!(custom.field_type, PyFieldType::Custom));
445
446        assert!(schema.get_field_by_name("nonexistent").is_none());
447    }
448
449    // Note: `validate_record` is intentionally not unit-tested here. It maps
450    // `CoreError` to a Python exception via `to_py_error`, which references the
451    // Python C-API exception types; exercising it would pull those symbols into
452    // the standalone `cargo test --lib` binary, which (with pyo3
453    // `extension-module`) does not link libpython. The logic is covered by
454    // `lindera-binding-core`'s `core_schema_validate_record` test and by the
455    // Python pytest suite.
456
457    #[test]
458    fn test_pyschema_roundtrip() {
459        let fields = vec![
460            "surface".to_string(),
461            "left_context_id".to_string(),
462            "right_context_id".to_string(),
463            "cost".to_string(),
464            "pos".to_string(),
465        ];
466        let py_schema = PySchema::new(fields.clone());
467        let schema: Schema = py_schema.into();
468        let roundtripped: PySchema = schema.into();
469        assert_eq!(roundtripped.fields(), fields);
470    }
471}