lindera-binding-core 6.2.0

Shared FFI-independent helpers for the Lindera language bindings.
Documentation
//! Shared dictionary-metadata defaults and schema wiring for the bindings.
//!
//! The Python/PHP/Ruby/Node.js `Metadata` wrappers each hard-coded the same
//! default values (name `"default"`, encoding `"UTF-8"`, word cost `-10000`,
//! context ids `1288`, field value `"*"`, three `false` flags) and the same
//! default user-dictionary schema (`surface`/`reading`/`pronunciation`). This
//! module collects that into a single [`CoreMetadata`] the bindings can wrap.

use lindera::dictionary::Metadata;
use lindera::space_penalty::SpacePenaltyConfig;

use crate::schema::CoreSchema;

/// Default dictionary name.
const DEFAULT_NAME: &str = "default";
/// Default character encoding.
const DEFAULT_ENCODING: &str = "UTF-8";
/// Default cost assigned to simple user-dictionary entries.
const DEFAULT_WORD_COST: i16 = -10000;
/// Default left context id for simple user-dictionary entries.
const DEFAULT_LEFT_CONTEXT_ID: u16 = 1288;
/// Default right context id for simple user-dictionary entries.
const DEFAULT_RIGHT_CONTEXT_ID: u16 = 1288;
/// Default value substituted for missing fields.
const DEFAULT_FIELD_VALUE: &str = "*";

/// Returns the default user-dictionary schema (`surface`/`reading`/`pronunciation`).
fn default_user_dictionary_schema() -> CoreSchema {
    CoreSchema::new(vec![
        "surface".to_string(),
        "reading".to_string(),
        "pronunciation".to_string(),
    ])
}

/// Dictionary metadata shared by the bindings.
///
/// Mirrors the binding `Metadata` wrappers as a pure-Rust type (using
/// [`CoreSchema`] for the two schema fields) so each binding can wrap a
/// `CoreMetadata` instead of re-declaring the same defaults and conversions.
/// Converts to and from [`lindera::dictionary::Metadata`]; the optional
/// `model_info` carried by the lindera type is not retained (the bindings do
/// not expose it), while `space_penalty` is, so a dictionary built from a
/// `metadata.json` that ships left-space penalty rules (ko-dic) keeps them.
#[derive(Debug, Clone)]
pub struct CoreMetadata {
    /// Dictionary name.
    pub name: String,
    /// Character encoding.
    pub encoding: String,
    /// Default word cost for simple user-dictionary entries.
    pub default_word_cost: i16,
    /// Default left context id for simple user-dictionary entries.
    pub default_left_context_id: u16,
    /// Default right context id for simple user-dictionary entries.
    pub default_right_context_id: u16,
    /// Default value substituted for missing fields.
    pub default_field_value: String,
    /// Whether CSV columns are handled flexibly.
    pub flexible_csv: bool,
    /// Whether entries with invalid cost or id are skipped.
    pub skip_invalid_cost_or_id: bool,
    /// Whether morphological details are normalized.
    pub normalize_details: bool,
    /// Schema for the main dictionary.
    pub dictionary_schema: CoreSchema,
    /// Schema for the user dictionary.
    pub user_dictionary_schema: CoreSchema,
    /// Left-space penalty rules the dictionary ships (ko-dic carries
    /// mecab-ko-dic's `left-space-penalty-factor`); `None` when it ships none.
    pub space_penalty: Option<SpacePenaltyConfig>,
}

impl CoreMetadata {
    /// Creates metadata, falling back to the binding defaults for any `None`.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        name: Option<String>,
        encoding: Option<String>,
        default_word_cost: Option<i16>,
        default_left_context_id: Option<u16>,
        default_right_context_id: Option<u16>,
        default_field_value: Option<String>,
        flexible_csv: Option<bool>,
        skip_invalid_cost_or_id: Option<bool>,
        normalize_details: Option<bool>,
        dictionary_schema: Option<CoreSchema>,
        user_dictionary_schema: Option<CoreSchema>,
    ) -> Self {
        Self {
            name: name.unwrap_or_else(|| DEFAULT_NAME.to_string()),
            encoding: encoding.unwrap_or_else(|| DEFAULT_ENCODING.to_string()),
            default_word_cost: default_word_cost.unwrap_or(DEFAULT_WORD_COST),
            default_left_context_id: default_left_context_id.unwrap_or(DEFAULT_LEFT_CONTEXT_ID),
            default_right_context_id: default_right_context_id.unwrap_or(DEFAULT_RIGHT_CONTEXT_ID),
            default_field_value: default_field_value
                .unwrap_or_else(|| DEFAULT_FIELD_VALUE.to_string()),
            flexible_csv: flexible_csv.unwrap_or(false),
            skip_invalid_cost_or_id: skip_invalid_cost_or_id.unwrap_or(false),
            normalize_details: normalize_details.unwrap_or(false),
            dictionary_schema: dictionary_schema.unwrap_or_else(CoreSchema::create_default),
            user_dictionary_schema: user_dictionary_schema
                .unwrap_or_else(default_user_dictionary_schema),
            space_penalty: None,
        }
    }

    /// Creates metadata with all binding defaults.
    pub fn create_default() -> Self {
        Self::new(
            None, None, None, None, None, None, None, None, None, None, None,
        )
    }
}

