Skip to main content

shuyi_assets/dialogue/
parse.rs

1//! The `.dlg` parser: lines → scenes. Reports syntax and structure errors
2//! (unknown directives, bad ids, missing `@end`, blocks left open, control
3//! characters); checks that need the whole scene or other files are in `check`.
4use crate::prelude::*;
5use serde::{Deserialize, Serialize};
6
7use super::{CharacterId, ChoiceOption, Directive, LineId, MusicLine, Scene, Side, Step};
8use crate::error::ContentError;
9
10/// A scene as written in a file, with the source lines the checks point at.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct ParsedScene<D = super::NoDirective> {
13    /// The scene.
14    pub scene: Scene<D>,
15    /// The file it is in (for error messages).
16    pub file: String,
17    /// 1-based line of its `@scene`.
18    pub line: u32,
19    /// Where its steps are.
20    pub lines: Lines,
21}
22
23/// Where the steps of one list of steps (a scene's, a reaction's, one part
24/// of an `@if` block) are in the file.
25#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
26pub struct Lines {
27    /// 1-based line where each step starts, in the order of the steps.
28    pub steps: Vec<u32>,
29    /// The parts of each block among the steps (a `Step::Choice` or a
30    /// `Step::If`), in the order of the blocks: a choice's options; an
31    /// `@if` block's two parts, the steps played with the character there
32    /// and those played without.
33    pub blocks: Vec<Vec<PartLines>>,
34}
35
36/// Where one part of a block is: an option of a `@choice`, or one of the
37/// two parts of an `@if` block.
38#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
39pub struct PartLines {
40    /// 1-based line that starts it: the option's `* tone: text`, the
41    /// `@if`, or the `@else` (the `@if`'s line when it has no `@else`).
42    pub line: u32,
43    /// Where its steps are.
44    pub lines: Lines,
45}
46
47/// A block being read.
48enum Open<D> {
49    /// `@choice`, until its `@endchoice`.
50    Choice {
51        /// Line of its `@choice`.
52        line: u32,
53        options: Vec<ChoiceOption<D>>,
54        parts: Vec<PartLines>,
55        /// The `@delivery` in force when it opened: each reply starts from
56        /// it and the block leaves it.
57        outer_delivery: Option<String>,
58    },
59    /// `@if`, until its `@endif`.
60    If {
61        /// Line of its `@if`.
62        line: u32,
63        character: CharacterId,
64        then: (Vec<Step<D>>, Lines),
65        /// The line of its `@else` and the steps after it, once read.
66        otherwise: Option<(u32, Vec<Step<D>>, Lines)>,
67        /// The `@delivery` in force when it opened: its `@else` starts from
68        /// it and the block leaves it.
69        outer_delivery: Option<String>,
70    },
71}
72
73impl<D> Open<D> {
74    /// The error for a block still open where its scene or reaction ends.
75    fn unclosed(&self) -> (u32, &'static str) {
76        match self {
77            Open::Choice { line, .. } => (*line, "@choice has no @endchoice"),
78            Open::If { line, .. } => (*line, "@if has no @endif"),
79        }
80    }
81}
82
83/// How far reaction lines are indented under their option.
84const REACTION_INDENT: usize = 2;
85
86/// Parses dialogue `source` (errors attributed to `file`) into its scenes,
87/// in file order, and every syntax error found. A scene with errors in it
88/// is still returned (without the broken lines), so later checks can run.
89/// A directive the engine doesn't know is read by the game's `D`.
90pub fn parse_dlg<D: Directive>(
91    file: &str,
92    source: &str,
93) -> (Vec<ParsedScene<D>>, Vec<ContentError>) {
94    let mut p = Parser {
95        file,
96        scenes: Vec::new(),
97        open: None,
98        blocks: Vec::new(),
99        continuable: false,
100        delivery: None,
101        errors: Vec::new(),
102    };
103    for (i, line) in source.split('\n').enumerate() {
104        p.line(u32::try_from(i + 1).unwrap_or(u32::MAX), line);
105    }
106    p.close_unclosed();
107    if let Some(open) = p.open.take() {
108        p.no_end(&open);
109        p.finish(open);
110    }
111    (p.scenes, p.errors)
112}
113
114/// Whether `s` is a valid scene, character or expression id: lowercase
115/// ASCII letters, digits and `_`, not empty.
116pub fn is_id(s: &str) -> bool {
117    !s.is_empty()
118        && s.bytes()
119            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
120}
121
122/// What is wrong with character `c` in dialogue text, if anything: text
123/// holds any character but a control character. Whether the font draws it
124/// is checked once the game's text and its atlas load
125/// ([`crate::font::undrawn`]), not here.
126pub fn char_problem(c: char) -> Option<String> {
127    c.is_control()
128        .then(|| format!("control character U+{:04X} in dialogue text", u32::from(c)))
129}
130
131/// What an id that isn't one ([`is_id`]) is told.
132pub fn bad_id(id: &str) -> String {
133    format!("\"{id}\" is not a valid id; use lowercase letters, digits and _")
134}
135
136struct Parser<'a, D> {
137    file: &'a str,
138    scenes: Vec<ParsedScene<D>>,
139    /// The scene being read (after `@scene`, before `@end`).
140    open: Option<ParsedScene<D>>,
141    /// The blocks being read inside `open`, the innermost last. At most one
142    /// is a `@choice`.
143    blocks: Vec<Open<D>>,
144    /// Whether the last line was speech or narration, so an indented line
145    /// continues it.
146    continuable: bool,
147    /// The direction of the last `@delivery`, for the lines without their
148    /// own.
149    delivery: Option<String>,
150    errors: Vec<ContentError>,
151}
152
153impl<D: Directive> Parser<'_, D> {
154    fn err(&mut self, line: u32, message: impl Into<String>) {
155        self.errors
156            .push(ContentError::new(self.file, message).at(line, None));
157    }
158
159    fn no_end(&mut self, open: &ParsedScene<D>) {
160        self.err(
161            open.line,
162            format!("scene \"{}\" has no @end", open.scene.id),
163        );
164    }
165
166    /// Adds the complete scene `open` to the scenes read, giving its lines
167    /// their ids (their text is final only now: continuation lines add to
168    /// it).
169    fn finish(&mut self, mut open: ParsedScene<D>) {
170        open.scene.assign_line_ids();
171        self.scenes.push(open);
172    }
173
174    /// Reads line `n` (1-based).
175    fn line(&mut self, n: u32, raw: &str) {
176        let line = raw.trim_end();
177        let body = line.trim_start_matches(' ');
178        if body.is_empty() || body.starts_with('#') {
179            return;
180        }
181        let indent = line.len() - body.len();
182        if self.in_choice() {
183            match indent {
184                0 => self.choice_line(n, line, body),
185                1..REACTION_INDENT => self.err(n, "reaction lines are indented by two spaces"),
186                REACTION_INDENT => self.statement(n, line, body),
187                _ => self.continuation(n, line, body),
188            }
189            return;
190        }
191        if indent > 0 {
192            self.continuation(n, line, body);
193            return;
194        }
195        if body.starts_with('*') {
196            self.err(
197                n,
198                "an option (\"* tone: text\") must be inside a @choice block",
199            );
200            return;
201        }
202        self.statement(n, line, body);
203    }
204
205    /// A step line (`body` is `line` without its indentation): directive,
206    /// narration or speech.
207    fn statement(&mut self, n: u32, line: &str, body: &str) {
208        self.continuable = false;
209        if let Some(rest) = body.strip_prefix('@') {
210            self.directive(n, line, rest);
211        } else if let Some(rest) = body.strip_prefix('>') {
212            let rest = rest.trim();
213            // `> (direction): text`; other narration may open with a bracket.
214            let directed = rest.strip_prefix('(').and_then(|r| r.split_once("):"));
215            let (own, text) = directed.map_or((None, rest), |(own, text)| (Some(own), text.trim()));
216            if text.is_empty() {
217                self.err(n, "narration has no text");
218            } else {
219                self.check_chars(n, line, text);
220                let direction = self.direction(n, own);
221                self.push(
222                    n,
223                    Step::Narrate {
224                        text: text.into(),
225                        direction,
226                        line: LineId::default(),
227                    },
228                );
229                self.continuable = self.open.is_some();
230            }
231        } else {
232            self.speech(n, line, body);
233        }
234    }
235
236    /// An unindented line inside a `@choice` block: an option,
237    /// `@endchoice`, or a line that ends the block early.
238    fn choice_line(&mut self, n: u32, line: &str, body: &str) {
239        self.continuable = false;
240        let name = body.split(' ').next().unwrap_or("");
241        let option = body.strip_prefix('*');
242        let ends = matches!(name, "@endchoice" | "@choice" | "@end" | "@scene");
243        if option.is_none() && !ends {
244            self.err(
245                n,
246                "inside @choice, a line is an option (\"* tone: text\"), an indented reaction line or @endchoice",
247            );
248            return;
249        }
250        // The reaction above is over: an `@if` it opened must be closed.
251        self.close_reaction_ifs();
252        if let Some(Open::Choice { outer_delivery, .. }) = self.blocks.last() {
253            self.delivery.clone_from(outer_delivery);
254        }
255        match option {
256            Some(rest) => self.option(n, line, rest),
257            None => self.directive(n, line, &body[1..]),
258        }
259    }
260
261    /// Whether a `@choice` block is open.
262    fn in_choice(&self) -> bool {
263        self.blocks.iter().any(|b| matches!(b, Open::Choice { .. }))
264    }
265
266    /// Reports and closes every `@if` block still open inside the open
267    /// `@choice`: its reaction ends here.
268    fn close_reaction_ifs(&mut self) {
269        let is_if = |block: &mut Open<D>| matches!(block, Open::If { .. });
270        while let Some(block) = self.blocks.pop_if(is_if) {
271            let (line, message) = block.unclosed();
272            self.err(line, message);
273            self.add_block(block);
274        }
275    }
276
277    /// `* <tone>: <text>` on line `n`; `rest` follows the `*`.
278    fn option(&mut self, n: u32, line: &str, rest: &str) {
279        let parsed = rest
280            .strip_prefix(' ')
281            .and_then(|r| r.split_once(':'))
282            .map(|(tone, text)| (tone.trim(), text.trim()))
283            .filter(|(tone, text)| !tone.is_empty() && !text.is_empty());
284        let Some((tone, text)) = parsed else {
285            self.err(n, "an option needs a tone and text: \"* tone: text\"");
286            return;
287        };
288        if !is_id(tone) {
289            self.bad_id(n, tone);
290        }
291        self.check_chars(n, line, text);
292        if let Some(Open::Choice { options, parts, .. }) = self.blocks.last_mut() {
293            options.push(ChoiceOption {
294                tone: tone.into(),
295                text: text.into(),
296                line: LineId::default(),
297                steps: Vec::new(),
298            });
299            parts.push(PartLines {
300                line: n,
301                lines: Lines::default(),
302            });
303        }
304    }
305
306    /// `@choice` on line `n`.
307    fn open_choice(&mut self, n: u32, args: &str) {
308        if !args.is_empty() {
309            self.err(n, "@choice takes nothing after it");
310        }
311        if self.in_choice() {
312            self.err(n, "@choice can't be nested inside another @choice");
313            return;
314        }
315        if self.open.is_none() {
316            self.err(n, "line is outside a scene; start one with @scene <id>");
317            return;
318        }
319        self.blocks.push(Open::Choice {
320            line: n,
321            options: Vec::new(),
322            parts: Vec::new(),
323            outer_delivery: self.delivery.clone(),
324        });
325    }
326
327    /// `@endchoice` on line `n`.
328    fn end_choice(&mut self, n: u32, args: &str) {
329        if !args.is_empty() {
330            self.err(n, "@endchoice takes nothing after it");
331        }
332        if !self.in_choice() {
333            self.err(n, "@endchoice without an open @choice");
334            return;
335        }
336        self.close_reaction_ifs();
337        if let Some(choice) = self.blocks.pop() {
338            self.add_block(choice);
339        }
340    }
341
342    /// `@if <character>` on line `n`.
343    fn open_if(&mut self, n: u32, args: &str) {
344        let words: Vec<&str> = args.split_whitespace().collect();
345        let character = if let [character] = words[..] {
346            if !is_id(character) {
347                self.bad_id(n, character);
348            }
349            character
350        } else {
351            self.err(n, "@if needs one character id");
352            ""
353        };
354        if self.target().is_none() {
355            self.no_target(n);
356            return;
357        }
358        self.blocks.push(Open::If {
359            line: n,
360            character: CharacterId(character.into()),
361            then: (Vec::new(), Lines::default()),
362            otherwise: None,
363            outer_delivery: self.delivery.clone(),
364        });
365    }
366
367    /// `@else` on line `n`.
368    fn else_if(&mut self, n: u32, args: &str) {
369        if !args.is_empty() {
370            self.err(n, "@else takes nothing after it");
371        }
372        match self.blocks.last_mut() {
373            Some(Open::If {
374                otherwise: Some((first, ..)),
375                ..
376            }) => {
377                let message = format!("@if already has an @else, on line {first}");
378                self.err(n, message);
379            }
380            Some(Open::If {
381                otherwise,
382                outer_delivery,
383                ..
384            }) => {
385                *otherwise = Some((n, Vec::new(), Lines::default()));
386                self.delivery.clone_from(outer_delivery);
387            }
388            _ => self.err(n, "@else without an open @if"),
389        }
390    }
391
392    /// `@endif` on line `n`.
393    fn end_if(&mut self, n: u32, args: &str) {
394        if !args.is_empty() {
395            self.err(n, "@endif takes nothing after it");
396        }
397        match self.blocks.pop_if(|block| matches!(block, Open::If { .. })) {
398            Some(block) => self.add_block(block),
399            None => self.err(n, "@endif without an open @if"),
400        }
401    }
402
403    /// Adds `block`, just closed (taken off the open blocks), as one step
404    /// to what it was in.
405    fn add_block(&mut self, block: Open<D>) {
406        let (line, step, parts) = match block {
407            Open::Choice {
408                line,
409                options,
410                parts,
411                outer_delivery,
412            } => {
413                self.delivery = outer_delivery;
414                (line, Step::Choice { options }, parts)
415            }
416            Open::If {
417                line,
418                character,
419                then,
420                otherwise,
421                outer_delivery,
422            } => {
423                self.delivery = outer_delivery;
424                let (else_line, otherwise, else_lines) =
425                    otherwise.unwrap_or((line, Vec::new(), Lines::default()));
426                let step = Step::If {
427                    character,
428                    then: then.0,
429                    otherwise,
430                };
431                let part = |line, lines| PartLines { line, lines };
432                (
433                    line,
434                    step,
435                    vec![part(line, then.1), part(else_line, else_lines)],
436                )
437            }
438        };
439        if let Some((steps, lines)) = self.target() {
440            steps.push(step);
441            lines.steps.push(line);
442            lines.blocks.push(parts);
443        }
444    }
445
446    /// Reports every block still open (its scene or file ends first) and
447    /// closes it.
448    fn close_unclosed(&mut self) {
449        while let Some(block) = self.blocks.pop() {
450            let (line, message) = block.unclosed();
451            self.err(line, message);
452            self.add_block(block);
453        }
454    }
455
456    /// Where the next step goes: the part being read of the innermost open
457    /// block (an `@if` block's steps, or its `@else`'s; a `@choice`'s last
458    /// option's reaction), or else the open scene. `None` outside a scene
459    /// and in a `@choice` before its first option.
460    fn target(&mut self) -> Option<(&mut Vec<Step<D>>, &mut Lines)> {
461        match self.blocks.last_mut() {
462            Some(Open::If {
463                otherwise: Some((_, steps, lines)),
464                ..
465            }) => Some((steps, lines)),
466            Some(Open::If { then, .. }) => Some((&mut then.0, &mut then.1)),
467            Some(Open::Choice { options, parts, .. }) => {
468                match (options.last_mut(), parts.last_mut()) {
469                    (Some(option), Some(part)) => Some((&mut option.steps, &mut part.lines)),
470                    _ => None,
471                }
472            }
473            None => self
474                .open
475                .as_mut()
476                .map(|open| (&mut open.scene.steps, &mut open.lines)),
477        }
478    }
479
480    /// Reports that the line `n` has nowhere to go ([`Self::target`]).
481    fn no_target(&mut self, n: u32) {
482        let message = if self.blocks.is_empty() {
483            "line is outside a scene; start one with @scene <id>"
484        } else {
485            "a reaction line needs an option above it: \"* tone: text\""
486        };
487        self.err(n, message);
488    }
489
490    /// The step an indented line would continue: the last one read.
491    fn last_step_mut(&mut self) -> Option<&mut Step<D>> {
492        self.target().and_then(|(steps, _)| steps.last_mut())
493    }
494
495    /// An indented line: more text for the speech or narration above.
496    fn continuation(&mut self, n: u32, line: &str, body: &str) {
497        if body.starts_with('@') {
498            self.err(n, "directives can't be indented");
499            return;
500        }
501        let continuable = self.continuable;
502        let last = self.last_step_mut().filter(|_| continuable);
503        match last {
504            Some(Step::Say { text, .. } | Step::Narrate { text, .. }) => {
505                text.push(' ');
506                text.push_str(body);
507                self.check_chars(n, line, body);
508            }
509            _ => self.err(
510                n,
511                "indented line doesn't continue a speech or narration line",
512            ),
513        }
514    }
515
516    /// `@<rest>` on line `n`.
517    fn directive(&mut self, n: u32, line: &str, rest: &str) {
518        let (name, args) = rest
519            .split_once(' ')
520            .map_or((rest, ""), |(name, args)| (name, args.trim()));
521        match name {
522            "scene" => self.scene(n, args),
523            "end" => self.end(n, args),
524            "caption" if args.is_empty() => self.err(n, "@caption has no text"),
525            "caption" => {
526                self.check_chars(n, line, args);
527                self.push(n, Step::Caption { text: args.into() });
528            }
529            "left" => self.place(n, Side::Left, args),
530            "right" => self.place(n, Side::Right, args),
531            "music" => self.music(n, args),
532            "delivery" => self.delivery_line(n, args),
533            "choice" => self.open_choice(n, args),
534            "endchoice" => self.end_choice(n, args),
535            "if" => self.open_if(n, args),
536            "else" => self.else_if(n, args),
537            "endif" => self.end_if(n, args),
538            _ => match D::parse(name, args) {
539                Some(parsed) => self.game_directive(n, parsed),
540                None => self.err(n, format!("unknown directive \"@{name}\"")),
541            },
542        }
543    }
544
545    /// A directive of the game's on line `n`, as [`Directive::parse`] read
546    /// it.
547    fn game_directive(&mut self, n: u32, parsed: super::Parsed<D>) {
548        for error in parsed.errors {
549            self.err(n, error);
550        }
551        if let Some(step) = parsed.step {
552            self.push(n, Step::Directive(step));
553        }
554    }
555
556    fn scene(&mut self, n: u32, args: &str) {
557        self.close_unclosed();
558        if let Some(open) = self.open.take() {
559            self.no_end(&open);
560            self.finish(open);
561        }
562        let ok = args.split_whitespace().count() == 1;
563        if !ok {
564            self.err(n, "@scene needs one scene id");
565        } else if !is_id(args) {
566            self.bad_id(n, args);
567        }
568        self.open = Some(ParsedScene {
569            scene: Scene {
570                id: args.into(),
571                steps: Vec::new(),
572            },
573            file: self.file.into(),
574            line: n,
575            lines: Lines::default(),
576        });
577        self.delivery = None;
578    }
579
580    fn end(&mut self, n: u32, args: &str) {
581        if !args.is_empty() {
582            self.err(n, "@end takes nothing after it");
583        }
584        self.close_unclosed();
585        match self.open.take() {
586            Some(open) => self.finish(open),
587            None => self.err(n, "@end without an open @scene"),
588        }
589    }
590
591    /// `@left`/`@right` with `args`: `<id> <expression>` or `clear`.
592    fn place(&mut self, n: u32, side: Side, args: &str) {
593        let args: Vec<&str> = args.split_whitespace().collect();
594        let step = match args[..] {
595            ["clear"] => Step::Clear { side },
596            [character, expression] if character != "clear" => {
597                if !is_id(character) {
598                    self.bad_id(n, character);
599                }
600                if !is_id(expression) {
601                    self.bad_id(n, expression);
602                }
603                Step::Place {
604                    side,
605                    character: CharacterId(character.into()),
606                    expression: expression.into(),
607                }
608            }
609            _ => {
610                self.err(
611                    n,
612                    format!(
613                        "@{} needs a character id and an expression, or \"clear\"",
614                        side.name()
615                    ),
616                );
617                return;
618            }
619        };
620        self.push(n, step);
621    }
622
623    /// `@music` with `args`: one music cue id, or `stop`.
624    fn music(&mut self, n: u32, args: &str) {
625        let args: Vec<&str> = args.split_whitespace().collect();
626        let [arg] = args[..] else {
627            self.err(n, "@music needs one music cue id, or \"stop\"");
628            return;
629        };
630        let music = if arg == MusicLine::STOP {
631            MusicLine::Stop
632        } else {
633            if !is_id(arg) {
634                self.bad_id(n, arg);
635            }
636            MusicLine::Cue(arg.into())
637        };
638        self.push(n, Step::Music(music));
639    }
640
641    /// `id: text` or `id[expression]: text` on line `n`.
642    fn speech(&mut self, n: u32, line: &str, body: &str) {
643        // `speaker (direction): text`: the head without its direction, and
644        // the direction.
645        let split = body.split_once(':').and_then(|(head, text)| {
646            let Some((id, own)) = head.split_once(' ') else {
647                return Some((head, None, text));
648            };
649            let own = own.trim().strip_prefix('(')?.strip_suffix(')')?;
650            Some((id, Some(own), text))
651        });
652        let Some((head, own, text)) = split else {
653            self.err(
654                n,
655                "unrecognised line; expected \"@directive\", \"> narration\", \"speaker: text\" or \"# comment\"",
656            );
657            return;
658        };
659        let (speaker, expression) = match head.strip_suffix(']').and_then(|h| h.split_once('[')) {
660            Some((speaker, expression)) => (speaker, Some(expression)),
661            None => (head, None),
662        };
663        for id in core::iter::once(speaker).chain(expression) {
664            if !is_id(id) {
665                self.bad_id(n, id);
666            }
667        }
668        let text = text.trim();
669        if text.is_empty() {
670            self.err(n, format!("\"{head}:\" has no text"));
671            return;
672        }
673        self.check_chars(n, line, text);
674        let direction = self.direction(n, own);
675        self.push(
676            n,
677            Step::Say {
678                speaker: CharacterId(speaker.into()),
679                expression: expression.map(Into::into),
680                text: text.into(),
681                direction,
682                line: LineId::default(),
683            },
684        );
685        self.continuable = self.open.is_some();
686    }
687
688    /// The direction of the line on line `n`: `own`, the one written on it,
689    /// else the `@delivery` in force.
690    fn direction(&mut self, n: u32, own: Option<&str>) -> Option<String> {
691        match own {
692            Some(own) => self.checked_direction(n, own, "a line's ( ) has no direction in it"),
693            None => self.delivery.clone(),
694        }
695    }
696
697    /// `direction`, trimmed, reporting `when_empty` if nothing is left and
698    /// every control character ([`char_problem`]).
699    fn checked_direction(&mut self, n: u32, direction: &str, when_empty: &str) -> Option<String> {
700        let direction = direction.trim();
701        if direction.is_empty() {
702            self.err(n, when_empty);
703            return None;
704        }
705        for message in direction.chars().filter_map(char_problem) {
706            self.err(n, message);
707        }
708        Some(direction.into())
709    }
710
711    /// `@delivery` with `args`: a direction, or `clear`.
712    fn delivery_line(&mut self, n: u32, args: &str) {
713        if self.target().is_none() {
714            self.no_target(n);
715            return;
716        }
717        self.delivery = if args == "clear" {
718            None
719        } else {
720            self.checked_direction(n, args, "@delivery needs a direction, or \"clear\"")
721        };
722    }
723
724    fn bad_id(&mut self, n: u32, id: &str) {
725        self.err(n, bad_id(id));
726    }
727
728    /// Adds `step` (from line `n`) to where steps go now
729    /// ([`Self::target`]).
730    fn push(&mut self, n: u32, step: Step<D>) {
731        match self.target() {
732            Some((steps, lines)) => {
733                steps.push(step);
734                lines.steps.push(n);
735            }
736            None => self.no_target(n),
737        }
738    }
739
740    /// Reports every control character of `text`, a suffix of `line`,
741    /// with its column.
742    fn check_chars(&mut self, n: u32, line: &str, text: &str) {
743        let before = line[..line.len() - text.len()].chars().count();
744        for (i, c) in text.chars().enumerate() {
745            if let Some(message) = char_problem(c) {
746                let column = u32::try_from(before + i + 1).unwrap_or(u32::MAX);
747                self.errors
748                    .push(ContentError::new(self.file, message).at(n, Some(column)));
749            }
750        }
751    }
752}