Skip to main content

quarb_lib_morph/
lib.rs

1//! `morph:` — pymorphy3, the Russian morphological analyser, as a
2//! Quarb command library: the pilot of a library that wraps another
3//! language's library through an embedded interpreter (spec: Command
4//! Libraries). The commands are declared here, in Rust, with the
5//! synopsis the engine checks before the run; a page of Python glue
6//! (compiled once, the analyser built once) adapts pymorphy3's
7//! objects to the call shape, and the values cross through
8//! `quarb-pyvalue`, the same mapping `quarb-py` uses the other way.
9//!
10//! What the pilot proves of the contract: the synopsis per command
11//! (operands, options with short and long names, pipe, input slot,
12//! return); the required prefix; no mount (the library is pure and
13//! linked); the input slot (first positional unless `$_` places it;
14//! after `@|` the stream is the value and the call runs once over
15//! it); options as keyword arguments; value conversion both ways;
16//! failure as null after `|` and a refusal after `@|`, the module's
17//! stdout redirected to stderr during a call; a run-time requirement
18//! (the `pymorphy3` package and `pymorphy3-dicts-ru`) refused by name
19//! before the run where it is not importable.
20//!
21//! What a node becomes: the engine hands a node to a library as the
22//! record of its name, its text (`::`) and its properties, a
23//! snapshot; this library reads a word from the `text` field.
24
25use quarb::Value;
26use quarb::library::{
27    CommandLibrary, InputSlot, LibCall, LibFailure, OperandSpec, OptionSpec, Pipe, Synopsis,
28};
29use quarb_pyvalue::PyGlue;
30
31/// The glue: pymorphy3 in the call shape. One analyser per
32/// language, built on first use; a list input (the stream after
33/// `@|`) maps the command over its items.
34const 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
102/// The library.
103pub 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}