impl CoreMetadata {
    /// Returns the left-space penalty rules as a JSON string, for the
    /// bindings' string-valued `to_dict` / `to_hash` / `to_array` /
    /// `to_object` maps.
    ///
    /// # Returns
    ///
    /// The rules serialized as JSON (e.g. `{"rules":[...]}`), or `None` when
    /// the dictionary ships none.
    pub fn space_penalty_json(&self) -> Option<String> {
        self.space_penalty
            .as_ref()
            .and_then(|config| serde_json::to_string(config).ok())
    }
}

impl Default for CoreMetadata {
    /// Returns [`CoreMetadata::create_default`].
    fn default() -> Self {
        Self::create_default()
    }
}

impl From<Metadata> for CoreMetadata {
    /// Converts a lindera [`Metadata`] into a [`CoreMetadata`] (dropping
    /// `model_info`, keeping `space_penalty`).
    fn from(metadata: Metadata) -> Self {
        Self {
            name: metadata.name,
            encoding: metadata.encoding,
            default_word_cost: metadata.default_word_cost,
            default_left_context_id: metadata.default_left_context_id,
            default_right_context_id: metadata.default_right_context_id,
            default_field_value: metadata.default_field_value,
            flexible_csv: metadata.flexible_csv,
            skip_invalid_cost_or_id: metadata.skip_invalid_cost_or_id,
            normalize_details: metadata.normalize_details,
            dictionary_schema: metadata.dictionary_schema.into(),
            user_dictionary_schema: metadata.user_dictionary_schema.into(),
            space_penalty: metadata.space_penalty,
        }
    }
}

impl From<CoreMetadata> for Metadata {
    /// Converts a [`CoreMetadata`] into a lindera [`Metadata`] (`model_info` is
    /// `None`; `space_penalty` is carried over).
    fn from(metadata: CoreMetadata) -> Self {
        let space_penalty = metadata.space_penalty;
        let mut converted = Metadata::new(
            metadata.name,
            metadata.encoding,
            metadata.default_word_cost,
            metadata.default_left_context_id,
            metadata.default_right_context_id,
            metadata.default_field_value,
            metadata.flexible_csv,
            metadata.skip_invalid_cost_or_id,
            metadata.normalize_details,
            metadata.dictionary_schema.into(),
            metadata.user_dictionary_schema.into(),
        );
        converted.space_penalty = space_penalty;
        converted
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn create_default_uses_binding_defaults() {
        let m = CoreMetadata::create_default();
        assert_eq!(m.name, "default");
        assert_eq!(m.encoding, "UTF-8");
        assert_eq!(m.default_word_cost, -10000);
        assert_eq!(m.default_left_context_id, 1288);
        assert_eq!(m.default_right_context_id, 1288);
        assert_eq!(m.default_field_value, "*");
        assert!(!m.flexible_csv);
        assert!(!m.skip_invalid_cost_or_id);
        assert!(!m.normalize_details);
        // The default dictionary schema uses the unified `pos_detail_*` names.
        assert_eq!(m.dictionary_schema.field_count(), 13);
        assert_eq!(m.dictionary_schema.fields()[5], "pos_detail_1");
        assert_eq!(m.user_dictionary_schema.fields().len(), 3);
        assert_eq!(m.user_dictionary_schema.fields()[1], "reading");
    }

    #[test]
    fn new_applies_overrides() {
        let m = CoreMetadata::new(
            Some("custom".to_string()),
            Some("EUC-JP".to_string()),
            Some(-5000),
            Some(100),
            Some(200),
            Some("N/A".to_string()),
            Some(true),
            Some(true),
            Some(true),
            None,
            None,
        );
        assert_eq!(m.name, "custom");
        assert_eq!(m.encoding, "EUC-JP");
        assert_eq!(m.default_word_cost, -5000);
        assert_eq!(m.default_left_context_id, 100);
        assert_eq!(m.default_right_context_id, 200);
        assert_eq!(m.default_field_value, "N/A");
        assert!(m.flexible_csv && m.skip_invalid_cost_or_id && m.normalize_details);
    }

    #[test]
    fn converts_to_and_from_lindera() {
        let m = CoreMetadata::create_default();
        let lindera: Metadata = m.into();
        assert_eq!(lindera.name, "default");
        assert_eq!(lindera.default_left_context_id, 1288);
        assert_eq!(lindera.user_dictionary_schema.field_count(), 3);

        let back: CoreMetadata = lindera.into();
        assert_eq!(back.name, "default");
        assert_eq!(back.dictionary_schema.fields()[5], "pos_detail_1");
        assert!(back.space_penalty.is_none());
        assert!(back.space_penalty_json().is_none());
    }

    /// ko-dic's `metadata.json` ships left-space penalty rules; loading it the
    /// way the bindings' `Metadata.from_json_file` does and converting back
    /// for a dictionary build must keep them (#1052).
    #[test]
    fn keeps_space_penalty_from_ko_dic_metadata() {
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../lindera-ko-dic/metadata.json");
        let json = std::fs::read_to_string(&path).unwrap();
        let metadata: Metadata = serde_json::from_str(&json).unwrap();
        let shipped = metadata.space_penalty.clone().unwrap();
        assert!(!shipped.rules.is_empty());

        let core = CoreMetadata::from(metadata);
        assert_eq!(core.space_penalty.as_ref(), Some(&shipped));
        let as_json = core.space_penalty_json().unwrap();
        assert!(as_json.contains("\"rules\""));
        assert!(as_json.contains("JKS"));

        let rebuilt: Metadata = core.into();
        assert_eq!(rebuilt.space_penalty, Some(shipped));
    }
}