pub type Transform = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;
pub fn ensure_decl(line: &str) -> Transform {
ensure_lines(vec![line.to_string()])
}
pub fn ensure_lines(new_lines: Vec<String>) -> Transform {
let new_lines: Vec<String> = new_lines
.into_iter()
.map(|l| l.trim_end().to_string())
.collect();
Box::new(move |content: &str| {
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
let mut changed = false;
for line in &new_lines {
if lines.iter().any(|l| l.trim() == line) {
continue;
}
let group = leading_keyword(line);
let insert_at = lines
.iter()
.rposition(|l| leading_keyword(l) == group)
.map(|i| i + 1)
.unwrap_or(lines.len());
lines.insert(insert_at, line.clone());
changed = true;
}
changed.then(|| rejoin(&lines, content))
})
}
pub fn ensure_module_imports(imports: &[(&str, &str)]) -> Transform {
let imports: Vec<(String, String)> = imports
.iter()
.map(|(use_path, ident)| (format!("use {use_path};"), (*ident).to_owned()))
.collect();
Box::new(move |content: &str| {
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
let changed = imports.iter().fold(false, |changed, (use_line, ident)| {
insert_module_import(&mut lines, use_line, ident) || changed
});
changed.then(|| rejoin(&lines, content))
})
}
fn insert_module_import(lines: &mut Vec<String>, use_line: &str, ident: &str) -> bool {
let mut changed = false;
if !lines.iter().any(|l| l.trim() == use_line) {
let insert_at = lines
.iter()
.rposition(|l| l.trim_start().starts_with("use "))
.map(|i| i + 1)
.unwrap_or(0);
lines.insert(insert_at, use_line.to_owned());
changed = true;
}
if let Some((start, end)) = imports_span(lines) {
let already = lines[start..=end].iter().any(|l| entry_matches(l, ident));
if !already {
if start == end {
if let Some(spliced) = splice_inline(&lines[start], ident) {
lines[start] = spliced;
changed = true;
}
} else {
let indent = entry_indent(lines, end);
lines.insert(end, format!("{indent}{ident},"));
changed = true;
}
}
}
changed
}
fn leading_keyword(line: &str) -> &'static str {
let t = line.trim_start();
if t.starts_with("pub mod ") || t.starts_with("mod ") {
"mod"
} else if t.starts_with("pub use ") || t.starts_with("use ") {
"use"
} else {
""
}
}
fn imports_span(lines: &[String]) -> Option<(usize, usize)> {
let start = lines.iter().position(|l| l.contains("imports = ["))?;
if lines[start].contains(']') {
return Some((start, start));
}
let end = lines[start + 1..]
.iter()
.position(|l| l.trim_start().starts_with(']'))
.map(|i| start + 1 + i)?;
Some((start, end))
}
fn splice_inline(line: &str, ident: &str) -> Option<String> {
let open = line.find('[')?;
let close = line[open..].find(']').map(|i| open + i)?;
let inner = line[open + 1..close].trim();
let new_inner = if inner.is_empty() {
ident.to_string()
} else {
format!("{inner}, {ident}")
};
Some(format!("{}{new_inner}{}", &line[..=open], &line[close..]))
}
fn entry_matches(line: &str, ident: &str) -> bool {
line.split([',', '[', ']', ' ', '\t'])
.any(|tok| tok == ident)
}
fn entry_indent(lines: &[String], end: usize) -> String {
if end > 0 && !lines[end - 1].contains("imports = [") {
let ws: String = lines[end - 1]
.chars()
.take_while(|c| c.is_whitespace())
.collect();
if !ws.is_empty() {
return ws;
}
}
" ".to_string()
}
fn rejoin(lines: &[String], original: &str) -> String {
let joined = lines.join("\n");
if original.ends_with('\n') {
format!("{joined}\n")
} else {
joined
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ensure_decl_groups_and_is_idempotent() {
let src = "mod module;\nmod service;\n\npub use module::Foo;\n";
let t = ensure_decl("pub mod http;");
let out = t(src).expect("inserts");
assert!(out.contains("pub mod http;"));
let idx_mod = out.find("pub mod http;").unwrap();
let idx_use = out.find("pub use module::Foo;").unwrap();
assert!(idx_mod < idx_use);
assert!(ensure_decl("pub mod http;")(&out).is_none());
}
#[test]
fn ensure_module_import_into_multiline_block() {
let src = "use nest_rs_core::module;\nuse nest_rs_http::HttpModule;\n\n#[module(\n imports = [\n HttpModule::for_root(None),\n ],\n)]\npub struct AppModule;\n";
let one =
|| ensure_module_imports(&[("features::posts::PostsHttpModule", "PostsHttpModule")]);
let out = one()(src).expect("inserts");
assert!(out.contains("use features::posts::PostsHttpModule;"));
assert!(out.contains(" PostsHttpModule,"));
let entry = out.find("PostsHttpModule,").unwrap();
let close = out.find(" ],").unwrap();
assert!(entry < close);
assert!(one()(&out).is_none());
}
#[test]
fn ensure_module_import_single_line_block() {
let src = "use nest_rs_http::HttpModule;\n\n#[module(imports = [HttpModule::for_root(None)])]\npub struct AppModule;\n";
let t = ensure_module_imports(&[("crate::posts::PostsHttpModule", "PostsHttpModule")]);
let out = t(src).expect("inserts");
assert!(out.contains("PostsHttpModule"));
assert!(out.contains("use crate::posts::PostsHttpModule;"));
}
#[test]
fn ensure_module_imports_lands_every_pair_in_one_pass() {
let src = "use nest_rs_core::module;\n\n#[module(\n imports = [\n HttpModule::for_root(None),\n ],\n)]\npub struct AppModule;\n";
let pairs = [
("features::authn::AuthnModule", "AuthnModule"),
("features::authz::AuthzHttpModule", "AuthzHttpModule"),
];
let out = ensure_module_imports(&pairs)(src).expect("inserts");
for (use_path, ident) in pairs {
assert!(out.contains(&format!("use {use_path};")), "{out}");
assert!(out.contains(&format!(" {ident},")), "{out}");
}
assert!(ensure_module_imports(&pairs)(&out).is_none(), "idempotent");
}
}