Skip to main content

ephemeris_documents/
header.rs

1//! The generated comment at the start of a document.
2
3use std::borrow::Cow;
4
5use crate::ownership::Ownership;
6use crate::text::CommentSyntax;
7
8/// The text of the comment that marks a document as generated.
9pub(crate) const GENERATED: &str = "Generated by Ephemeris. Do not edit by hand.";
10
11/// A header block together with the text that follows it.
12#[derive(Debug, PartialEq, Eq)]
13pub(crate) struct Applied<'a> {
14    pub(crate) block: String,
15    pub(crate) rest: Cow<'a, str>,
16}
17
18impl Applied<'_> {
19    /// The same text without borrowing from the source.
20    pub(crate) fn into_owned(self) -> Applied<'static> {
21        Applied {
22            block: self.block,
23            rest: Cow::Owned(self.rest.into_owned()),
24        }
25    }
26
27    pub(crate) fn into_text(self) -> String {
28        [self.block.as_str(), &self.rest].concat()
29    }
30}
31
32/// What happens to a top comment block that is not the desired header.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub(crate) enum Existing {
35    /// The block is the old header and the desired header replaces it.
36    Replace,
37    /// The block belongs to the user and the desired header goes above it.
38    Keep,
39}
40
41impl Existing {
42    /// `Keep` when the user owns everything outside the exceptions.
43    pub(crate) const fn under(ownership: &Ownership) -> Self {
44        if ownership.default_claim().is_owned() {
45            Self::Replace
46        } else {
47            Self::Keep
48        }
49    }
50}
51
52/// The desired header text of a document.
53///
54/// The header of a document is the comment block at the very top that is
55/// followed by at least one blank line. A comment block directly above the
56/// first entry belongs to that entry.
57#[derive(Debug, Clone, Copy)]
58pub(crate) struct Header<'a>(&'a str);
59
60impl<'a> Header<'a> {
61    /// A header from `text`, ignoring blank lines at its end.
62    pub(crate) fn new(text: &'a str) -> Self {
63        Self(text.trim_end_matches(['\n', '\r', ' ', '\t']))
64    }
65
66    /// The header followed by one blank line, or an empty string when there
67    /// is no header.
68    pub(crate) fn block(self) -> String {
69        if self.0.is_empty() {
70            String::new()
71        } else {
72            format!("{}\n\n", self.0)
73        }
74    }
75
76    /// The leading text `current` becomes: its header block replaced by this
77    /// header, or this header put in front when it has none or the block is
78    /// kept. `None` when
79    /// there is no header or nothing changes.
80    pub(crate) fn applied_to(self, current: &str, existing: Existing) -> Option<Applied<'_>> {
81        let start = self.changed_from(current, existing)?;
82        Some(Applied {
83            block: self.block(),
84            rest: Cow::Borrowed(current.get(start..).unwrap_or_default()),
85        })
86    }
87
88    /// Like `applied_to` for the text `first` that follows `root`, without
89    /// joining the two when `root` is empty.
90    pub(crate) fn applied_to_parts<'c>(
91        self,
92        root: &str,
93        first: &'c str,
94        existing: Existing,
95    ) -> Option<Applied<'c>> {
96        if root.is_empty() {
97            return self.applied_to(first, existing);
98        }
99        let joined = [root, first].concat();
100        let start = self.changed_from(&joined, existing)?;
101        Some(Applied {
102            block: self.block(),
103            rest: Cow::Owned(joined.get(start..).unwrap_or_default().to_owned()),
104        })
105    }
106
107    /// Where the text after the old header block starts in `current`, or
108    /// `None` when there is no header or `current` already starts with it.
109    /// A document that holds only the header counts, with or without a blank
110    /// line after it.
111    fn changed_from(self, current: &str, existing: Existing) -> Option<usize> {
112        if self.0.is_empty() {
113            return None;
114        }
115        let len = Self::block_len(current);
116        let unchanged = current.trim_end() == self.0
117            || current
118                .get(..len)
119                .and_then(|old| old.strip_prefix(self.0))
120                .is_some_and(|tail| tail == "\n\n");
121        match existing {
122            _ if unchanged => None,
123            Existing::Replace => Some(len),
124            Existing::Keep => Some(0),
125        }
126    }
127
128    /// The length of the header block at the start of `text`, including the
129    /// blank lines after it. Zero when the top block has no blank line after
130    /// it.
131    fn block_len(text: &str) -> usize {
132        let mut lines = text.split_inclusive('\n').peekable();
133        let mut len = 0usize;
134        while let Some(line) = lines.next_if(|line| !Self::is_blank(line)) {
135            len = len.saturating_add(line.len());
136        }
137        if len == 0 || lines.peek().is_none() {
138            return 0;
139        }
140        while let Some(line) = lines.next_if(|line| Self::is_blank(line)) {
141            len = len.saturating_add(line.len());
142        }
143        len
144    }
145
146    fn is_blank(line: &str) -> bool {
147        line.trim().is_empty()
148    }
149}
150
151/// Whether the first line of `content` is a comment that holds the generated
152/// marker. Every comment syntax is accepted.
153#[must_use]
154pub fn is_generated(content: &[u8]) -> bool {
155    let first = content
156        .split(|&byte| byte == b'\n')
157        .next()
158        .unwrap_or_default();
159    let Ok(line) = str::from_utf8(first) else {
160        return false;
161    };
162    let line = line.trim();
163    CommentSyntax::ALL
164        .iter()
165        .filter_map(|syntax| syntax.uncomment(line))
166        .any(|text| text.trim() == GENERATED)
167}
168
169#[cfg(test)]
170mod tests;