1use std::collections::HashMap;
2
3use crate::ast::NounsBlock;
4use crate::span::Span;
5
6#[derive(Debug, Clone)]
7pub struct Entry {
8 pub singular: String,
9 pub plural: String,
10 pub short: String,
11 pub comment: Option<String>,
12 pub span: Span,
13}
14
15#[derive(Debug, Default)]
17pub struct Dict {
18 by_id: HashMap<String, Entry>,
19}
20
21#[cfg_attr(feature = "serde", derive(serde::Serialize))]
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Part {
25 Noun(String),
27 Literal(String),
29}
30
31#[cfg_attr(feature = "serde", derive(serde::Serialize))]
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Compound {
37 pub parts: Vec<Part>,
38}
39
40impl Compound {
41 pub fn noun(name: impl Into<String>) -> Self {
42 Compound {
43 parts: vec![Part::Noun(name.into())],
44 }
45 }
46
47 pub fn literal(text: impl Into<String>) -> Self {
48 Compound {
49 parts: vec![Part::Literal(text.into())],
50 }
51 }
52
53 pub fn as_single_noun(&self) -> Option<&str> {
55 match self.parts.as_slice() {
56 [Part::Noun(n)] => Some(n),
57 _ => None,
58 }
59 }
60
61 pub fn nouns(&self) -> impl Iterator<Item = &str> {
63 self.parts.iter().filter_map(|p| match p {
64 Part::Noun(n) => Some(n.as_str()),
65 Part::Literal(_) => None,
66 })
67 }
68
69 pub fn singular(&self, dict: &Dict, separator: &str) -> String {
70 self.render(dict, separator, false)
71 }
72
73 pub fn plural(&self, dict: &Dict, separator: &str) -> String {
74 self.render(dict, separator, true)
75 }
76
77 pub fn short(&self, dict: &Dict, separator: &str) -> String {
79 self.parts
80 .iter()
81 .map(|part| match part {
82 Part::Literal(text) => text.clone(),
83 Part::Noun(id) => dict.short(id).into_value(),
84 })
85 .collect::<Vec<_>>()
86 .join(separator)
87 }
88
89 pub fn as_written(&self, separator: &str) -> String {
91 self.parts
92 .iter()
93 .map(|p| match p {
94 Part::Noun(n) | Part::Literal(n) => n.as_str(),
95 })
96 .collect::<Vec<_>>()
97 .join(separator)
98 }
99
100 fn render(&self, dict: &Dict, separator: &str, last_plural: bool) -> String {
101 let last = self.parts.len().saturating_sub(1);
102 self.parts
103 .iter()
104 .enumerate()
105 .map(|(i, part)| match part {
106 Part::Literal(text) => text.clone(),
107 Part::Noun(name) if i == last && last_plural => dict.plural(name).into_value(),
108 Part::Noun(name) => dict.singular(name).into_value(),
109 })
110 .collect::<Vec<_>>()
111 .join(separator)
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum Resolved {
118 FromDict(String),
119 FromRule(String),
120}
121
122impl Resolved {
123 pub fn value(&self) -> &str {
124 match self {
125 Resolved::FromDict(s) | Resolved::FromRule(s) => s,
126 }
127 }
128
129 pub fn into_value(self) -> String {
130 match self {
131 Resolved::FromDict(s) | Resolved::FromRule(s) => s,
132 }
133 }
134}
135
136impl Dict {
137 pub fn from_block(block: Option<&NounsBlock>) -> Dict {
140 let mut dict = Dict::default();
141 let Some(block) = block else { return dict };
142 for e in &block.entries {
143 let word = |w: &Option<crate::ast::Name>| w.as_ref().map(|w| w.value.clone());
144 let singular = word(&e.singular).unwrap_or_else(|| e.id.value.clone());
145 let plural = word(&e.plural).unwrap_or_else(|| pluralize(&singular));
146 let short = word(&e.short).unwrap_or_else(|| singular.clone());
147 dict.by_id.insert(
148 e.id.value.clone(),
149 Entry {
150 singular,
151 plural,
152 short,
153 comment: e.comment.as_ref().map(|c| c.value.clone()),
154 span: e.id.span,
155 },
156 );
157 }
158 dict
159 }
160
161 pub fn get(&self, id: &str) -> Option<&Entry> {
162 self.by_id.get(id)
163 }
164
165 pub fn entries(&self) -> impl Iterator<Item = &Entry> {
166 self.by_id.values()
167 }
168
169 pub fn plural(&self, id: &str) -> Resolved {
170 match self.by_id.get(id) {
171 Some(e) => Resolved::FromDict(e.plural.clone()),
172 None => Resolved::FromRule(pluralize(id)),
173 }
174 }
175
176 pub fn singular(&self, id: &str) -> Resolved {
177 match self.by_id.get(id) {
178 Some(e) => Resolved::FromDict(e.singular.clone()),
179 None => Resolved::FromRule(id.to_string()),
180 }
181 }
182
183 pub fn short(&self, id: &str) -> Resolved {
184 match self.by_id.get(id) {
185 Some(e) => Resolved::FromDict(e.short.clone()),
186 None => Resolved::FromRule(id.to_string()),
187 }
188 }
189}
190
191fn is_vowel(c: char) -> bool {
192 matches!(c, 'a' | 'e' | 'i' | 'o' | 'u')
193}
194
195pub fn pluralize(word: &str) -> String {
197 let lower = word.to_ascii_lowercase();
198 if lower.ends_with("s")
199 || lower.ends_with("x")
200 || lower.ends_with("z")
201 || lower.ends_with("ch")
202 || lower.ends_with("sh")
203 {
204 return format!("{word}es");
205 }
206 if lower.ends_with('y') {
207 let before = lower.chars().rev().nth(1);
208 if before.is_some_and(|c| !is_vowel(c)) {
209 return format!("{}ies", &word[..word.len() - 1]);
210 }
211 }
212 format!("{word}s")
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::span::Span;
219
220 #[test]
221 fn regular_plurals() {
222 assert_eq!(pluralize("post"), "posts");
223 assert_eq!(pluralize("category"), "categories");
224 assert_eq!(pluralize("history"), "histories");
225 assert_eq!(pluralize("box"), "boxes");
226 assert_eq!(pluralize("day"), "days");
227 }
228
229 fn dict_with(entries: &[(&str, &str, &str, &str)]) -> Dict {
231 let mut dict = Dict::default();
232 for (id, singular, plural, short) in entries {
233 dict.by_id.insert(
234 id.to_string(),
235 Entry {
236 singular: singular.to_string(),
237 plural: plural.to_string(),
238 short: short.to_string(),
239 comment: None,
240 span: Span::new(0, 0),
241 },
242 );
243 }
244 dict
245 }
246
247 #[test]
248 fn compound_inflects_only_the_last_noun() {
249 let dict = dict_with(&[
250 ("message", "message", "messages", "msg"),
251 ("history", "history", "histories", "hist"),
252 ]);
253 let c = Compound {
254 parts: vec![Part::Literal("sent".into()), Part::Noun("message".into())],
255 };
256 assert_eq!(c.singular(&dict, "_"), "sent_message");
257 assert_eq!(c.plural(&dict, "_"), "sent_messages");
258
259 let c = Compound {
260 parts: vec![Part::Noun("post".into()), Part::Noun("history".into())],
261 };
262 assert_eq!(c.singular(&dict, "_"), "post_history");
263 assert_eq!(c.plural(&dict, "_"), "post_histories");
264 }
265
266 #[test]
267 fn literal_never_inflects() {
268 let dict = dict_with(&[]);
269 let c = Compound::literal("users");
270 assert_eq!(c.plural(&dict, "_"), "users");
271 assert_eq!(c.short(&dict, "_"), "users");
272 }
273
274 #[test]
275 fn short_replaces_every_noun_and_keeps_the_number() {
276 let dict = dict_with(&[
277 ("message", "message", "messages", "msg"),
278 ("history", "history", "histories", "hist"),
279 ]);
280 let c = Compound {
281 parts: vec![Part::Noun("message".into()), Part::Noun("history".into())],
282 };
283 assert_eq!(c.short(&dict, "_"), "msg_hist");
284
285 let c = Compound {
286 parts: vec![Part::Literal("sent".into()), Part::Noun("message".into())],
287 };
288 assert_eq!(c.short(&dict, "_"), "sent_msg");
289 }
290
291 #[test]
292 fn unregistered_nouns_keep_the_identifier() {
293 let dict = dict_with(&[]);
294 assert_eq!(dict.singular("widget").value(), "widget");
295 assert_eq!(dict.short("widget").value(), "widget");
296 assert_eq!(dict.plural("widget").value(), "widgets");
297 }
298}