Skip to main content

lindera_binding_core/
schema.rs

1//! Shared dictionary-schema helpers for the language bindings.
2//!
3//! The Python/PHP/Ruby/Node.js `Schema` wrappers each reimplemented an
4//! identical default field list and CSV-record validation. Those are
5//! collected here so the logic lives in one place.
6
7use lindera::dictionary::{FieldDefinition, FieldType, Schema};
8
9use crate::error::{CoreError, CoreResult};
10
11/// Default dictionary schema field names shared by all bindings'
12/// `Schema.create_default()`.
13///
14/// These match [`lindera::dictionary::Schema::default()`] exactly, including the
15/// `pos_detail_1/2/3` POS-detail field names. (Earlier the Python/PHP/Ruby/Node.js
16/// bindings used `middle_pos/small_pos/fine_pos` while WASM used `pos_detail_*`;
17/// that inconsistency was reconciled onto `pos_detail_*` in v4.0.0.)
18pub fn default_dictionary_fields() -> Vec<String> {
19    [
20        "surface",
21        "left_context_id",
22        "right_context_id",
23        "cost",
24        "major_pos",
25        "pos_detail_1",
26        "pos_detail_2",
27        "pos_detail_3",
28        "conjugation_type",
29        "conjugation_form",
30        "base_form",
31        "reading",
32        "pronunciation",
33    ]
34    .into_iter()
35    .map(String::from)
36    .collect()
37}
38
39/// Validates a CSV record against the given schema field names.
40///
41/// Returns `Err(message)` if the record has fewer fields than the schema
42/// requires, or if any schema field is present but empty. The caller maps the
43/// message onto its own FFI exception type.
44pub fn validate_record(fields: &[String], record: &[String]) -> Result<(), String> {
45    if record.len() < fields.len() {
46        return Err(format!(
47            "CSV row has {} fields but schema requires {} fields",
48            record.len(),
49            fields.len()
50        ));
51    }
52
53    for (index, field_name) in fields.iter().enumerate() {
54        if index < record.len() && record[index].trim().is_empty() {
55            return Err(format!("Field {field_name} is missing or empty"));
56        }
57    }
58
59    Ok(())
60}
61
62/// Category of a schema field, decoupled from [`lindera::dictionary::FieldType`]
63/// so the bindings depend only on `lindera-binding-core`.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum CoreFieldType {
66    /// Surface form (word text).
67    Surface,
68    /// Left context id used by the connection matrix.
69    LeftContextId,
70    /// Right context id used by the connection matrix.
71    RightContextId,
72    /// Word cost used in path selection.
73    Cost,
74    /// Custom field (a morphological feature).
75    Custom,
76}
77
78impl CoreFieldType {
79    /// Returns a stable, lowercase identifier for the field type.
80    pub fn as_str(&self) -> &'static str {
81        match self {
82            CoreFieldType::Surface => "surface",
83            CoreFieldType::LeftContextId => "left_context_id",
84            CoreFieldType::RightContextId => "right_context_id",
85            CoreFieldType::Cost => "cost",
86            CoreFieldType::Custom => "custom",
87        }
88    }
89}
90
91impl From<FieldType> for CoreFieldType {
92    /// Converts a lindera [`FieldType`] into a [`CoreFieldType`].
93    fn from(field_type: FieldType) -> Self {
94        match field_type {
95            FieldType::Surface => CoreFieldType::Surface,
96            FieldType::LeftContextId => CoreFieldType::LeftContextId,
97            FieldType::RightContextId => CoreFieldType::RightContextId,
98            FieldType::Cost => CoreFieldType::Cost,
99            FieldType::Custom => CoreFieldType::Custom,
100        }
101    }
102}
103
104impl From<CoreFieldType> for FieldType {
105    /// Converts a [`CoreFieldType`] into a lindera [`FieldType`].
106    fn from(field_type: CoreFieldType) -> Self {
107        match field_type {
108            CoreFieldType::Surface => FieldType::Surface,
109            CoreFieldType::LeftContextId => FieldType::LeftContextId,
110            CoreFieldType::RightContextId => FieldType::RightContextId,
111            CoreFieldType::Cost => FieldType::Cost,
112            CoreFieldType::Custom => FieldType::Custom,
113        }
114    }
115}
116
117/// A single schema field definition, decoupled from
118/// [`lindera::dictionary::FieldDefinition`].
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CoreFieldDefinition {
121    /// Zero-based position of the field in the schema.
122    pub index: usize,
123    /// Field name.
124    pub name: String,
125    /// Field category.
126    pub field_type: CoreFieldType,
127    /// Optional human-readable description.
128    pub description: Option<String>,
129}
130
131impl From<FieldDefinition> for CoreFieldDefinition {
132    /// Converts a lindera [`FieldDefinition`] into a [`CoreFieldDefinition`].
133    fn from(field_def: FieldDefinition) -> Self {
134        CoreFieldDefinition {
135            index: field_def.index,
136            name: field_def.name,
137            field_type: field_def.field_type.into(),
138            description: field_def.description,
139        }
140    }
141}
142
143impl From<CoreFieldDefinition> for FieldDefinition {
144    /// Converts a [`CoreFieldDefinition`] into a lindera [`FieldDefinition`].
145    fn from(field_def: CoreFieldDefinition) -> Self {
146        FieldDefinition {
147            index: field_def.index,
148            name: field_def.name,
149            field_type: field_def.field_type.into(),
150            description: field_def.description,
151        }
152    }
153}
154
155/// Dictionary schema shared by the bindings.
156///
157/// Owns the field storage, the name-to-index map, and the field lookups by
158/// delegating to [`lindera::dictionary::Schema`], so each binding can wrap a
159/// `CoreSchema` instead of reimplementing the same logic.
160#[derive(Debug, Clone)]
161pub struct CoreSchema {
162    /// The backing lindera schema that owns the fields and the index map.
163    inner: Schema,
164}
165
166impl CoreSchema {
167    /// Creates a schema from the given ordered field names.
168    pub fn new(fields: Vec<String>) -> Self {
169        Self {
170            inner: Schema::new(fields),
171        }
172    }
173
174    /// Creates the default binding schema (see [`default_dictionary_fields`]).
175    pub fn create_default() -> Self {
176        Self::new(default_dictionary_fields())
177    }
178
179    /// Returns all field names in order.
180    pub fn fields(&self) -> &[String] {
181        self.inner.get_all_fields()
182    }
183
184    /// Returns the index of `field_name`, if present.
185    pub fn get_field_index(&self, field_name: &str) -> Option<usize> {
186        self.inner.get_field_index(field_name)
187    }
188
189    /// Returns the total number of fields.
190    pub fn field_count(&self) -> usize {
191        self.inner.field_count()
192    }
193
194    /// Returns the field name at `index`, if present.
195    pub fn get_field_name(&self, index: usize) -> Option<&str> {
196        self.inner.get_field_name(index)
197    }
198
199    /// Returns the custom fields (everything after the four system fields).
200    pub fn get_custom_fields(&self) -> &[String] {
201        self.inner.get_custom_fields()
202    }
203
204    /// Returns the [`CoreFieldDefinition`] for `name`, if present.
205    pub fn get_field_by_name(&self, name: &str) -> Option<CoreFieldDefinition> {
206        self.inner
207            .get_field_by_name(name)
208            .map(CoreFieldDefinition::from)
209    }
210
211    /// Validates a CSV record against this schema.
212    ///
213    /// Returns an [`ErrorKind::Validation`](crate::ErrorKind::Validation) error
214    /// when the record has too few fields or a required field is empty.
215    pub fn validate_record(&self, record: &[String]) -> CoreResult<()> {
216        validate_record(self.fields(), record).map_err(CoreError::validation)
217    }
218
219    /// Borrows the backing lindera schema.
220    pub fn as_lindera(&self) -> &Schema {
221        &self.inner
222    }
223
224    /// Consumes this schema and returns the backing lindera schema.
225    pub fn into_lindera(self) -> Schema {
226        self.inner
227    }
228}
229
230impl From<Schema> for CoreSchema {
231    /// Wraps a lindera [`Schema`] in a [`CoreSchema`].
232    fn from(schema: Schema) -> Self {
233        Self { inner: schema }
234    }
235}
236
237impl From<CoreSchema> for Schema {
238    /// Unwraps the backing lindera [`Schema`].
239    fn from(schema: CoreSchema) -> Self {
240        schema.inner
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn default_fields_count() {
250        assert_eq!(default_dictionary_fields().len(), 13);
251        assert_eq!(default_dictionary_fields()[0], "surface");
252        assert_eq!(default_dictionary_fields()[5], "pos_detail_1");
253    }
254
255    #[test]
256    fn validate_ok() {
257        let fields = default_dictionary_fields();
258        let record: Vec<String> = (0..13).map(|i| format!("v{i}")).collect();
259        assert!(validate_record(&fields, &record).is_ok());
260    }
261
262    #[test]
263    fn validate_too_few_fields() {
264        let fields = default_dictionary_fields();
265        let record = vec!["a".to_string(), "b".to_string()];
266        let err = validate_record(&fields, &record).unwrap_err();
267        assert!(err.contains("requires 13 fields"));
268    }
269
270    #[test]
271    fn validate_empty_field() {
272        let fields = vec!["surface".to_string(), "reading".to_string()];
273        let record = vec!["x".to_string(), "  ".to_string()];
274        let err = validate_record(&fields, &record).unwrap_err();
275        assert!(err.contains("reading"));
276    }
277
278    #[test]
279    fn core_field_type_roundtrips_with_lindera() {
280        for ft in [
281            FieldType::Surface,
282            FieldType::LeftContextId,
283            FieldType::RightContextId,
284            FieldType::Cost,
285            FieldType::Custom,
286        ] {
287            let core: CoreFieldType = ft.clone().into();
288            let back: FieldType = core.into();
289            assert_eq!(back, ft);
290        }
291        assert_eq!(CoreFieldType::Surface.as_str(), "surface");
292        assert_eq!(CoreFieldType::Custom.as_str(), "custom");
293    }
294
295    #[test]
296    fn core_field_definition_roundtrips_with_lindera() {
297        let fd = FieldDefinition {
298            index: 4,
299            name: "major_pos".to_string(),
300            field_type: FieldType::Custom,
301            description: None,
302        };
303        let core: CoreFieldDefinition = fd.clone().into();
304        assert_eq!(core.index, 4);
305        assert_eq!(core.name, "major_pos");
306        assert_eq!(core.field_type, CoreFieldType::Custom);
307        let back: FieldDefinition = core.into();
308        assert_eq!(back.index, fd.index);
309        assert_eq!(back.name, fd.name);
310    }
311
312    #[test]
313    fn core_schema_default_matches_lindera() {
314        let schema = CoreSchema::create_default();
315        assert_eq!(schema.field_count(), 13);
316        assert_eq!(schema.fields()[0], "surface");
317        // The default now matches `lindera::dictionary::Schema::default()`,
318        // including the `pos_detail_*` POS-detail names (unified in v4.0.0).
319        assert_eq!(schema.fields()[5], "pos_detail_1");
320        assert_eq!(schema.fields()[12], "pronunciation");
321        assert_eq!(
322            schema.fields(),
323            lindera::dictionary::Schema::default().get_all_fields()
324        );
325    }
326
327    #[test]
328    fn core_schema_lookups() {
329        let schema = CoreSchema::new(vec![
330            "surface".to_string(),
331            "left_context_id".to_string(),
332            "right_context_id".to_string(),
333            "cost".to_string(),
334            "major_pos".to_string(),
335            "reading".to_string(),
336        ]);
337        assert_eq!(schema.get_field_index("surface"), Some(0));
338        assert_eq!(schema.get_field_index("reading"), Some(5));
339        assert_eq!(schema.get_field_index("nonexistent"), None);
340        assert_eq!(schema.get_field_name(4), Some("major_pos"));
341        assert_eq!(schema.get_field_name(99), None);
342        assert_eq!(schema.get_custom_fields(), ["major_pos", "reading"]);
343    }
344
345    #[test]
346    fn core_schema_get_field_by_name_classifies_type() {
347        let schema = CoreSchema::create_default();
348        let surface = schema.get_field_by_name("surface").unwrap();
349        assert_eq!(surface.index, 0);
350        assert_eq!(surface.field_type, CoreFieldType::Surface);
351
352        let custom = schema.get_field_by_name("major_pos").unwrap();
353        assert_eq!(custom.index, 4);
354        assert_eq!(custom.field_type, CoreFieldType::Custom);
355
356        assert!(schema.get_field_by_name("nonexistent").is_none());
357    }
358
359    #[test]
360    fn core_schema_validate_record() {
361        let schema = CoreSchema::new(vec!["surface".to_string(), "reading".to_string()]);
362        assert!(
363            schema
364                .validate_record(&["x".to_string(), "y".to_string()])
365                .is_ok()
366        );
367        let err = schema.validate_record(&["x".to_string()]).unwrap_err();
368        assert_eq!(err.kind(), crate::ErrorKind::Validation);
369    }
370
371    #[test]
372    fn core_schema_converts_to_and_from_lindera() {
373        let schema = CoreSchema::new(vec!["surface".to_string(), "pos".to_string()]);
374        let lindera: Schema = schema.clone().into();
375        assert_eq!(lindera.get_all_fields().len(), 2);
376        let back: CoreSchema = lindera.into();
377        assert_eq!(back.fields(), schema.fields());
378    }
379}