Skip to main content

devset_core/part/
block.rs

1//! Blocks a profile owns in a line-based file: the lines between two comment markers.
2
3use core::ops::Range;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::path::RelPath;
9
10/// A line comment syntax, which a block's markers are written in.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
12pub enum Comment {
13    /// `#`: shells, TOML, YAML, Python, `.gitignore`.
14    #[serde(rename = "#")]
15    Hash,
16    /// `//`: C, Rust, JavaScript, JSONC.
17    #[serde(rename = "//")]
18    Slashes,
19    /// `--`: SQL, Lua, Haskell.
20    #[serde(rename = "--")]
21    Dashes,
22    /// `;`: INI, Lisp, assembly.
23    #[serde(rename = ";")]
24    Semicolon,
25    /// `%`: TeX, Erlang, MATLAB.
26    #[serde(rename = "%")]
27    Percent,
28    /// `/* */`: CSS.
29    #[serde(rename = "/*")]
30    Star,
31    /// `<!-- -->`: Markdown, HTML, XML.
32    #[serde(rename = "<!--")]
33    Angle,
34}
35
36impl Comment {
37    /// What opens the comment, and what closes it on the same line.
38    const fn delimiters(self) -> (&'static str, &'static str) {
39        match self {
40            Self::Hash => ("#", ""),
41            Self::Slashes => ("//", ""),
42            Self::Dashes => ("--", ""),
43            Self::Semicolon => (";", ""),
44            Self::Percent => ("%", ""),
45            Self::Star => ("/*", " */"),
46            Self::Angle => ("<!--", " -->"),
47        }
48    }
49}
50
51/// The comment syntax a file type writes markers in, when devset knows it.
52pub(crate) fn comment(path: &RelPath) -> Option<Comment> {
53    let by_name = match path.file_name() {
54        ".gitignore" | ".gitattributes" | ".dockerignore" | ".editorconfig" | "CODEOWNERS"
55        | "Makefile" | "justfile" | "Justfile" | "Dockerfile" | ".env" => Some(Comment::Hash),
56        _ => None,
57    };
58    by_name.or_else(|| {
59        Some(match path.extension()?.as_str() {
60            "toml" | "yml" | "yaml" | "sh" | "bash" | "zsh" | "fish" | "py" | "rb" | "conf"
61            | "cfg" | "ini" | "properties" | "tf" | "just" | "env" | "txt" | "gitignore" => {
62                Comment::Hash
63            },
64            "jsonc" | "js" | "mjs" | "cjs" | "ts" | "jsx" | "tsx" | "rs" | "go" | "c" | "h"
65            | "cc" | "cpp" | "hpp" | "java" | "kt" | "swift" | "scala" | "dart" | "zig"
66            | "proto" => Comment::Slashes,
67            "md" | "markdown" | "html" | "htm" | "xml" | "svg" | "vue" => Comment::Angle,
68            "sql" | "lua" | "hs" => Comment::Dashes,
69            "el" | "lisp" | "clj" | "asm" => Comment::Semicolon,
70            "tex" | "erl" | "m" => Comment::Percent,
71            "css" | "scss" | "less" => Comment::Star,
72            _ => return None,
73        })
74    })
75}
76
77/// Where one block lies in a text.
78struct Located {
79    /// The block, its marker lines included.
80    whole: Range<usize>,
81    /// Its lines, marker lines excluded.
82    lines: Range<usize>,
83}
84
85/// The lines that open and close one profile's block.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub(crate) struct Markers {
88    /// The line that opens the block.
89    open: String,
90    /// The line that closes it.
91    close: String,
92}
93
94impl Markers {
95    /// The markers of `owner`'s block, in `comment` syntax.
96    pub(crate) fn new(comment: Comment, owner: &str) -> Self {
97        let (open, close) = comment.delimiters();
98        Self {
99            open: format!("{open} >>> devset: {owner} >>>{close}"),
100            close: format!("{open} <<< devset: {owner} <<<{close}"),
101        }
102    }
103
104    /// Where the block's lines lie in `text`, marker lines excluded; `None` when there is no
105    /// block. `Err` holds the offending line's range and why it breaks the block.
106    pub(crate) fn find(&self, text: &str) -> Result<Option<Range<usize>>, (Range<usize>, String)> {
107        Ok(self.locate(text)?.map(|found| found.lines))
108    }
109
110    /// Where the block lies in `text`; `None` when there is no block.
111    fn locate(&self, text: &str) -> Result<Option<Located>, (Range<usize>, String)> {
112        let (mut start, mut end) = (None::<Range<usize>>, None::<(Range<usize>, usize)>);
113        let mut at: usize = 0;
114        for line in text.split_inclusive('\n') {
115            let span = at..at.saturating_add(line.len());
116            at = span.end;
117            let trimmed = line.trim();
118            if trimmed == self.open {
119                if start.is_some() {
120                    return Err((span, "the block opens twice".to_owned()));
121                }
122                start = Some(span);
123            } else if trimmed == self.close {
124                let Some(open) = &start else {
125                    return Err((span, "the block closes before it opens".to_owned()));
126                };
127                if end.is_some() {
128                    return Err((span, "the block closes twice".to_owned()));
129                }
130                end = Some((open.end..span.start, span.end));
131            }
132        }
133        match (start, end) {
134            (None, _) => Ok(None),
135            (Some(open), None) => Err((open, "the block has no closing marker".to_owned())),
136            (Some(open), Some((lines, close))) => {
137                Ok(Some(Located { whole: open.start..close, lines }))
138            },
139        }
140    }
141
142    /// `text` without the block, its markers included, and without the blank line
143    /// [`splice`](Self::splice) set before it.
144    pub(crate) fn remove(&self, text: &str) -> Result<String, (Range<usize>, String)> {
145        let Some(Located { whole, .. }) = self.locate(text)? else {
146            return Ok(text.to_owned());
147        };
148        let (before, after) = (
149            text.get(..whole.start).unwrap_or_default(),
150            text.get(whole.end..).unwrap_or_default(),
151        );
152        if after.trim().is_empty() {
153            let kept = before.trim_end();
154            let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
155            return Ok(if kept.is_empty() { String::new() } else { format!("{kept}{newline}") });
156        }
157        let blank = |s: &str| s.ends_with("\n\n") || s.ends_with("\r\n\r\n");
158        let after = if blank(before) {
159            after.strip_prefix("\r\n").or_else(|| after.strip_prefix('\n')).unwrap_or(after)
160        } else {
161            after
162        };
163        Ok(format!("{before}{after}"))
164    }
165
166    /// `text` with the block holding `content`: in place, or appended after a blank line; in
167    /// the file's line endings.
168    pub(crate) fn splice(
169        &self, text: &str, content: &str,
170    ) -> Result<String, (Range<usize>, String)> {
171        let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
172        let mut body = content.replace("\r\n", "\n");
173        if newline == "\r\n" {
174            body = body.replace('\n', newline);
175        }
176        if !body.is_empty() && !body.ends_with('\n') {
177            body.push_str(newline);
178        }
179        if let Some(lines) = self.find(text)? {
180            let (before, after) = (text.get(..lines.start), text.get(lines.end..));
181            return Ok(format!(
182                "{}{body}{}",
183                before.unwrap_or_default(),
184                after.unwrap_or_default()
185            ));
186        }
187        let mut out = text.to_owned();
188        if !out.is_empty() && !out.ends_with('\n') {
189            out.push_str(newline);
190        }
191        if !out.trim().is_empty() {
192            out.push_str(newline);
193        }
194        out.extend([self.open.as_str(), newline, &body, self.close.as_str(), newline]);
195        Ok(out)
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::{Comment, Markers, comment};
202    use crate::path::RelPath;
203
204    #[test]
205    fn blocks_are_found_written_and_moved() {
206        let markers = Markers::new(Comment::Hash, "rust");
207        let first = markers.splice("mine\n", "/target\n").expect("an empty file takes a block");
208        assert_eq!(
209            first, "mine\n\n# >>> devset: rust >>>\n/target\n# <<< devset: rust <<<\n",
210            "appended after a blank line"
211        );
212        let moved = "# >>> devset: rust >>>\n/target\n# <<< devset: rust <<<\nmine\n";
213        let updated = markers.splice(moved, "/target\n/out\n").expect("a block in place");
214        assert_eq!(
215            updated, "# >>> devset: rust >>>\n/target\n/out\n# <<< devset: rust <<<\nmine\n",
216            "a moved block is updated where it is"
217        );
218        let open_only = "# >>> devset: rust >>>\n/target\n";
219        assert!(
220            markers.find(open_only).is_err(),
221            "a lone marker is an error, never a second block"
222        );
223        assert_eq!(markers.find("mine\n"), Ok(None), "no block, no error");
224    }
225
226    #[test]
227    fn comment_syntax_follows_the_file_type() {
228        let of = |path: &str| comment(&RelPath::new(path).expect("a valid path"));
229        assert_eq!(of(".gitignore"), Some(Comment::Hash), "by name");
230        assert_eq!(of("docs/README.md"), Some(Comment::Angle), "by extension");
231        assert_eq!(of(".vscode/settings.jsonc"), Some(Comment::Slashes), "JSONC has comments");
232        assert_eq!(of("package.json"), None, "strict JSON has none");
233        let html = Markers::new(Comment::Angle, "badges");
234        let text = html.splice("", "[badge]\n").expect("an empty file takes a block");
235        assert!(text.starts_with("<!-- >>> devset: badges >>> -->\n"), "HTML comments close");
236    }
237}