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_import(use_path: &str, ident: &str) -> Transform {
let use_line = format!("use {use_path};");
let ident = ident.to_string();
Box::new(move |content: &str| {
let mut changed = false;
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
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.clone());
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.then(|| rejoin(&lines, content))
})
}
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 t = ensure_module_import("features::posts::PostsHttpModule", "PostsHttpModule");
let out = t(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!(
ensure_module_import("features::posts::PostsHttpModule", "PostsHttpModule")(&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_import("crate::posts::PostsHttpModule", "PostsHttpModule");
let out = t(src).expect("inserts");
assert!(out.contains("PostsHttpModule"));
assert!(out.contains("use crate::posts::PostsHttpModule;"));
}
}