Skip to main content

shuyi_assets/
names.rs

1//! A names table: every display name of a story (characters, places,
2//! factions, terms) by stable id, so a rename is one line. Text refers to a
3//! name with a token, `{n:<id>}` (or `{N:<id>}` for a capital first
4//! letter), filled in by [`Names::substitute`] when it is shown. As a
5//! format (ADR-0007): [`NamesRon`].
6use crate::prelude::*;
7use serde::{Deserialize, Serialize};
8
9use alloc::borrow::Cow;
10use alloc::collections::BTreeMap;
11use core::cmp::Reverse;
12
13use shuyi_core::text::{Part, split_tokens};
14
15use crate::dialogue::char_problem;
16use crate::error::{ContentError, line_of};
17use crate::font::CellWidths;
18use crate::format::{AssetKind, AssetPath, DecodeCx, Format, TEXT_MAX_BYTES, text};
19use crate::lang::text_width;
20use crate::ron::parse_ron;
21
22/// Every display name, by name id.
23#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
24pub struct Names {
25    /// Display names by id.
26    pub names: BTreeMap<String, String>,
27}
28
29impl AssetKind for Names {
30    const NAME: &'static str = "names table";
31}
32
33/// A names table (`names`, `.ron`), checked as [`from_source`] says.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub struct NamesRon;
36
37impl Format for NamesRon {
38    type Asset = Names;
39
40    fn id(&self) -> &'static str {
41        "names"
42    }
43
44    fn extensions(&self) -> &'static [&'static str] {
45        &["ron"]
46    }
47
48    fn max_bytes(&self) -> usize {
49        TEXT_MAX_BYTES
50    }
51
52    fn decode(
53        &self,
54        file: &AssetPath,
55        bytes: &[u8],
56        _cx: &DecodeCx<'_>,
57    ) -> Result<Names, Vec<ContentError>> {
58        from_source(file.shown(), text(file, bytes)?)
59    }
60}
61
62/// A name token: the inside of `{n:<id>}` or `{N:<id>}`.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct NameToken<'a> {
65    /// The name id.
66    pub id: &'a str,
67    /// Whether the name's first letter is capitalised (`{N:...}`).
68    pub capital: bool,
69}
70
71/// The name token that `token` (the text between the braces) is, if it
72/// starts with `n:` or `N:`. The id may be anything; see [`is_name_id`].
73pub fn name_token(token: &str) -> Option<NameToken<'_>> {
74    if let Some(id) = token.strip_prefix("n:") {
75        return Some(NameToken { id, capital: false });
76    }
77    token
78        .strip_prefix("N:")
79        .map(|id| NameToken { id, capital: true })
80}
81
82/// Whether `id` is a valid name id: lowercase ASCII letters, digits, `_`
83/// and `.`, not empty.
84pub fn is_name_id(id: &str) -> bool {
85    !id.is_empty()
86        && id
87            .bytes()
88            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'.')
89}
90
91impl Names {
92    /// The display name with id `id`.
93    pub fn get(&self, id: &str) -> Option<&str> {
94        self.names.get(id).map(String::as_str)
95    }
96
97    /// Cells of the longest display name (0 for an empty table), a wide
98    /// character of `widths` two cells: what every name token counts as
99    /// when measuring text, so a rename can't push a line past its limit.
100    pub fn longest(&self, widths: &CellWidths) -> usize {
101        self.names
102            .values()
103            .map(|n| text_width(n, widths))
104            .max()
105            .unwrap_or(0)
106    }
107
108    /// What name token `token` becomes, if it is one with a known id.
109    fn token(&self, token: &str) -> Option<Cow<'_, str>> {
110        let t = name_token(token)?;
111        let name = self.get(t.id)?;
112        Some(if t.capital {
113            Cow::Owned(capitalise(name))
114        } else {
115            Cow::Borrowed(name)
116        })
117    }
118
119    /// `text` with every name token replaced by its display name. Other
120    /// tokens (the game's) and unknown ids are left as written (the
121    /// dialogue validator rejects unknown ones).
122    pub fn substitute<'t>(&self, text: &'t str) -> Cow<'t, str> {
123        if !text.contains('{') {
124            return Cow::Borrowed(text);
125        }
126        let mut out = String::with_capacity(text.len());
127        for part in split_tokens(text) {
128            match part {
129                Part::Token(t) => {
130                    if let Some(name) = self.token(t) {
131                        out.push_str(&name);
132                    } else {
133                        out.push('{');
134                        out.push_str(t);
135                        out.push('}');
136                    }
137                }
138                Part::Text(t) | Part::Unclosed(t) => out.push_str(t),
139            }
140        }
141        Cow::Owned(out)
142    }
143
144    /// The longest display name written literally in `text` (outside
145    /// tokens), with its id; of equally long ones, the first by id. The
146    /// longest, so that a full name is reported as itself and not as one
147    /// of its short forms (`Ada Brook` is `smith`, not `family.brook`).
148    /// See [`literal_form`] for what counts.
149    pub fn literal_in(&self, text: &str) -> Option<(&str, &str)> {
150        Literals::of(self).find_in(text)
151    }
152}
153
154/// The literal forms of a table's names ([`literal_form`]) by their first
155/// byte, found once for many texts: a text tries at each place a whole
156/// word may start only the names that begin with the byte there. A
157/// substring search per name per line of every script took most of a
158/// handheld's start.
159pub(crate) struct Literals<'n> {
160    /// Each name with a literal form, as its id and the form, by id.
161    forms: Vec<(&'n str, &'n str)>,
162    /// For each first byte, the forms (their places in `forms`) that
163    /// begin with it.
164    by_first: BTreeMap<u8, Vec<usize>>,
165}
166
167impl<'n> Literals<'n> {
168    /// The literal forms of `names`.
169    pub(crate) fn of(names: &'n Names) -> Self {
170        let forms: Vec<(&str, &str)> = names
171            .names
172            .iter()
173            .filter_map(|(id, name)| Some((id.as_str(), literal_form(name)?)))
174            .filter(|(_, form)| !form.is_empty())
175            .collect();
176        let mut by_first: BTreeMap<u8, Vec<usize>> = BTreeMap::new();
177        for (k, (_, form)) in forms.iter().enumerate() {
178            by_first.entry(form.as_bytes()[0]).or_default().push(k);
179        }
180        Self { forms, by_first }
181    }
182
183    /// [`Names::literal_in`]: the longest form written as whole words in
184    /// `text` outside its tokens, with its id; of equally long ones, the
185    /// first by id. A whole word starts at the start of a part or after a
186    /// character that is no ASCII letter or digit, and is not followed by
187    /// one.
188    pub(crate) fn find_in(&self, text: &str) -> Option<(&'n str, &'n str)> {
189        let mut found: Vec<usize> = Vec::new();
190        let plain = split_tokens(text).filter_map(|part| match part {
191            Part::Text(t) | Part::Unclosed(t) => Some(t),
192            Part::Token(_) => None,
193        });
194        for part in plain {
195            let mut at_start = true;
196            for (i, c) in part.char_indices() {
197                if at_start && let Some(forms) = self.by_first.get(&part.as_bytes()[i]) {
198                    found.extend(
199                        forms
200                            .iter()
201                            .filter(|&&k| whole_word_at(part, i, self.forms[k].1)),
202                    );
203                }
204                at_start = !c.is_ascii_alphanumeric();
205            }
206        }
207        found
208            .into_iter()
209            .max_by_key(|&k| (self.forms[k].1.len(), Reverse(k)))
210            .map(|k| self.forms[k])
211    }
212}
213
214/// Whether `word` is written in `text` at byte `i` and is not followed by
215/// an ASCII letter or digit, case-sensitively.
216fn whole_word_at(text: &str, i: usize, word: &str) -> bool {
217    text.as_bytes()[i..].starts_with(word.as_bytes())
218        && !text[i + word.len()..]
219            .chars()
220            .next()
221            .is_some_and(|c| c.is_ascii_alphanumeric())
222}
223
224/// What of display name `name` the literal-name check looks for: the name
225/// without a leading `the `, `a ` or `an ` (so `the Old Mill` is also
226/// found as `Old Mill`), or `None` for a name with no capital letter
227/// (`breath`, `a vow`: ordinary words the check would trip on).
228pub fn literal_form(name: &str) -> Option<&str> {
229    if !name.bytes().any(|b| b.is_ascii_uppercase()) {
230        return None;
231    }
232    let form = ["the ", "a ", "an "]
233        .iter()
234        .find_map(|article| name.strip_prefix(article))
235        .unwrap_or(name);
236    Some(form)
237}
238
239/// Whether `c` is a letter of the Latin script: ASCII, Latin-1 or Latin
240/// Extended-A/B.
241fn is_latin_letter(c: char) -> bool {
242    c.is_ascii_alphabetic() || (('\u{c0}'..='\u{24f}').contains(&c) && c.is_alphabetic())
243}
244
245/// `name` with its first letter capitalised, if it starts with a Latin
246/// letter; as it is otherwise (a name in a script without capitals).
247fn capitalise(name: &str) -> String {
248    let mut chars = name.chars();
249    match chars.next() {
250        Some(first) if is_latin_letter(first) => first.to_uppercase().chain(chars).collect(),
251        _ => name.to_owned(),
252    }
253}
254
255/// `names` (the table of file `story`) with the names of `source` added
256/// (a second file, the test files' names, say). `source` is checked like a names
257/// file of its own, attributing errors to `file`; an id or a name that
258/// `names` has too is an error.
259pub fn with_part(
260    mut names: Names,
261    story: &str,
262    file: &str,
263    source: &str,
264) -> Result<Names, Vec<ContentError>> {
265    let part = from_source(file, source)?;
266    let mut errors = Vec::new();
267    for (id, name) in part.names {
268        let mut err = |message: String| {
269            let e = ContentError::new(file, message);
270            errors.push(match line_of(source, &format!("\"{id}\":")) {
271                Some(l) => e.at(l, None),
272                None => e,
273            });
274        };
275        if names.names.contains_key(&id) {
276            err(format!("name id \"{id}\" is already in {story}"));
277        } else if let Some((other, _)) = names.names.iter().find(|(_, n)| **n == name) {
278            err(format!(
279                "\"{name}\" is the name of \"{other}\" in {story} too; names must be unique"
280            ));
281        } else {
282            names.names.insert(id, name);
283        }
284    }
285    if errors.is_empty() {
286        Ok(names)
287    } else {
288        Err(errors)
289    }
290}
291
292/// Parses and validates names `source`, attributing errors to `file`:
293/// valid ids, each once; values non-empty, without braces or control
294/// characters ([`char_problem`]), each used once. Whether the font draws a
295/// name is checked with the rest of the text
296/// ([`undrawn`](crate::font::undrawn)). Reports every problem found.
297pub fn from_source(file: &str, source: &str) -> Result<Names, Vec<ContentError>> {
298    let names: BTreeMap<String, String> = parse_ron(file, source).map_err(|e| vec![e])?;
299    let mut errors = Vec::new();
300    let mut err = |id: &str, message: String| {
301        let e = ContentError::new(file, message);
302        errors.push(match line_of(source, &format!("\"{id}\":")) {
303            Some(l) => e.at(l, None),
304            None => e,
305        });
306    };
307    let mut first_with: BTreeMap<&str, &str> = BTreeMap::new();
308    for (id, name) in &names {
309        if !is_name_id(id) {
310            err(
311                id,
312                format!("\"{id}\" is not a valid name id; use lowercase letters, digits, _ and ."),
313            );
314        }
315        let key = format!("\"{id}\":");
316        if source
317            .lines()
318            .filter(|l| l.trim_start().starts_with(&key))
319            .count()
320            > 1
321        {
322            err(id, format!("name id \"{id}\" is listed more than once"));
323        }
324        if name.trim().is_empty() {
325            err(id, format!("name \"{id}\" is empty"));
326        }
327        if name.contains(['{', '}']) {
328            err(
329                id,
330                format!("name \"{id}\" contains a brace; names can't hold tokens"),
331            );
332        }
333        if let Some(problem) = name.chars().find_map(char_problem) {
334            err(id, format!("name \"{id}\": {problem}"));
335        }
336        if let Some(other) = first_with.insert(name, id) {
337            err(
338                id,
339                format!(
340                    "\"{name}\" is the name of both \"{other}\" and \"{id}\"; names must be unique"
341                ),
342            );
343        }
344    }
345    if errors.is_empty() {
346        Ok(Names { names })
347    } else {
348        Err(errors)
349    }
350}
351
352#[cfg(test)]
353mod tests;