use std::ops::Range;
use super::{Leaf, LeafVisitor, Splicer};
pub(super) fn visit(
text: &str,
ranges: &[Range<usize>],
visitor: &mut dyn LeafVisitor,
splicer: &mut Splicer<'_>,
) {
for range in ranges {
let Some(value) = text.get(range.clone()) else {
continue;
};
let leaf = Leaf::comment(value).with_offset(range.start);
if let Some(replacement) = visitor.leaf(&leaf) {
splicer.apply(
range.clone(),
range.clone(),
value,
&replacement,
str::to_owned,
);
}
}
}
fn line_end(bytes: &[u8], from: usize) -> usize {
let end = memchr::memchr(b'\n', &bytes[from..]).map_or(bytes.len(), |i| from + i);
if end > from && bytes[end - 1] == b'\r' {
end - 1
} else {
end
}
}
pub(super) fn toml(text: &str) -> Vec<Range<usize>> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'#' => {
let end = line_end(bytes, i);
out.push(i..end);
i = end.max(i + 1);
}
b'"' | b'\'' => i = skip_string(bytes, i, bytes[i] == b'"', true),
_ => i += 1,
}
}
out
}
pub(super) fn hcl(text: &str) -> Vec<Range<usize>> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
let rest = &bytes[i..];
match bytes[i] {
b'#' => {
let end = line_end(bytes, i);
out.push(i..end);
i = end.max(i + 1);
}
b'/' if rest.starts_with(b"//") => {
let end = line_end(bytes, i);
out.push(i..end);
i = end.max(i + 1);
}
b'/' if rest.starts_with(b"/*") => {
let end = memchr::memmem::find(&bytes[i + 2..], b"*/")
.map_or(bytes.len(), |p| i + 2 + p + 2);
out.push(i..end);
i = end;
}
b'"' => i = skip_string(bytes, i, true, false),
b'<' if rest.starts_with(b"<<") => i = skip_heredoc(bytes, i),
_ => i += 1,
}
}
out
}
pub(super) fn properties(text: &str) -> Vec<Range<usize>> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut i = 0;
let mut continued = false;
while i < bytes.len() {
let end = line_end(bytes, i);
let content = &text[i..end];
let indent = content.len() - content.trim_start_matches([' ', '\t', '\x0c']).len();
if !continued && content[indent..].starts_with(['#', '!']) {
out.push(i + indent..end);
continued = false;
} else {
let backslashes = bytes[i..end]
.iter()
.rev()
.take_while(|&&b| b == b'\\')
.count();
continued = backslashes % 2 == 1;
}
i = next_line(bytes, end);
}
out
}
pub(super) fn hash_outside(text: &str, spans: &mut Vec<Range<usize>>) -> Vec<Range<usize>> {
merge_spans(spans);
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while let Some(found) = memchr::memchr(b'#', &bytes[i..]) {
let at = i + found;
let starts_token = at == 0 || matches!(bytes[at - 1], b' ' | b'\t' | b'\n' | b'\r');
let end = line_end(bytes, at);
if starts_token && !covered(spans, at..end) {
out.push(at..end);
}
i = end.max(at + 1);
}
out
}
fn covered(spans: &[Range<usize>], range: Range<usize>) -> bool {
let next = spans.partition_point(|s| s.end <= range.start);
spans
.get(next)
.is_some_and(|s| s.start < range.end.max(range.start + 1))
}
fn merge_spans(spans: &mut Vec<Range<usize>>) {
spans.retain(|s| s.start < s.end);
spans.sort_by_key(|s| (s.start, s.end));
let mut merged: Vec<Range<usize>> = Vec::with_capacity(spans.len());
for span in spans.iter() {
match merged.last_mut() {
Some(last) if span.start <= last.end => last.end = last.end.max(span.end),
_ => merged.push(span.clone()),
}
}
*spans = merged;
}
fn skip_string(bytes: &[u8], start: usize, escapes: bool, triples: bool) -> usize {
let quote = bytes[start];
let fence = [quote, quote, quote];
if triples && bytes[start..].starts_with(&fence) {
let mut i = start + 3;
while i < bytes.len() {
if escapes && bytes[i] == b'\\' {
i += 2;
} else if bytes[i..].starts_with(&fence) {
return i + 3;
} else {
i += 1;
}
}
return bytes.len();
}
let mut i = start + 1;
while i < bytes.len() {
match bytes[i] {
b'\\' if escapes => i += 2,
b'\n' => return i,
b if b == quote => return i + 1,
_ => i += 1,
}
}
bytes.len()
}
fn skip_heredoc(bytes: &[u8], start: usize) -> usize {
let mut i = start + 2;
if bytes.get(i) == Some(&b'-') {
i += 1;
}
let tag_start = i;
while bytes
.get(i)
.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_')
{
i += 1;
}
if i == tag_start {
return start + 2;
}
let tag = &bytes[tag_start..i];
let mut line = next_line(bytes, line_end(bytes, i));
while line < bytes.len() {
let end = line_end(bytes, line);
if bytes[line..end].trim_ascii() == tag {
return end;
}
line = next_line(bytes, end);
}
bytes.len()
}
fn next_line(bytes: &[u8], end: usize) -> usize {
match bytes.get(end) {
Some(b'\r') if bytes.get(end + 1) == Some(&b'\n') => end + 2,
Some(_) => end + 1,
None => end,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn found<'a>(text: &'a str, ranges: &[Range<usize>]) -> Vec<&'a str> {
ranges.iter().map(|r| &text[r.clone()]).collect()
}
#[test]
fn toml_comments() {
let text = "# one\nkey = \"a # b\" # two\nk2 = '''\n# inside\n''' # three\n\"a#b\" = 1\n";
assert_eq!(
found(text, &toml(text)),
["# one", "# two", "# three"],
"{:?}",
toml(text)
);
}
#[test]
fn hcl_comments() {
let text = concat!(
"# one\n",
"a = \"x # y\" // two\n",
"/* three\n still */ b = 1 # four\n",
"c = <<EOT\n# not a comment\nEOT\n",
"d = 2 # five\n",
);
assert_eq!(
found(text, &hcl(text)),
[
"# one",
"// two",
"/* three\n still */",
"# four",
"# five"
]
);
}
#[test]
fn properties_comments() {
let text = "# one\n ! two\nkey=value # not a comment\nk2=a\\\n# continued\nk3=b\n";
assert_eq!(found(text, &properties(text)), ["# one", "! two"]);
}
#[test]
fn hash_comments_outside_spans() {
let text = "key: value # one\nother: \"a # b\"\n# two\nurl: http://x/#frag\n";
let quoted = text.find("\"a # b\"").unwrap();
let mut spans = vec![quoted..quoted + 7, 0..0];
assert_eq!(
found(text, &hash_outside(text, &mut spans)),
["# one", "# two"]
);
}
#[test]
fn crlf_comments_exclude_the_line_break() {
let text = "a = 1 # one\r\nb = 2\r\n";
assert_eq!(found(text, &toml(text)), ["# one"]);
}
#[test]
fn merges_and_detects_covered_ranges() {
let mut spans = vec![10..20, 0..5, 4..8];
merge_spans(&mut spans);
assert_eq!(spans, vec![0..8, 10..20]);
assert!(covered(&spans, 7..9));
assert!(covered(&spans, 19..25));
assert!(!covered(&spans, 8..10));
}
}