1use quarb::Value;
26use quarb::library::{
27 CommandLibrary, InputSlot, LibCall, LibFailure, OperandSpec, OptionSpec, Pipe, Synopsis,
28};
29use quarb_pyvalue::PyGlue;
30
31const GLUE: &str = r#"
35import contextlib, sys
36import pymorphy3
37
38_analysers = {}
39
40def _morph(lang):
41 lang = lang or "ru"
42 if lang not in _analysers:
43 _analysers[lang] = pymorphy3.MorphAnalyzer(lang=lang)
44 return _analysers[lang]
45
46def _word(x):
47 if isinstance(x, dict):
48 x = x.get("text") or x.get("name") or ""
49 return "" if x is None else str(x)
50
51def _best(word, lang):
52 parses = _morph(lang).parse(_word(word))
53 return parses[0] if parses else None
54
55def lemma(word, lang="ru"):
56 p = _best(word, lang)
57 return None if p is None else p.normal_form
58
59def tag(word, lang="ru"):
60 p = _best(word, lang)
61 return None if p is None else str(p.tag)
62
63def pos(word, lang="ru"):
64 p = _best(word, lang)
65 return None if p is None else p.tag.POS
66
67def parse(word, n=None, lang="ru"):
68 parses = _morph(lang).parse(_word(word))
69 if n is not None:
70 parses = parses[: int(n)]
71 return [
72 {
73 "lemma": p.normal_form,
74 "tag": str(p.tag),
75 "pos": p.tag.POS,
76 "score": round(p.score, 6),
77 }
78 for p in parses
79 ]
80
81_GRAMMEMES = ("case", "number", "gender", "person", "tense", "mood", "voice", "aspect", "animacy", "transitivity")
82
83def inflect(word, lang="ru", **grammemes):
84 p = _best(word, lang)
85 if p is None:
86 return None
87 wanted = {str(v) for k, v in grammemes.items() if k in _GRAMMEMES and v is not None}
88 if not wanted:
89 return p.word
90 q = p.inflect(wanted)
91 return None if q is None else q.word
92
93def _call(name, args, kwargs):
94 f = globals()[name]
95 with contextlib.redirect_stdout(sys.stderr):
96 if args and isinstance(args[0], list):
97 head, rest = args[0], args[1:]
98 return [f(item, *rest, **kwargs) for item in head]
99 return f(*args, **kwargs)
100"#;
101
102pub struct Morph;
104
105const LANG: OptionSpec = OptionSpec {
106 short: Some("-l"),
107 long: "lang",
108 takes_value: true,
109 doc: "the dictionary: ru (the default), uk where its dictionaries are installed",
110};
111
112const WORD: OperandSpec = OperandSpec {
113 name: "word",
114 doc: "the word form (the input; a node by its text)",
115 values: &[],
116 required: true,
117};
118
119const GRAMMEME_OPTIONS: &[OptionSpec] = &[
120 LANG,
121 OptionSpec {
122 short: None,
123 long: "case",
124 takes_value: true,
125 doc: "nomn gent datv accs ablt loct voct gen2 acc2 loc2",
126 },
127 OptionSpec {
128 short: None,
129 long: "number",
130 takes_value: true,
131 doc: "sing plur",
132 },
133 OptionSpec {
134 short: None,
135 long: "gender",
136 takes_value: true,
137 doc: "masc femn neut",
138 },
139 OptionSpec {
140 short: None,
141 long: "person",
142 takes_value: true,
143 doc: "1per 2per 3per",
144 },
145 OptionSpec {
146 short: None,
147 long: "tense",
148 takes_value: true,
149 doc: "pres past futr",
150 },
151 OptionSpec {
152 short: None,
153 long: "mood",
154 takes_value: true,
155 doc: "indc impr",
156 },
157 OptionSpec {
158 short: None,
159 long: "voice",
160 takes_value: true,
161 doc: "actv pssv",
162 },
163 OptionSpec {
164 short: None,
165 long: "aspect",
166 takes_value: true,
167 doc: "perf impf",
168 },
169 OptionSpec {
170 short: None,
171 long: "animacy",
172 takes_value: true,
173 doc: "anim inan",
174 },
175 OptionSpec {
176 short: None,
177 long: "transitivity",
178 takes_value: true,
179 doc: "tran intr",
180 },
181];
182
183const COMMANDS: &[Synopsis] = &[
184 Synopsis {
185 name: "lemma",
186 doc: "the normal form of the word's most likely parse",
187 pipes: Pipe::Both,
188 operands: &[WORD],
189 options: &[LANG],
190 input: InputSlot::Operand(0),
191 returns: "string (null where the word has no parse)",
192 },
193 Synopsis {
194 name: "tag",
195 doc: "the grammemes of the most likely parse, as OpenCorpora writes them",
196 pipes: Pipe::Both,
197 operands: &[WORD],
198 options: &[LANG],
199 input: InputSlot::Operand(0),
200 returns: "string",
201 },
202 Synopsis {
203 name: "pos",
204 doc: "the part of speech of the most likely parse (NOUN, VERB, ADJF, …)",
205 pipes: Pipe::Both,
206 operands: &[WORD],
207 options: &[LANG],
208 input: InputSlot::Operand(0),
209 returns: "string",
210 },
211 Synopsis {
212 name: "parse",
213 doc: "every parse of the word, most likely first: lemma, tag, pos, score",
214 pipes: Pipe::Both,
215 operands: &[WORD],
216 options: &[
217 OptionSpec {
218 short: Some("-n"),
219 long: "n",
220 takes_value: true,
221 doc: "at most this many parses (all by default; -n=1 the best)",
222 },
223 LANG,
224 ],
225 input: InputSlot::Operand(0),
226 returns: "list of records %(lemma; tag; pos; score)",
227 },
228 Synopsis {
229 name: "inflect",
230 doc: "the word's most likely parse inflected to the grammemes given",
231 pipes: Pipe::Both,
232 operands: &[WORD],
233 options: GRAMMEME_OPTIONS,
234 input: InputSlot::Operand(0),
235 returns: "string (null where no form exists)",
236 },
237];
238
239static PY: PyGlue = PyGlue::new(GLUE, "quarb_morph.py", "quarb_morph");
240
241impl CommandLibrary for Morph {
242 fn prefix(&self) -> &str {
243 "morph"
244 }
245
246 fn commands(&self) -> &[Synopsis] {
247 COMMANDS
248 }
249
250 fn requirements(&self) -> &[&str] {
251 &["pymorphy3", "pymorphy3-dicts-ru"]
252 }
253
254 fn available(&self) -> Result<(), String> {
255 quarb_pyvalue::importable(
256 &["pymorphy3", "pymorphy3_dicts_ru"],
257 "pymorphy3 pymorphy3-dicts-ru",
258 )
259 }
260
261 fn call(&self, name: &str, call: LibCall) -> Result<Value, LibFailure> {
262 let Some(syn) = self.synopsis(name) else {
263 return Err(LibFailure::Unknown(format!(
264 "library 'morph' has no command '{name}'"
265 )));
266 };
267 PY.call(syn, name, call)
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 fn plain(name: &str, input: Value, options: Vec<(&str, Value)>) -> Value {
276 Morph
277 .call(
278 name,
279 LibCall {
280 agg: false,
281 input: Some(input),
282 operands: vec![],
283 options: options
284 .into_iter()
285 .map(|(k, v)| (k.to_string(), Some(v)))
286 .collect(),
287 },
288 )
289 .unwrap()
290 }
291
292 #[test]
293 fn lemma_tag_and_parse_read_a_russian_form() {
294 if Morph.available().is_err() {
295 eprintln!("pymorphy3 not installed; skipping");
296 return;
297 }
298 assert_eq!(
299 plain("lemma", Value::Str("книги".into()), vec![]),
300 Value::Str("книга".into())
301 );
302 let Value::Str(tag) = plain("tag", Value::Str("книги".into()), vec![]) else {
303 panic!("tag")
304 };
305 assert!(tag.starts_with("NOUN"), "{tag}");
306 let Value::List(parses) = plain("parse", Value::Str("стали".into()), vec![]) else {
307 panic!("parse")
308 };
309 assert!(parses.len() >= 2, "{parses:?}");
310 let best = plain(
311 "parse",
312 Value::Str("стали".into()),
313 vec![("n", Value::Int(1))],
314 );
315 assert_eq!(best.into_items().map(|v| v.len()), Some(1));
316 assert_eq!(
317 plain(
318 "inflect",
319 Value::Str("книга".into()),
320 vec![
321 ("case", Value::Str("gent".into())),
322 ("number", Value::Str("plur".into()))
323 ]
324 ),
325 Value::Str("книг".into())
326 );
327 }
328
329 #[test]
330 fn the_stream_maps_once_after_the_aggregation_pipe() {
331 if Morph.available().is_err() {
332 return;
333 }
334 let out = Morph
335 .call(
336 "lemma",
337 LibCall {
338 agg: true,
339 input: Some(Value::list(vec![
340 Value::Str("книги".into()),
341 Value::Str("стали".into()),
342 ])),
343 operands: vec![],
344 options: vec![],
345 },
346 )
347 .unwrap();
348 assert_eq!(
349 out,
350 Value::list(vec![Value::Str("книга".into()), Value::Str("стать".into())])
351 );
352 }
353}