Skip to main content

ephemeris_documents/yaml/source/
text.rs

1//! Raw source text and column shifts.
2
3use std::{fmt, iter, mem};
4
5/// A piece of the original source, kept verbatim.
6#[derive(Debug, Clone, Default, PartialEq, Eq)]
7pub(crate) struct Text(String);
8
9impl Text {
10    pub(crate) const fn is_empty(&self) -> bool {
11        self.0.is_empty()
12    }
13
14    pub(crate) const fn as_str(&self) -> &str {
15        self.0.as_str()
16    }
17
18    pub(crate) fn append(&mut self, other: &Self) {
19        self.0.push_str(&other.0);
20    }
21
22    /// End the text with a line break, adding one when it is missing.
23    pub(crate) fn terminate(&mut self) {
24        if !self.0.ends_with('\n') {
25            self.0.push('\n');
26        }
27    }
28
29    /// Where the whitespace before the first comment in `line` starts. A
30    /// comment is a `#` at the start or after a space or tab.
31    pub(crate) fn comment_start(line: &str) -> Option<usize> {
32        let hash = line.char_indices().find_map(|(index, c)| {
33            let before = line.get(..index)?;
34            (c == '#' && (before.is_empty() || before.ends_with([' ', '\t']))).then_some(index)
35        })?;
36        Some(line.get(..hash)?.trim_end_matches([' ', '\t']).len())
37    }
38
39    /// The comment at the end of `line`, with the whitespace before it.
40    pub(crate) fn comment_in(line: &str) -> Option<&str> {
41        let start = Self::comment_start(line)?;
42        line.get(start..).map(str::trim_end)
43    }
44
45    /// Split off what comes before the comment block that ends this text.
46    /// The block is the run of comment lines at the end with no blank line
47    /// between them. Returns the part before it and keeps the block.
48    pub(crate) fn take_before_comment_block(&mut self) -> Self {
49        let mut start = self.0.len();
50        for line in self.0.split_inclusive('\n').rev() {
51            if !line.trim_start().starts_with('#') {
52                break;
53            }
54            start = start.saturating_sub(line.len());
55        }
56        let rest = self.0.split_off(start);
57        Self(mem::replace(&mut self.0, rest))
58    }
59
60    /// Move every line by `shift`. When the text does not begin at the start
61    /// of a line, its first line is left alone.
62    pub(crate) fn reindent(&mut self, shift: Reindent, origin: Origin) {
63        if shift.is_noop() {
64            return;
65        }
66        let mut shifted = String::with_capacity(self.0.len());
67        for (index, line) in self.0.split_inclusive('\n').enumerate() {
68            if index == 0 && origin == Origin::MidLine {
69                shifted.push_str(line);
70            } else {
71                shift.line(line, &mut shifted);
72            }
73        }
74        self.0 = shifted;
75    }
76}
77
78impl From<&str> for Text {
79    fn from(text: &str) -> Self {
80        Self(text.to_owned())
81    }
82}
83
84impl From<String> for Text {
85    fn from(text: String) -> Self {
86        Self(text)
87    }
88}
89
90impl fmt::Display for Text {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        f.write_str(&self.0)
93    }
94}
95
96/// Where a text begins.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub(crate) enum Origin {
99    LineStart,
100    MidLine,
101}
102
103/// A move of a block of lines from one indent to another.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub(crate) struct Reindent {
106    from: usize,
107    to: usize,
108}
109
110impl Reindent {
111    pub(crate) const fn new(from: usize, to: usize) -> Self {
112        Self { from, to }
113    }
114
115    pub(crate) const fn is_noop(self) -> bool {
116        self.from == self.to
117    }
118
119    /// Append `line`, moved, to `out`. Empty lines stay empty and lines
120    /// cannot lose more spaces than they have.
121    fn line(self, line: &str, out: &mut String) {
122        if line.is_empty() || line == "\n" {
123            out.push_str(line);
124            return;
125        }
126        if let Some(added) = self.to.checked_sub(self.from) {
127            out.extend(iter::repeat_n(' ', added));
128            out.push_str(line);
129            return;
130        }
131        let removed = self.from.saturating_sub(self.to);
132        let spaces = line.bytes().take_while(|byte| *byte == b' ').count();
133        out.push_str(line.get(spaces.min(removed)..).unwrap_or_default());
134    }
135}