1use core::ops::Range;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::path::RelPath;
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
12pub enum Comment {
13 #[serde(rename = "#")]
15 Hash,
16 #[serde(rename = "//")]
18 Slashes,
19 #[serde(rename = "--")]
21 Dashes,
22 #[serde(rename = ";")]
24 Semicolon,
25 #[serde(rename = "%")]
27 Percent,
28 #[serde(rename = "/*")]
30 Star,
31 #[serde(rename = "<!--")]
33 Angle,
34}
35
36impl Comment {
37 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
51pub(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
77struct Located {
79 whole: Range<usize>,
81 lines: Range<usize>,
83}
84
85#[derive(Clone, Debug, PartialEq, Eq)]
87pub(crate) struct Markers {
88 open: String,
90 close: String,
92}
93
94impl Markers {
95 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 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 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 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 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}