use core::ops::Range;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::path::RelPath;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum Comment {
#[serde(rename = "#")]
Hash,
#[serde(rename = "//")]
Slashes,
#[serde(rename = "--")]
Dashes,
#[serde(rename = ";")]
Semicolon,
#[serde(rename = "%")]
Percent,
#[serde(rename = "/*")]
Star,
#[serde(rename = "<!--")]
Angle,
}
impl Comment {
const fn delimiters(self) -> (&'static str, &'static str) {
match self {
Self::Hash => ("#", ""),
Self::Slashes => ("//", ""),
Self::Dashes => ("--", ""),
Self::Semicolon => (";", ""),
Self::Percent => ("%", ""),
Self::Star => ("/*", " */"),
Self::Angle => ("<!--", " -->"),
}
}
}
pub(crate) fn comment(path: &RelPath) -> Option<Comment> {
let by_name = match path.file_name() {
".gitignore" | ".gitattributes" | ".dockerignore" | ".editorconfig" | "CODEOWNERS"
| "Makefile" | "justfile" | "Justfile" | "Dockerfile" | ".env" => Some(Comment::Hash),
_ => None,
};
by_name.or_else(|| {
Some(match path.extension()?.as_str() {
"toml" | "yml" | "yaml" | "sh" | "bash" | "zsh" | "fish" | "py" | "rb" | "conf"
| "cfg" | "ini" | "properties" | "tf" | "just" | "env" | "txt" | "gitignore" => {
Comment::Hash
},
"jsonc" | "js" | "mjs" | "cjs" | "ts" | "jsx" | "tsx" | "rs" | "go" | "c" | "h"
| "cc" | "cpp" | "hpp" | "java" | "kt" | "swift" | "scala" | "dart" | "zig"
| "proto" => Comment::Slashes,
"md" | "markdown" | "html" | "htm" | "xml" | "svg" | "vue" => Comment::Angle,
"sql" | "lua" | "hs" => Comment::Dashes,
"el" | "lisp" | "clj" | "asm" => Comment::Semicolon,
"tex" | "erl" | "m" => Comment::Percent,
"css" | "scss" | "less" => Comment::Star,
_ => return None,
})
})
}
struct Located {
whole: Range<usize>,
lines: Range<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Markers {
open: String,
close: String,
}
impl Markers {
pub(crate) fn new(comment: Comment, owner: &str) -> Self {
let (open, close) = comment.delimiters();
Self {
open: format!("{open} >>> devset: {owner} >>>{close}"),
close: format!("{open} <<< devset: {owner} <<<{close}"),
}
}
pub(crate) fn find(&self, text: &str) -> Result<Option<Range<usize>>, (Range<usize>, String)> {
Ok(self.locate(text)?.map(|found| found.lines))
}
fn locate(&self, text: &str) -> Result<Option<Located>, (Range<usize>, String)> {
let (mut start, mut end) = (None::<Range<usize>>, None::<(Range<usize>, usize)>);
let mut at: usize = 0;
for line in text.split_inclusive('\n') {
let span = at..at.saturating_add(line.len());
at = span.end;
let trimmed = line.trim();
if trimmed == self.open {
if start.is_some() {
return Err((span, "the block opens twice".to_owned()));
}
start = Some(span);
} else if trimmed == self.close {
let Some(open) = &start else {
return Err((span, "the block closes before it opens".to_owned()));
};
if end.is_some() {
return Err((span, "the block closes twice".to_owned()));
}
end = Some((open.end..span.start, span.end));
}
}
match (start, end) {
(None, _) => Ok(None),
(Some(open), None) => Err((open, "the block has no closing marker".to_owned())),
(Some(open), Some((lines, close))) => {
Ok(Some(Located { whole: open.start..close, lines }))
},
}
}
pub(crate) fn remove(&self, text: &str) -> Result<String, (Range<usize>, String)> {
let Some(Located { whole, .. }) = self.locate(text)? else {
return Ok(text.to_owned());
};
let (before, after) = (
text.get(..whole.start).unwrap_or_default(),
text.get(whole.end..).unwrap_or_default(),
);
if after.trim().is_empty() {
let kept = before.trim_end();
let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
return Ok(if kept.is_empty() { String::new() } else { format!("{kept}{newline}") });
}
let blank = |s: &str| s.ends_with("\n\n") || s.ends_with("\r\n\r\n");
let after = if blank(before) {
after.strip_prefix("\r\n").or_else(|| after.strip_prefix('\n')).unwrap_or(after)
} else {
after
};
Ok(format!("{before}{after}"))
}
pub(crate) fn splice(
&self, text: &str, content: &str,
) -> Result<String, (Range<usize>, String)> {
let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
let mut body = content.replace("\r\n", "\n");
if newline == "\r\n" {
body = body.replace('\n', newline);
}
if !body.is_empty() && !body.ends_with('\n') {
body.push_str(newline);
}
if let Some(lines) = self.find(text)? {
let (before, after) = (text.get(..lines.start), text.get(lines.end..));
return Ok(format!(
"{}{body}{}",
before.unwrap_or_default(),
after.unwrap_or_default()
));
}
let mut out = text.to_owned();
if !out.is_empty() && !out.ends_with('\n') {
out.push_str(newline);
}
if !out.trim().is_empty() {
out.push_str(newline);
}
out.extend([self.open.as_str(), newline, &body, self.close.as_str(), newline]);
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::{Comment, Markers, comment};
use crate::path::RelPath;
#[test]
fn blocks_are_found_written_and_moved() {
let markers = Markers::new(Comment::Hash, "rust");
let first = markers.splice("mine\n", "/target\n").expect("an empty file takes a block");
assert_eq!(
first, "mine\n\n# >>> devset: rust >>>\n/target\n# <<< devset: rust <<<\n",
"appended after a blank line"
);
let moved = "# >>> devset: rust >>>\n/target\n# <<< devset: rust <<<\nmine\n";
let updated = markers.splice(moved, "/target\n/out\n").expect("a block in place");
assert_eq!(
updated, "# >>> devset: rust >>>\n/target\n/out\n# <<< devset: rust <<<\nmine\n",
"a moved block is updated where it is"
);
let open_only = "# >>> devset: rust >>>\n/target\n";
assert!(
markers.find(open_only).is_err(),
"a lone marker is an error, never a second block"
);
assert_eq!(markers.find("mine\n"), Ok(None), "no block, no error");
}
#[test]
fn comment_syntax_follows_the_file_type() {
let of = |path: &str| comment(&RelPath::new(path).expect("a valid path"));
assert_eq!(of(".gitignore"), Some(Comment::Hash), "by name");
assert_eq!(of("docs/README.md"), Some(Comment::Angle), "by extension");
assert_eq!(of(".vscode/settings.jsonc"), Some(Comment::Slashes), "JSONC has comments");
assert_eq!(of("package.json"), None, "strict JSON has none");
let html = Markers::new(Comment::Angle, "badges");
let text = html.splice("", "[badge]\n").expect("an empty file takes a block");
assert!(text.starts_with("<!-- >>> devset: badges >>> -->\n"), "HTML comments close");
}
}