#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Morph {
pub surface: String,
pub lemma: Option<String>,
pub content: bool,
pub start: usize,
pub end: usize,
}
pub const fn available() -> bool {
cfg!(feature = "cjk-dict")
}
pub fn warm_up() {
if available() {
let _ = japanese("研究");
let _ = korean("학교");
}
}
#[cfg(feature = "cjk-dict")]
mod imp {
use super::Morph;
use std::borrow::Cow;
use std::sync::OnceLock;
use lindera::dictionary::load_dictionary;
use lindera::mode::Mode;
use lindera::segmenter::Segmenter;
type Classify = fn(pos: &str) -> Option<bool>;
fn segmenter(uri: &'static str, cell: &'static OnceLock<Segmenter>) -> &'static Segmenter {
cell.get_or_init(|| {
let dictionary = load_dictionary(uri).expect("embedded lindera dictionary");
Segmenter::new(Mode::Normal, dictionary, None)
})
}
fn analyse(
segmenter: &Segmenter,
text: &str,
classify: Classify,
lemma_index: Option<usize>,
) -> Vec<Morph> {
let Ok(mut tokens) = segmenter.segment(Cow::Borrowed(text)) else {
return Vec::new();
};
tokens
.iter_mut()
.filter_map(|token| {
let surface = token.surface.to_string();
if surface.trim().is_empty() {
return None;
}
let details = token.details();
let pos = details.first().copied().unwrap_or("");
let content = classify(pos)?;
let lemma = lemma_index
.and_then(|index| details.get(index).copied())
.filter(|base| !base.is_empty() && *base != "*" && *base != surface)
.map(str::to_string);
Some(Morph {
surface,
lemma,
content,
start: token.byte_start,
end: token.byte_end,
})
})
.collect()
}
pub(in crate::tokenizer) fn japanese(text: &str) -> Vec<Morph> {
static CELL: OnceLock<Segmenter> = OnceLock::new();
analyse(
segmenter("embedded://unidic", &CELL),
text,
|pos| match pos {
"補助記号" | "記号" | "空白" => None,
"助詞" | "助動詞" => Some(false),
_ => Some(true),
},
Some(10),
)
}
pub(in crate::tokenizer) fn korean(text: &str) -> Vec<Morph> {
static CELL: OnceLock<Segmenter> = OnceLock::new();
analyse(
segmenter("embedded://ko-dic", &CELL),
text,
|pos| {
let first = pos.split('+').next().unwrap_or("");
match first {
"SF" | "SP" | "SS" | "SE" | "SO" | "SC" | "SY" | "SW" => None,
_ if first.starts_with('J')
|| first.starts_with('E')
|| first.starts_with("XS") =>
{
Some(false)
}
_ => Some(true),
}
},
None,
)
}
}
#[cfg(feature = "cjk-dict")]
pub(super) use imp::{japanese, korean};
#[cfg(not(feature = "cjk-dict"))]
pub(super) fn japanese(_text: &str) -> Vec<Morph> {
Vec::new()
}
#[cfg(not(feature = "cjk-dict"))]
pub(super) fn korean(_text: &str) -> Vec<Morph> {
Vec::new()
}
#[cfg(all(test, feature = "cjk-dict"))]
mod tests {
use super::*;
#[test]
fn japanese_morphemes_carry_base_forms_and_drop_particles() {
let morphs = japanese("量子コンピュータの研究を食べました");
let content: Vec<(&str, Option<&str>)> = morphs
.iter()
.filter(|m| m.content)
.map(|m| (m.surface.as_str(), m.lemma.as_deref()))
.collect();
assert_eq!(
content,
vec![
("量子", None),
("コンピュータ", None),
("研究", None),
("食べ", Some("食べる")),
]
);
assert!(morphs.iter().any(|m| m.surface == "の" && !m.content));
}
#[test]
fn korean_particles_and_endings_are_function_morphemes() {
let morphs = korean("학교에서 친구들과 공부했습니다");
let content: Vec<&str> = morphs
.iter()
.filter(|m| m.content)
.map(|m| m.surface.as_str())
.collect();
assert_eq!(content, vec!["학교", "친구", "공부"]);
assert!(morphs.iter().any(|m| m.surface == "에서" && !m.content));
}
}