Skip to main content

lindera_nodejs/
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 napi wrappers.
6
7use lindera::dictionary::{FieldDefinition, FieldType, Schema};
8use lindera_binding_core::{CoreFieldDefinition, CoreFieldType, CoreSchema};
9
10use crate::error::to_napi_error;
11
12/// Field type in dictionary schema.
13///
14/// Defines the type of a field in the dictionary entry.
15#[napi(string_enum)]
16pub enum JsFieldType {
17    /// Surface form (word text)
18    Surface,
19    /// Left context ID for morphological analysis
20    LeftContextId,
21    /// Right context ID for morphological analysis
22    RightContextId,
23    /// Word cost (used in path selection)
24    Cost,
25    /// Custom field (morphological features)
26    Custom,
27}
28
29impl From<CoreFieldType> for JsFieldType {
30    fn from(field_type: CoreFieldType) -> Self {
31        match field_type {
32            CoreFieldType::Surface => JsFieldType::Surface,
33            CoreFieldType::LeftContextId => JsFieldType::LeftContextId,
34            CoreFieldType::RightContextId => JsFieldType::RightContextId,
35            CoreFieldType::Cost => JsFieldType::Cost,
36            CoreFieldType::Custom => JsFieldType::Custom,
37        }
38    }
39}
40
41impl From<JsFieldType> for CoreFieldType {
42    fn from(field_type: JsFieldType) -> Self {
43        match field_type {
44            JsFieldType::Surface => CoreFieldType::Surface,
45            JsFieldType::LeftContextId => CoreFieldType::LeftContextId,
46            JsFieldType::RightContextId => CoreFieldType::RightContextId,
47            JsFieldType::Cost => CoreFieldType::Cost,
48            JsFieldType::Custom => CoreFieldType::Custom,
49        }
50    }
51}
52
53impl From<FieldType> for JsFieldType {
54    fn from(field_type: FieldType) -> Self {
55        JsFieldType::from(CoreFieldType::from(field_type))
56    }
57}
58
59impl From<JsFieldType> for FieldType {
60    fn from(field_type: JsFieldType) -> Self {
61        FieldType::from(CoreFieldType::from(field_type))
62    }
63}
64
65/// Field definition in dictionary schema.
66///
67/// Describes a single field in the dictionary entry format.
68#[napi(object)]
69pub struct JsFieldDefinition {
70    /// Field index in the record.
71    pub index: u32,
72    /// Field name.
73    pub name: String,
74    /// Field type.
75    pub field_type: JsFieldType,
76    /// Optional description of the field.
77    pub description: Option<String>,
78}
79
80impl From<CoreFieldDefinition> for JsFieldDefinition {
81    fn from(field_def: CoreFieldDefinition) -> Self {
82        JsFieldDefinition {
83            index: field_def.index as u32,
84            name: field_def.name,
85            field_type: field_def.field_type.into(),
86            description: field_def.description,
87        }
88    }
89}
90
91impl From<JsFieldDefinition> for CoreFieldDefinition {
92    fn from(field_def: JsFieldDefinition) -> Self {
93        CoreFieldDefinition {
94            index: field_def.index as usize,
95            name: field_def.name,
96            field_type: field_def.field_type.into(),
97            description: field_def.description,
98        }
99    }
100}
101
102impl From<FieldDefinition> for JsFieldDefinition {
103    fn from(field_def: FieldDefinition) -> Self {
104        JsFieldDefinition::from(CoreFieldDefinition::from(field_def))
105    }
106}
107
108impl From<JsFieldDefinition> for FieldDefinition {
109    fn from(field_def: JsFieldDefinition) -> Self {
110        FieldDefinition::from(CoreFieldDefinition::from(field_def))
111    }
112}
113
114/// Dictionary schema definition.
115///
116/// A thin napi wrapper over [`lindera_binding_core::CoreSchema`], which owns the
117/// field storage, the name-to-index map, and the field lookups.
118#[napi(js_name = "Schema")]
119pub struct JsSchema {
120    /// The backing binding-core schema.
121    inner: CoreSchema,
122}
123
124#[napi]
125impl JsSchema {
126    /// Creates a new schema with the specified field names.
127    ///
128    /// # Arguments
129    ///
130    /// * `fields` - Array of field name strings.
131    #[napi(constructor)]
132    pub fn new(fields: Vec<String>) -> Self {
133        Self {
134            inner: CoreSchema::new(fields),
135        }
136    }
137
138    /// Creates a default schema matching the IPADIC format (13 fields).
139    ///
140    /// # Returns
141    ///
142    /// A schema with the standard IPADIC field definitions.
143    #[napi(factory)]
144    pub fn create_default() -> Self {
145        Self {
146            inner: CoreSchema::create_default(),
147        }
148    }
149
150    /// Returns the field names in the schema.
151    #[napi(getter)]
152    pub fn fields(&self) -> Vec<String> {
153        self.inner.fields().to_vec()
154    }
155
156    /// Returns the index of the specified field name.
157    ///
158    /// # Arguments
159    ///
160    /// * `field_name` - Name of the field to look up.
161    ///
162    /// # Returns
163    ///
164    /// The zero-based index of the field, or `undefined` if not found.
165    #[napi]
166    pub fn get_field_index(&self, field_name: String) -> Option<u32> {
167        self.inner.get_field_index(&field_name).map(|i| i as u32)
168    }
169
170    /// Returns the total number of fields in the schema.
171    #[napi]
172    pub fn field_count(&self) -> u32 {
173        self.inner.field_count() as u32
174    }
175
176    /// Returns the field name at the specified index.
177    ///
178    /// # Arguments
179    ///
180    /// * `index` - Zero-based index.
181    ///
182    /// # Returns
183    ///
184    /// The field name, or `undefined` if the index is out of range.
185    #[napi]
186    pub fn get_field_name(&self, index: u32) -> Option<String> {
187        self.inner
188            .get_field_name(index as usize)
189            .map(str::to_string)
190    }
191
192    /// Returns the custom fields (index 4 and above).
193    ///
194    /// # Returns
195    ///
196    /// An array of custom field names.
197    #[napi]
198    pub fn get_custom_fields(&self) -> Vec<String> {
199        self.inner.get_custom_fields().to_vec()
200    }
201
202    /// Returns all field names in the schema.
203    ///
204    /// # Returns
205    ///
206    /// An array of all field names.
207    #[napi]
208    pub fn get_all_fields(&self) -> Vec<String> {
209        self.inner.fields().to_vec()
210    }
211
212    /// Returns the field definition for the specified field name.
213    ///
214    /// # Arguments
215    ///
216    /// * `name` - Name of the field to look up.
217    ///
218    /// # Returns
219    ///
220    /// The field definition, or `undefined` if not found.
221    #[napi]
222    pub fn get_field_by_name(&self, name: String) -> Option<JsFieldDefinition> {
223        self.inner
224            .get_field_by_name(&name)
225            .map(JsFieldDefinition::from)
226    }
227
228    /// Validates that a CSV record matches the schema.
229    ///
230    /// # Arguments
231    ///
232    /// * `record` - Array of field values to validate.
233    #[napi]
234    pub fn validate_record(&self, record: Vec<String>) -> napi::Result<()> {
235        self.inner.validate_record(&record).map_err(to_napi_error)
236    }
237}
238
239impl From<CoreSchema> for JsSchema {
240    fn from(schema: CoreSchema) -> Self {
241        JsSchema { inner: schema }
242    }
243}
244
245impl From<JsSchema> for CoreSchema {
246    fn from(schema: JsSchema) -> Self {
247        schema.inner
248    }
249}
250
251impl From<JsSchema> for Schema {
252    fn from(schema: JsSchema) -> Self {
253        schema.inner.into()
254    }
255}
256
257impl From<Schema> for JsSchema {
258    fn from(schema: Schema) -> Self {
259        JsSchema {
260            inner: CoreSchema::from(schema),
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_js_field_type_to_field_type_all_variants() {
271        assert!(matches!(
272            FieldType::from(JsFieldType::Surface),
273            FieldType::Surface
274        ));
275        assert!(matches!(
276            FieldType::from(JsFieldType::Custom),
277            FieldType::Custom
278        ));
279    }
280
281    #[test]
282    fn test_field_type_to_js_field_type_all_variants() {
283        assert!(matches!(
284            JsFieldType::from(FieldType::Surface),
285            JsFieldType::Surface
286        ));
287        assert!(matches!(
288            JsFieldType::from(FieldType::Custom),
289            JsFieldType::Custom
290        ));
291    }
292
293    #[test]
294    fn test_js_schema_new_builds_index_map() {
295        let schema = JsSchema::new(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
296        assert_eq!(schema.get_field_index("a".to_string()), Some(0));
297        assert_eq!(schema.get_field_index("b".to_string()), Some(1));
298        assert_eq!(schema.get_field_index("c".to_string()), Some(2));
299    }
300
301    #[test]
302    fn test_js_schema_get_field_index_not_found() {
303        let schema = JsSchema::new(vec!["x".to_string()]);
304        assert_eq!(schema.get_field_index("y".to_string()), None);
305    }
306
307    #[test]
308    fn test_js_schema_field_count() {
309        let schema = JsSchema::new(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
310        assert_eq!(schema.field_count(), 3);
311    }
312
313    #[test]
314    fn test_js_schema_get_field_name() {
315        let schema = JsSchema::new(vec!["a".to_string(), "b".to_string()]);
316        assert_eq!(schema.get_field_name(0), Some("a".to_string()));
317        assert_eq!(schema.get_field_name(9), None);
318    }
319
320    #[test]
321    fn test_js_schema_get_custom_fields() {
322        let schema = JsSchema::new(vec![
323            "surface".to_string(),
324            "left_context_id".to_string(),
325            "right_context_id".to_string(),
326            "cost".to_string(),
327            "pos1".to_string(),
328            "pos2".to_string(),
329        ]);
330        let custom = schema.get_custom_fields();
331        assert_eq!(custom, vec!["pos1".to_string(), "pos2".to_string()]);
332    }
333
334    #[test]
335    fn test_js_schema_get_custom_fields_no_custom() {
336        let schema = JsSchema::new(vec![
337            "surface".to_string(),
338            "left_context_id".to_string(),
339            "right_context_id".to_string(),
340            "cost".to_string(),
341        ]);
342        assert!(schema.get_custom_fields().is_empty());
343    }
344
345    #[test]
346    fn test_js_schema_create_default() {
347        let schema = JsSchema::create_default();
348        assert_eq!(schema.field_count(), 13);
349        assert_eq!(schema.get_field_index("surface".to_string()), Some(0));
350        assert_eq!(schema.fields()[5], "pos_detail_1");
351        assert_eq!(
352            schema.get_field_index("pronunciation".to_string()),
353            Some(12)
354        );
355    }
356
357    #[test]
358    fn test_js_schema_get_field_by_name() {
359        let schema = JsSchema::create_default();
360        let surface = schema.get_field_by_name("surface".to_string()).unwrap();
361        assert_eq!(surface.index, 0);
362        assert!(matches!(surface.field_type, JsFieldType::Surface));
363
364        let custom = schema
365            .get_field_by_name("pos_detail_1".to_string())
366            .unwrap();
367        assert_eq!(custom.index, 5);
368        assert!(matches!(custom.field_type, JsFieldType::Custom));
369
370        assert!(schema.get_field_by_name("nope".to_string()).is_none());
371    }
372
373    #[test]
374    fn test_js_schema_to_lindera_schema_roundtrip() {
375        let fields = vec![
376            "surface".to_string(),
377            "left_context_id".to_string(),
378            "right_context_id".to_string(),
379            "cost".to_string(),
380            "pos".to_string(),
381        ];
382        let js_schema = JsSchema::new(fields.clone());
383        let lindera_schema: Schema = js_schema.into();
384        let roundtripped: JsSchema = lindera_schema.into();
385        assert_eq!(roundtripped.field_count(), 5);
386        assert_eq!(roundtripped.get_field_index("pos".to_string()), Some(4));
387    }
388
389    #[test]
390    fn test_lindera_schema_to_js_schema() {
391        let lindera_schema = Schema::new(vec!["a".to_string(), "b".to_string()]);
392        let js_schema: JsSchema = lindera_schema.into();
393        assert_eq!(js_schema.field_count(), 2);
394        assert_eq!(js_schema.get_field_index("a".to_string()), Some(0));
395    }
396}