pub fn to_snake(ident: &str) -> String {
let chars: Vec<char> = ident.chars().collect();
let mut out = String::with_capacity(ident.len() + 4);
for (i, &c) in chars.iter().enumerate() {
if c == '_' {
if !out.ends_with('_') && !out.is_empty() {
out.push('_');
}
continue;
}
if c.is_ascii_uppercase() {
let prev = if i > 0 { Some(chars[i - 1]) } else { None };
let next = chars.get(i + 1).copied();
let boundary = match prev {
None => false,
Some('_') => false,
Some(p) if p.is_ascii_lowercase() || p.is_ascii_digit() => true,
Some(p) if p.is_ascii_uppercase() => {
matches!(next, Some(n) if n.is_ascii_lowercase())
}
_ => false,
};
if boundary && !out.is_empty() && !out.ends_with('_') {
out.push('_');
}
out.push(c.to_ascii_lowercase());
} else {
out.push(c);
}
}
let trimmed = out.trim_matches('_').to_string();
let stem = if trimmed.is_empty() { "item".to_string() } else { trimmed };
sanitize_stem(&stem)
}
const KEYWORDS: &[&str] = &[
"as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false", "fn",
"for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
"return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe",
"use", "where", "while", "async", "await", "abstract", "become", "box", "do", "final",
"macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
];
fn sanitize_stem(stem: &str) -> String {
if KEYWORDS.contains(&stem) {
format!("{stem}_")
} else {
stem.to_string()
}
}
pub fn is_keyword(name: &str) -> bool {
KEYWORDS.contains(&name)
}
pub fn line_start(src: &str, byte: usize) -> usize {
src[..byte].rfind('\n').map(|i| i + 1).unwrap_or(0)
}
pub fn leading_comment_start(src: &str, gap_start: usize, item_start: usize) -> usize {
let ls = line_start(src, item_start);
if ls <= gap_start {
return item_start;
}
let mut block_start = ls;
let mut cursor = ls;
loop {
if cursor <= gap_start {
break;
}
let prev_line_end = cursor - 1; let prev_line_start = src[gap_start..prev_line_end]
.rfind('\n')
.map(|i| gap_start + i + 1)
.unwrap_or(gap_start);
let line = src[prev_line_start..prev_line_end].trim();
let is_comment = line.starts_with("//") && !line.starts_with("///") && !line.starts_with("//!");
if is_comment || (line.starts_with("//") && prev_line_start >= gap_start) {
block_start = prev_line_start;
cursor = prev_line_start;
} else {
break;
}
}
if block_start < ls {
block_start
} else {
item_start
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snake_cases() {
assert_eq!(to_snake("Foo"), "foo");
assert_eq!(to_snake("FooBar"), "foo_bar");
assert_eq!(to_snake("HTTPServer"), "http_server");
assert_eq!(to_snake("IOError"), "io_error");
assert_eq!(to_snake("MAX_SIZE"), "max_size");
assert_eq!(to_snake("parse_input"), "parse_input");
assert_eq!(to_snake("Http2Server"), "http2_server");
assert_eq!(to_snake("A"), "a");
assert_eq!(to_snake("VersionReq"), "version_req");
}
#[test]
fn keyword_stems_are_sanitized() {
assert_eq!(to_snake("Match"), "match_");
assert_eq!(to_snake("Type"), "type_");
assert!(!is_keyword("match_"));
}
}
pub fn extend_trailing_comment(src: &str, end: usize) -> usize {
let bytes = src.as_bytes();
let line_end = src[end..].find('\n').map(|i| end + i).unwrap_or(src.len());
let rest = &src[end..line_end];
let trimmed = rest.trim_start();
if trimmed.starts_with("//") {
let _ = bytes;
line_end
} else {
end
}
}