Skip to main content

prosaic_core/
language.rs

1#[cfg(not(feature = "std"))]
2use alloc::format;
3#[cfg(not(feature = "std"))]
4use alloc::string::{String, ToString};
5
6/// Verb tense for conjugation.
7///
8/// Simple tense axis — combine with [`Aspect`] and [`Voice`] to get richer
9/// forms like "has been renamed" (present perfect passive) or "is being
10/// renamed" (present progressive passive).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub enum Tense {
14    #[default]
15    Past,
16    Present,
17    Future,
18}
19
20/// Grammatical aspect — whether the action is simple, completed, or ongoing.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub enum Aspect {
24    /// "renamed" / "was renamed" — a plain, point-in-time action.
25    #[default]
26    Simple,
27    /// "has renamed" / "has been renamed" — emphasises completion/relevance.
28    Perfect,
29    /// "is renaming" / "is being renamed" — emphasises ongoing action.
30    Progressive,
31}
32
33/// Voice controls whether the verb is rendered in active or passive form.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36pub enum Voice {
37    /// "Foo renamed Foobar" — subject performs the action.
38    Active,
39    /// "Foo was renamed to Foobar" — subject receives the action. Default.
40    #[default]
41    Passive,
42}
43
44/// Grammatical person for conjugation.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47pub enum Person {
48    First,
49    Second,
50    #[default]
51    Third,
52}
53
54/// Grammatical mood — indicative (factual) vs conditional ("would …").
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57pub enum Mood {
58    #[default]
59    Indicative,
60    /// Conditional: "would rename" / "would be renamed". Only pairs with
61    /// Simple or Perfect aspect; ignores tense.
62    Conditional,
63}
64
65/// Conjunction used when joining lists.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub enum Conjunction {
69    And,
70    Or,
71}
72
73/// A fully-specified verb form. Convenience enum covering the most common
74/// tense × aspect × mood combinations in English. Use with
75/// [`Language::verb_phrase`] or template `{…|verb:<form>}` pipes.
76///
77/// Each variant maps cleanly to a (Tense, Aspect, Mood) triple — see
78/// [`VerbForm::resolve`]. Names are written from an English perspective
79/// but the trait-level composition is language-agnostic.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82pub enum VerbForm {
83    /// "renamed" / "was renamed"
84    SimplePast,
85    /// "renames" / "is renamed"
86    SimplePresent,
87    /// "will rename" / "will be renamed"
88    SimpleFuture,
89    /// "has renamed" / "has been renamed"
90    PresentPerfect,
91    /// "had renamed" / "had been renamed"
92    PastPerfect,
93    /// "will have renamed" / "will have been renamed"
94    FuturePerfect,
95    /// "is renaming" / "is being renamed"
96    PresentProgressive,
97    /// "was renaming" / "was being renamed"
98    PastProgressive,
99    /// "would rename" / "would be renamed"
100    Conditional,
101    /// "would have renamed" / "would have been renamed"
102    ConditionalPerfect,
103}
104
105impl VerbForm {
106    /// Decompose into tense, aspect, and mood primitives.
107    pub fn resolve(self) -> (Tense, Aspect, Mood) {
108        use Aspect::*;
109        use Mood::*;
110        use Tense::*;
111        match self {
112            VerbForm::SimplePast => (Past, Simple, Indicative),
113            VerbForm::SimplePresent => (Present, Simple, Indicative),
114            VerbForm::SimpleFuture => (Future, Simple, Indicative),
115            VerbForm::PresentPerfect => (Present, Perfect, Indicative),
116            VerbForm::PastPerfect => (Past, Perfect, Indicative),
117            VerbForm::FuturePerfect => (Future, Perfect, Indicative),
118            VerbForm::PresentProgressive => (Present, Progressive, Indicative),
119            VerbForm::PastProgressive => (Past, Progressive, Indicative),
120            // Conditional mood ignores tense; Present is used as a neutral slot.
121            VerbForm::Conditional => (Present, Simple, Conditional),
122            VerbForm::ConditionalPerfect => (Present, Perfect, Conditional),
123        }
124    }
125
126    /// Parse a snake_case form name (e.g. "present_perfect") plus an
127    /// optional `active_` prefix into `(VerbForm, Voice)`. Returns `None`
128    /// on unknown names. Used by the `verb` template pipe.
129    pub fn parse_spec(spec: &str) -> Option<(VerbForm, Voice)> {
130        let (voice, rest) = if let Some(tail) = spec.strip_prefix("active_") {
131            (Voice::Active, tail)
132        } else if let Some(tail) = spec.strip_prefix("passive_") {
133            (Voice::Passive, tail)
134        } else {
135            (Voice::Passive, spec)
136        };
137
138        let form = match rest {
139            "past" | "simple_past" => VerbForm::SimplePast,
140            "present" | "simple_present" => VerbForm::SimplePresent,
141            "future" | "simple_future" => VerbForm::SimpleFuture,
142            "present_perfect" => VerbForm::PresentPerfect,
143            "past_perfect" => VerbForm::PastPerfect,
144            "future_perfect" => VerbForm::FuturePerfect,
145            "present_progressive" | "progressive" => VerbForm::PresentProgressive,
146            "past_progressive" => VerbForm::PastProgressive,
147            "conditional" => VerbForm::Conditional,
148            "conditional_perfect" => VerbForm::ConditionalPerfect,
149            _ => return None,
150        };
151
152        Some((form, voice))
153    }
154}
155
156impl From<Tense> for VerbForm {
157    fn from(tense: Tense) -> Self {
158        match tense {
159            Tense::Past => VerbForm::SimplePast,
160            Tense::Present => VerbForm::SimplePresent,
161            Tense::Future => VerbForm::SimpleFuture,
162        }
163    }
164}
165
166/// CLDR plural categories. A language's [`Language::plural_category`]
167/// implementation maps an integer count into one of these six buckets.
168/// The subset a language actually uses depends on its grammar:
169///
170/// - English: `One` | `Other`
171/// - Spanish: `One` | `Other`
172/// - Polish:  `One` | `Few` | `Many` | `Other`
173/// - Arabic:  `Zero` | `One` | `Two` | `Few` | `Many` | `Other`
174///
175/// Non-English grammars must override [`Language::plural_category`] and
176/// [`Language::pluralize_with_category`] to return the correct category and
177/// the correct word form for their language.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
179#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
180pub enum PluralCategory {
181    Zero,
182    One,
183    Two,
184    Few,
185    Many,
186    /// The catch-all category; used by languages that do not distinguish a
187    /// more specific bucket for a given count.
188    #[default]
189    Other,
190}
191
192/// Trait abstracting over a natural language's grammar rules.
193///
194/// Implement this trait for each language you want to support.
195/// The crate ships with an English implementation in `prosaic-grammar-en`.
196pub trait Language: Send + Sync {
197    /// Return the plural form of `word` for the given `count`.
198    /// When `count` is 1, return the singular form.
199    fn pluralize(&self, word: &str, count: usize) -> String;
200
201    /// Return the singular form of a potentially plural `word`.
202    fn singularize(&self, word: &str) -> String;
203
204    /// Return the indefinite article ("a" or "an" in English) for `word`.
205    fn article(&self, word: &str) -> &str;
206
207    /// Conjugate `verb` in the given simple `tense` and `person`
208    /// (no aspect/mood compounding — just "renamed", "renames", "will rename").
209    fn conjugate(&self, verb: &str, tense: Tense, person: Person) -> String;
210
211    /// Return the past participle of a verb (e.g., "broken", "renamed").
212    /// Used for passive and perfect constructions ("was renamed",
213    /// "has been renamed", "will have broken").
214    fn past_participle(&self, verb: &str) -> String;
215
216    /// Return the present participle of a verb (e.g., "renaming", "writing").
217    /// Used for progressive constructions ("is renaming", "was being renamed").
218    fn present_participle(&self, verb: &str) -> String;
219
220    /// Join a list of items with the given conjunction.
221    /// Should use the language's standard list format (e.g., Oxford comma in English).
222    fn join_list(&self, items: &[&str], conjunction: Conjunction) -> String;
223
224    /// Return the ordinal string for `n` (e.g., "1st", "2nd", "3rd").
225    fn ordinal(&self, n: usize) -> String;
226
227    /// Spell out `n` as words (e.g., 42 → "forty-two").
228    fn number_to_words(&self, n: usize) -> String;
229
230    /// Produce a plural description for a set of same-type entities.
231    ///
232    /// Called by the `|refer` pipe when the slot value is a `Value::List` of
233    /// 2+ items sharing an entity type. The default implementation is
234    /// English-shaped but generic enough to serve as a reasonable fallback for
235    /// languages that have not overridden it:
236    ///
237    /// - count = 0 → `""` (empty string)
238    /// - count = 1 → `"the {entity_type}"`
239    /// - count ≥ 2 → `"the {count} {entity_type_plural}"`
240    ///
241    /// Non-English grammars should override this to handle gender agreement
242    /// (Spanish/French), counter words (Japanese), dual/few/many categories
243    /// (Arabic), and so on. The `features` parameter carries agreement info
244    /// propagated from the first entity in the set; override implementations
245    /// may use it to select the correct article or adjective endings.
246    ///
247    /// English's `prosaic_grammar_en::English` uses the default; no override
248    /// is needed for v1.
249    fn plural_description(
250        &self,
251        entity_type: &str,
252        count: usize,
253        _features: &crate::agreement::AgreementFeatures,
254    ) -> String {
255        match count {
256            0 => String::new(),
257            1 => format!("the {entity_type}"),
258            _ => format!("the {count} {}", self.pluralize(entity_type, count)),
259        }
260    }
261
262    /// Render a full verb phrase combining tense, aspect, voice, and mood.
263    ///
264    /// Default implementation composes from the primitive inflections
265    /// (`conjugate`, `past_participle`, `present_participle`) following
266    /// English auxiliary-verb rules. Override for languages whose verb
267    /// phrase structure differs from English's `aux + aux + participle`
268    /// layout.
269    fn verb_phrase(&self, verb: &str, form: VerbForm, voice: Voice, person: Person) -> String {
270        english_verb_phrase(self, verb, form, voice, person)
271    }
272
273    /// Classify an integer count into a CLDR plural category.
274    ///
275    /// Default implementation uses English rules: `n == 1` → [`PluralCategory::One`],
276    /// anything else → [`PluralCategory::Other`]. Non-English grammars must
277    /// override this method to return the correct categories for their
278    /// language (e.g., Polish distinguishes `One / Few / Many / Other`;
279    /// Arabic uses all six categories).
280    fn plural_category(&self, n: i64) -> PluralCategory {
281        match n {
282            1 => PluralCategory::One,
283            _ => PluralCategory::Other,
284        }
285    }
286
287    /// Produce the form of `word` appropriate for the given plural category.
288    ///
289    /// Default implementation uses English rules: [`PluralCategory::One`]
290    /// returns the singular form (the word unchanged); any other category
291    /// returns the plural form via [`Language::pluralize`] with count 2,
292    /// which picks the plural branch in the legacy API without triggering
293    /// irregular-specific overrides that count on the exact integer.
294    ///
295    /// Non-English grammars must override this method when they support
296    /// richer category sets (e.g., Polish `One / Few / Many / Other`) or
297    /// when gender / case agreement affects the choice of form.
298    fn pluralize_with_category(&self, word: &str, category: PluralCategory) -> String {
299        match category {
300            PluralCategory::One => word.to_string(),
301            _ => self.pluralize(word, 2),
302        }
303    }
304
305    /// Return a discourse marker for the given RST relation.
306    ///
307    /// Emitted at the START of a sentence (with trailing space), e.g.
308    /// `"Furthermore, "`. Return `None` to suppress the marker (the renderer
309    /// will fall back to a plain inter-sentence space).
310    ///
311    /// The default implementation encodes English markers. Non-English grammars
312    /// override with locale-appropriate markers.
313    /// Return `true` when `text` is a known sentence-leading connective
314    /// in this language. Used by retrospective-pass diagnosers (e.g.
315    /// `ParagraphOpenerMonotony`) that need to recognize when a paragraph
316    /// opens with a continuation/contrast/sequencing cue rather than
317    /// fresh content.
318    ///
319    /// The default impl recognizes the English connective set the engine
320    /// itself emits: discourse-relation auto-connectives plus the
321    /// `discourse_marker` outputs. Non-English grammars override with
322    /// their own opener lexicon. Match is case-sensitive and includes
323    /// the trailing comma — operators should pass the raw connective
324    /// text the engine emits, not its lowercased form.
325    fn is_connective_opener(&self, text: &str) -> bool {
326        const ENGLISH_OPENERS: &[&str] = &[
327            // Same-entity continuation (SAME_ENTITY_CONNECTIVES).
328            "Additionally,",
329            "Furthermore,",
330            "It also",
331            // Same-action similarity (SAME_ACTION_CONNECTIVES).
332            "Similarly,",
333            "Likewise,",
334            // Contrast (CONTRAST_CONNECTIVES).
335            "Meanwhile,",
336            "However,",
337            "On the other hand,",
338            // RST-relation discourse markers (default Language impl).
339            "Because of this,",
340            "As a result,",
341            "Nevertheless,",
342            "Then,",
343            "If this happens,",
344            "In summary,",
345        ];
346        ENGLISH_OPENERS
347            .iter()
348            .any(|opener| text.starts_with(opener))
349    }
350
351    fn discourse_marker(&self, relation: crate::rst::RstRelation) -> Option<&'static str> {
352        use crate::rst::RstRelation::*;
353        Some(match relation {
354            Elaboration => "Furthermore, ",
355            Contrast => "However, ",
356            Cause => "Because of this, ",
357            Result => "As a result, ",
358            Concession => "Nevertheless, ",
359            Sequence => "Then, ",
360            Condition => "If this happens, ",
361            Background => "Meanwhile, ",
362            Summary => "In summary, ",
363        })
364    }
365
366    /// Produce a natural "X of Y" proportion phrase.
367    ///
368    /// Called by the `{…|proportion:total_key[:noun]}` pipe. Collapses the
369    /// awkward literal "N of N noun" to natural forms: "both noun" (when
370    /// both equal 2), "all N noun" (saturated, N>2), "the only noun" (1/1),
371    /// "none of the N noun" (0/N), "no noun" (0/0), and the literal
372    /// "n of t noun" only for partial coverage. See
373    /// [`crate::english_proportion`] for the full phrasing matrix.
374    ///
375    /// The default implementation encodes English via [`crate::english_proportion`].
376    /// Non-English grammars override with locale-appropriate forms
377    /// (e.g. Spanish `"ambos/ambas"`, `"todos los N"`; German `"beide"`,
378    /// `"alle N"`). The `features` parameter carries gender/number metadata
379    /// so implementations can select correctly-agreeing articles and
380    /// modifiers.
381    fn proportion_phrase(
382        &self,
383        matching: i64,
384        total: i64,
385        noun_singular: Option<&str>,
386        _features: &crate::agreement::AgreementFeatures,
387    ) -> String {
388        crate::proportion::english_proportion(self, matching, total, noun_singular)
389    }
390
391    /// Format an inter-event temporal delta as a narrative phrase.
392    ///
393    /// Called by the `{…|since_last}` pipe when the session has a
394    /// `last_temporal_anchor`. `diff_secs` is `current_ts - anchor_ts`
395    /// (positive = later event). Zero or negative returns "at the same
396    /// time" (English default); override for other languages.
397    ///
398    /// The default implementation produces English phrases like
399    /// "the next day", "moments later", "3 weeks later". Non-English
400    /// grammars should override to produce locale-appropriate phrases.
401    #[cfg(feature = "time")]
402    fn since_last_marker(&self, diff_secs: i64) -> String {
403        crate::time::format_since_last(diff_secs)
404    }
405
406    /// Realize a reference form as surface text for this language.
407    ///
408    /// The discourse policy layer chooses the [`crate::discourse::ReferenceForm`]
409    /// (Full, ShortName, Pronoun, Possessive, Demonstrative, or Zero) based on
410    /// language-agnostic rules. This method converts that choice into the
411    /// language-specific surface string.
412    ///
413    /// Only `Pronoun`, `Possessive`, `Demonstrative`, and `Zero` are meaningfully handled
414    /// here. `Full` and `ShortName` route through the engine's REG layer
415    /// (Dale & Reiter, graph-based) because they involve entity-attribute
416    /// logic that's not the language's concern.
417    ///
418    /// Returns:
419    /// - `Some(text)` for a realized form (e.g., `"it"`, `"they"`, `"this"`).
420    /// - `None` for `Zero` (pro-drop) or for `Full`/`ShortName` — the caller
421    ///   handles those via REG.
422    ///
423    /// The default implementation encodes English:
424    /// - `Pronoun`: `"they"` when `features.number` is `Plural` or `Dual`,
425    ///   `"it"` otherwise.
426    /// - `Possessive`: `"their"` when `features.number` is `Plural` or `Dual`,
427    ///   `"its"` otherwise.
428    /// - `Demonstrative`: `"this"`.
429    /// - `Zero`: `None` (English doesn't drop pronouns).
430    /// - `Full` / `ShortName`: `None` (engine handles via REG).
431    fn realize_reference(
432        &self,
433        form: crate::discourse::ReferenceForm,
434        features: &crate::agreement::AgreementFeatures,
435    ) -> Option<String> {
436        use crate::agreement::Number;
437        use crate::discourse::ReferenceForm;
438        match form {
439            ReferenceForm::Pronoun => Some(match features.number {
440                Number::Plural | Number::Dual => "they".to_string(),
441                _ => "it".to_string(),
442            }),
443            ReferenceForm::Possessive => Some(match features.number {
444                Number::Plural | Number::Dual => "their".to_string(),
445                _ => "its".to_string(),
446            }),
447            ReferenceForm::Demonstrative => Some("this".to_string()),
448            ReferenceForm::Zero => None,
449            ReferenceForm::Full | ReferenceForm::ShortName => None,
450        }
451    }
452
453    /// Convert a named owner phrase into a possessive owner phrase.
454    ///
455    /// Called by `{name|possessive}` when discourse policy says the entity
456    /// should be rendered by name rather than possessive pronoun. The default
457    /// is English-shaped (`"Foo" -> "Foo's"`, `"Services" -> "Services'"`);
458    /// non-English grammars should override for language-specific genitive
459    /// constructions.
460    fn possessive_name(&self, owner: &str) -> String {
461        let owner = owner.trim();
462        if owner.is_empty() {
463            return String::new();
464        }
465        if owner.ends_with('s') || owner.ends_with('S') {
466            format!("{owner}'")
467        } else {
468            format!("{owner}'s")
469        }
470    }
471}
472
473/// Default English-style verb phrase composition. Provided as a free
474/// function so custom `Language` impls can delegate to it selectively
475/// (`fn verb_phrase(…) { english_verb_phrase(self, …) }`).
476pub fn english_verb_phrase<L: Language + ?Sized>(
477    lang: &L,
478    verb: &str,
479    form: VerbForm,
480    voice: Voice,
481    person: Person,
482) -> String {
483    let (tense, aspect, mood) = form.resolve();
484    let past_participle = lang.past_participle(verb);
485    let present_participle = lang.present_participle(verb);
486
487    // "has" / "have" depends on person; use the language's own conjugation
488    // so this still works with any `Language` that overrides `conjugate`.
489    let have_aux = lang.conjugate("have", Tense::Present, person);
490    let had_aux = "had";
491    let be_present = lang.conjugate("be", Tense::Present, person);
492    let be_past = match person {
493        Person::Third | Person::First => "was".to_string(),
494        Person::Second => "were".to_string(),
495    };
496
497    match mood {
498        Mood::Conditional => match (aspect, voice) {
499            (Aspect::Simple, Voice::Active) => format!("would {verb}"),
500            (Aspect::Simple, Voice::Passive) => format!("would be {past_participle}"),
501            (Aspect::Perfect, Voice::Active) => format!("would have {past_participle}"),
502            (Aspect::Perfect, Voice::Passive) => {
503                format!("would have been {past_participle}")
504            }
505            (Aspect::Progressive, Voice::Active) => format!("would be {present_participle}"),
506            (Aspect::Progressive, Voice::Passive) => {
507                format!("would be being {past_participle}")
508            }
509        },
510        Mood::Indicative => match (tense, aspect, voice) {
511            // Simple
512            (Tense::Past, Aspect::Simple, Voice::Active) => {
513                lang.conjugate(verb, Tense::Past, person)
514            }
515            (Tense::Past, Aspect::Simple, Voice::Passive) => {
516                format!("{be_past} {past_participle}")
517            }
518            (Tense::Present, Aspect::Simple, Voice::Active) => {
519                lang.conjugate(verb, Tense::Present, person)
520            }
521            (Tense::Present, Aspect::Simple, Voice::Passive) => {
522                format!("{be_present} {past_participle}")
523            }
524            (Tense::Future, Aspect::Simple, Voice::Active) => format!("will {verb}"),
525            (Tense::Future, Aspect::Simple, Voice::Passive) => {
526                format!("will be {past_participle}")
527            }
528
529            // Perfect
530            (Tense::Past, Aspect::Perfect, Voice::Active) => {
531                format!("{had_aux} {past_participle}")
532            }
533            (Tense::Past, Aspect::Perfect, Voice::Passive) => {
534                format!("{had_aux} been {past_participle}")
535            }
536            (Tense::Present, Aspect::Perfect, Voice::Active) => {
537                format!("{have_aux} {past_participle}")
538            }
539            (Tense::Present, Aspect::Perfect, Voice::Passive) => {
540                format!("{have_aux} been {past_participle}")
541            }
542            (Tense::Future, Aspect::Perfect, Voice::Active) => {
543                format!("will have {past_participle}")
544            }
545            (Tense::Future, Aspect::Perfect, Voice::Passive) => {
546                format!("will have been {past_participle}")
547            }
548
549            // Progressive
550            (Tense::Past, Aspect::Progressive, Voice::Active) => {
551                format!("{be_past} {present_participle}")
552            }
553            (Tense::Past, Aspect::Progressive, Voice::Passive) => {
554                format!("{be_past} being {past_participle}")
555            }
556            (Tense::Present, Aspect::Progressive, Voice::Active) => {
557                format!("{be_present} {present_participle}")
558            }
559            (Tense::Present, Aspect::Progressive, Voice::Passive) => {
560                format!("{be_present} being {past_participle}")
561            }
562            (Tense::Future, Aspect::Progressive, Voice::Active) => {
563                format!("will be {present_participle}")
564            }
565            (Tense::Future, Aspect::Progressive, Voice::Passive) => {
566                // "will be being renamed" is technically valid but awkward;
567                // callers rarely want it. Composed anyway for completeness.
568                format!("will be being {past_participle}")
569            }
570        },
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::PluralCategory;
577    use super::*;
578    use crate::agreement::AgreementFeatures;
579
580    /// Minimal Language implementation used only in unit tests for this module.
581    struct MiniLang;
582
583    impl Language for MiniLang {
584        fn pluralize(&self, word: &str, count: usize) -> String {
585            if count == 1 {
586                return word.to_string();
587            }
588            // Basic English pluralisation rules for common test words.
589            if word.ends_with("ss")
590                || word.ends_with("sh")
591                || word.ends_with("ch")
592                || word.ends_with('x')
593                || word.ends_with('z')
594            {
595                format!("{word}es")
596            } else if word.ends_with('s') {
597                // e.g. "class" → "classes"
598                format!("{word}es")
599            } else {
600                format!("{word}s")
601            }
602        }
603        fn singularize(&self, word: &str) -> String {
604            word.strip_suffix('s').unwrap_or(word).to_string()
605        }
606        fn article(&self, _word: &str) -> &str {
607            "a"
608        }
609        fn conjugate(&self, verb: &str, tense: Tense, _person: Person) -> String {
610            match tense {
611                Tense::Past => format!("{verb}ed"),
612                Tense::Present => verb.to_string(),
613                Tense::Future => format!("will {verb}"),
614            }
615        }
616        fn past_participle(&self, verb: &str) -> String {
617            format!("{verb}ed")
618        }
619        fn present_participle(&self, verb: &str) -> String {
620            format!("{verb}ing")
621        }
622        fn join_list(&self, items: &[&str], _conjunction: Conjunction) -> String {
623            items.join(", ")
624        }
625        fn ordinal(&self, n: usize) -> String {
626            format!("{n}th")
627        }
628        fn number_to_words(&self, n: usize) -> String {
629            format!("{n}")
630        }
631    }
632
633    #[test]
634    fn plural_description_default_zero_is_empty() {
635        let l = MiniLang;
636        assert_eq!(
637            l.plural_description("class", 0, &AgreementFeatures::default()),
638            ""
639        );
640    }
641
642    #[test]
643    fn plural_description_default_one_is_the_type() {
644        let l = MiniLang;
645        assert_eq!(
646            l.plural_description("class", 1, &AgreementFeatures::default()),
647            "the class"
648        );
649    }
650
651    #[test]
652    fn plural_description_default_many_uses_pluralize() {
653        let l = MiniLang;
654        assert_eq!(
655            l.plural_description("class", 3, &AgreementFeatures::default()),
656            "the 3 classes"
657        );
658    }
659
660    #[test]
661    fn plural_description_two_items() {
662        let l = MiniLang;
663        assert_eq!(
664            l.plural_description("service", 2, &AgreementFeatures::default()),
665            "the 2 services"
666        );
667    }
668
669    #[test]
670    fn plural_description_ignores_features_in_default_impl() {
671        // The default impl does not use features — result must be identical
672        // regardless of what features are passed. Languages that care will override.
673        let l = MiniLang;
674        let with_features = l.plural_description("class", 3, &AgreementFeatures::default());
675        let without = l.plural_description("class", 3, &AgreementFeatures::default());
676        assert_eq!(with_features, without);
677        assert_eq!(with_features, "the 3 classes");
678    }
679
680    // ── PluralCategory + default trait methods ───────────────────────────────
681
682    #[test]
683    fn default_plural_category_one() {
684        let lang = MiniLang;
685        assert_eq!(lang.plural_category(1), PluralCategory::One);
686    }
687
688    #[test]
689    fn default_plural_category_other_for_zero() {
690        let lang = MiniLang;
691        assert_eq!(lang.plural_category(0), PluralCategory::Other);
692    }
693
694    #[test]
695    fn default_plural_category_other_for_plurals() {
696        let lang = MiniLang;
697        assert_eq!(lang.plural_category(2), PluralCategory::Other);
698        assert_eq!(lang.plural_category(17), PluralCategory::Other);
699    }
700
701    #[test]
702    fn default_plural_category_other_for_negatives() {
703        let lang = MiniLang;
704        assert_eq!(lang.plural_category(-5), PluralCategory::Other);
705    }
706
707    #[test]
708    fn default_pluralize_with_category_one_is_singular() {
709        let lang = MiniLang;
710        assert_eq!(
711            lang.pluralize_with_category("service", PluralCategory::One),
712            "service"
713        );
714    }
715
716    #[test]
717    fn default_pluralize_with_category_other_is_plural() {
718        let lang = MiniLang;
719        assert_eq!(
720            lang.pluralize_with_category("service", PluralCategory::Other),
721            "services"
722        );
723    }
724
725    #[test]
726    fn default_pluralize_with_category_few_falls_to_plural() {
727        // English collapses Few/Many/Zero/Two to Other; the default impl
728        // routes all non-One categories to the plural form.
729        let lang = MiniLang;
730        assert_eq!(
731            lang.pluralize_with_category("service", PluralCategory::Few),
732            "services"
733        );
734    }
735
736    #[test]
737    fn default_pluralize_with_category_many_falls_to_plural() {
738        let lang = MiniLang;
739        assert_eq!(
740            lang.pluralize_with_category("service", PluralCategory::Many),
741            "services"
742        );
743    }
744
745    #[test]
746    fn default_pluralize_with_category_zero_falls_to_plural() {
747        let lang = MiniLang;
748        assert_eq!(
749            lang.pluralize_with_category("service", PluralCategory::Zero),
750            "services"
751        );
752    }
753
754    #[test]
755    fn default_pluralize_with_category_two_falls_to_plural() {
756        let lang = MiniLang;
757        assert_eq!(
758            lang.pluralize_with_category("service", PluralCategory::Two),
759            "services"
760        );
761    }
762
763    #[test]
764    fn plural_category_default_variant_is_other() {
765        assert_eq!(PluralCategory::default(), PluralCategory::Other);
766    }
767
768    // ── realize_reference default implementation ─────────────────────────────
769
770    #[test]
771    fn realize_reference_pronoun_singular() {
772        let lang = MiniLang;
773        let f = AgreementFeatures::default(); // number=Unknown → falls through to "it"
774        assert_eq!(
775            lang.realize_reference(crate::discourse::ReferenceForm::Pronoun, &f),
776            Some("it".to_string())
777        );
778    }
779
780    #[test]
781    fn realize_reference_pronoun_plural() {
782        let lang = MiniLang;
783        let f = AgreementFeatures::default().with_number(crate::agreement::Number::Plural);
784        assert_eq!(
785            lang.realize_reference(crate::discourse::ReferenceForm::Pronoun, &f),
786            Some("they".to_string())
787        );
788    }
789
790    #[test]
791    fn realize_reference_pronoun_dual() {
792        let lang = MiniLang;
793        let f = AgreementFeatures::default().with_number(crate::agreement::Number::Dual);
794        assert_eq!(
795            lang.realize_reference(crate::discourse::ReferenceForm::Pronoun, &f),
796            Some("they".to_string())
797        );
798    }
799
800    #[test]
801    fn realize_reference_possessive_singular() {
802        let lang = MiniLang;
803        let f = AgreementFeatures::default();
804        assert_eq!(
805            lang.realize_reference(crate::discourse::ReferenceForm::Possessive, &f),
806            Some("its".to_string())
807        );
808    }
809
810    #[test]
811    fn realize_reference_possessive_plural() {
812        let lang = MiniLang;
813        let f = AgreementFeatures::default().with_number(crate::agreement::Number::Plural);
814        assert_eq!(
815            lang.realize_reference(crate::discourse::ReferenceForm::Possessive, &f),
816            Some("their".to_string())
817        );
818    }
819
820    #[test]
821    fn realize_reference_demonstrative() {
822        let lang = MiniLang;
823        let f = AgreementFeatures::default();
824        assert_eq!(
825            lang.realize_reference(crate::discourse::ReferenceForm::Demonstrative, &f),
826            Some("this".to_string())
827        );
828    }
829
830    #[test]
831    fn realize_reference_zero_is_none() {
832        let lang = MiniLang;
833        let f = AgreementFeatures::default();
834        assert_eq!(
835            lang.realize_reference(crate::discourse::ReferenceForm::Zero, &f),
836            None
837        );
838    }
839
840    #[test]
841    fn realize_reference_full_is_none() {
842        // Full form is handled by engine REG, not the language layer.
843        let lang = MiniLang;
844        let f = AgreementFeatures::default();
845        assert_eq!(
846            lang.realize_reference(crate::discourse::ReferenceForm::Full, &f),
847            None
848        );
849    }
850
851    #[test]
852    fn realize_reference_short_name_is_none() {
853        // ShortName is handled by engine REG, not the language layer.
854        let lang = MiniLang;
855        let f = AgreementFeatures::default();
856        assert_eq!(
857            lang.realize_reference(crate::discourse::ReferenceForm::ShortName, &f),
858            None
859        );
860    }
861
862    #[test]
863    fn possessive_name_adds_english_suffix() {
864        let lang = MiniLang;
865        assert_eq!(lang.possessive_name("UserService"), "UserService's");
866        assert_eq!(lang.possessive_name("CoreServices"), "CoreServices'");
867    }
868
869    // ── proportion_phrase default implementation ────────────────────────────
870
871    #[test]
872    fn proportion_phrase_default_delegates_to_english_both() {
873        let lang = MiniLang;
874        let f = AgreementFeatures::default();
875        assert_eq!(
876            lang.proportion_phrase(2, 2, Some("modified file"), &f),
877            "both modified files"
878        );
879    }
880
881    #[test]
882    fn proportion_phrase_default_delegates_to_english_all_n() {
883        let lang = MiniLang;
884        let f = AgreementFeatures::default();
885        assert_eq!(
886            lang.proportion_phrase(13, 13, Some("modified file"), &f),
887            "all 13 modified files"
888        );
889    }
890
891    #[test]
892    fn proportion_phrase_default_partial_without_noun() {
893        let lang = MiniLang;
894        let f = AgreementFeatures::default();
895        assert_eq!(lang.proportion_phrase(3, 13, None, &f), "3 of 13");
896    }
897
898    #[test]
899    fn proportion_phrase_default_zero_zero_with_noun() {
900        let lang = MiniLang;
901        let f = AgreementFeatures::default();
902        assert_eq!(lang.proportion_phrase(0, 0, Some("file"), &f), "no files");
903    }
904
905    #[test]
906    fn discourse_marker_english_defaults() {
907        let lang = MiniLang;
908        use crate::rst::RstRelation::*;
909        assert_eq!(lang.discourse_marker(Elaboration), Some("Furthermore, "));
910        assert_eq!(lang.discourse_marker(Contrast), Some("However, "));
911        assert_eq!(lang.discourse_marker(Result), Some("As a result, "));
912    }
913
914    // ── since_last_marker default (English) ──────────────────────────────────
915
916    #[cfg(feature = "time")]
917    #[test]
918    fn since_last_marker_default_the_next_day() {
919        let lang = MiniLang;
920        assert_eq!(lang.since_last_marker(86_400 + 1), "the next day");
921    }
922
923    #[cfg(feature = "time")]
924    #[test]
925    fn since_last_marker_default_moments_later() {
926        let lang = MiniLang;
927        assert_eq!(lang.since_last_marker(30), "moments later");
928    }
929
930    #[cfg(feature = "time")]
931    #[test]
932    fn since_last_marker_default_zero_is_at_the_same_time() {
933        let lang = MiniLang;
934        assert_eq!(lang.since_last_marker(0), "at the same time");
935    }
936
937    #[cfg(feature = "time")]
938    #[test]
939    fn since_last_marker_default_years() {
940        let lang = MiniLang;
941        assert_eq!(lang.since_last_marker(3 * 365 * 86_400), "3 years later");
942    }
943}