use crate::error::{PassengerError, Result};
use crate::gate::{relative, workspace_root, EditSet, FileEdit};
use custodian_kit::change::{self, At, ChangeSpec, Landing, Operation};
use custodian_kit::transform::{Checked, FileWrite, TransformRecord, TRANSFORM_SCHEMA};
use proc_macro2::LineColumn;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use syn::spanned::Spanned;
use syn::visit::Visit;
#[derive(Debug, Clone, Default)]
pub struct Options {
pub spec: PathBuf,
pub write: bool,
pub no_cargo: bool,
pub unproven: bool,
pub out: Option<PathBuf>,
pub preflight: Option<i64>,
}
#[derive(Debug)]
pub struct Outcome {
pub root: PathBuf,
pub record: TransformRecord,
pub record_file: Option<PathBuf>,
pub edits: EditSet,
pub exit_code: i32,
pub said: Vec<String>,
}
fn refused(detail: impl std::fmt::Display) -> PassengerError {
PassengerError::TransformRefused { detail: detail.to_string() }
}
#[derive(Default)]
struct Tree {
files: BTreeMap<PathBuf, (Option<String>, String)>,
}
fn canon(p: &Path) -> PathBuf {
p.canonicalize().unwrap_or_else(|_| match (p.parent().and_then(|d| d.canonicalize().ok()), p.file_name()) {
(Some(d), Some(n)) => d.join(n),
_ => p.to_path_buf(),
})
}
impl Tree {
fn text(&mut self, p: &Path) -> Result<&mut String> {
let p = &canon(p);
if !self.files.contains_key(p) {
let t = std::fs::read_to_string(p).map_err(|e| PassengerError::Io { detail: format!("{}: {e}", p.display()) })?;
self.files.insert(p.to_path_buf(), (Some(t.clone()), t));
}
Ok(&mut self.files.get_mut(p).expect("just inserted").1)
}
fn create(&mut self, p: &Path, text: String) -> Result<()> {
let p = &canon(p);
if p.exists() || self.files.contains_key(p) {
return Err(refused(format!("{} already exists", p.display())));
}
self.files.insert(p.to_path_buf(), (None, text));
Ok(())
}
fn edits(&self) -> EditSet {
EditSet { files: self.files.iter().map(|(p, (b, a))| FileEdit { path: p.clone(), before: b.clone().unwrap_or_default(), after: a.clone() }).collect(), manifest: None }
}
}
fn offset(text: &str, at: LineColumn) -> usize {
let mut off = 0;
for (i, line) in text.split_inclusive('\n').enumerate() {
if i + 1 == at.line {
return off + line.chars().take(at.column).map(char::len_utf8).sum::<usize>();
}
off += line.len();
}
text.len()
}
fn line_start(text: &str, line: usize) -> usize {
offset(text, LineColumn { line, column: 0 })
}
fn line_end(text: &str, line: usize) -> usize {
line_start(text, line + 1)
}
fn indent_of(text: &str, line: usize) -> String {
text.lines().nth(line.saturating_sub(1)).map(|l| l.chars().take_while(|c| c.is_whitespace()).collect()).unwrap_or_default()
}
fn parse(text: &str, path: &Path) -> Result<syn::File> {
syn::parse_file(text).map_err(|e| refused(format!("{}: syn cannot parse it: {e}", path.display())))
}
#[derive(Debug, Clone, Copy)]
struct Spans {
start: LineColumn,
end: LineColumn,
close: Option<LineColumn>,
body_open: Option<LineColumn>,
}
struct FindItem {
line: usize,
found: Option<Spans>,
}
impl FindItem {
fn consider(&mut self, start: LineColumn, end: LineColumn, close: Option<LineColumn>, body_open: Option<LineColumn>) {
if start.line <= self.line && self.line <= end.line {
let better = match &self.found {
None => true,
Some(f) => start.line == self.line && f.start.line != self.line || (start.line >= f.start.line && end.line <= f.end.line),
};
if better {
self.found = Some(Spans { start, end, close, body_open });
}
}
}
}
impl<'ast> Visit<'ast> for FindItem {
fn visit_item(&mut self, i: &'ast syn::Item) {
let sp = i.span();
let close = match i {
syn::Item::Struct(s) => match &s.fields {
syn::Fields::Named(n) => Some(n.brace_token.span.close().start()),
_ => None,
},
syn::Item::Enum(e) => Some(e.brace_token.span.close().start()),
syn::Item::Impl(x) => Some(x.brace_token.span.close().start()),
syn::Item::Mod(m) => m.content.as_ref().map(|(b, _)| b.span.close().start()),
syn::Item::Trait(t) => Some(t.brace_token.span.close().start()),
_ => None,
};
let body = match i {
syn::Item::Fn(f) => Some(f.block.brace_token.span.open().start()),
_ => None,
};
self.consider(sp.start(), sp.end(), close, body);
syn::visit::visit_item(self, i);
}
fn visit_impl_item_fn(&mut self, f: &'ast syn::ImplItemFn) {
let sp = f.span();
self.consider(sp.start(), sp.end(), None, Some(f.block.brace_token.span.open().start()));
syn::visit::visit_impl_item_fn(self, f);
}
fn visit_trait_item_fn(&mut self, f: &'ast syn::TraitItemFn) {
let sp = f.span();
self.consider(sp.start(), sp.end(), None, f.default.as_ref().map(|b| b.brace_token.span.open().start()));
syn::visit::visit_trait_item_fn(self, f);
}
}
fn item_at(text: &str, path: &Path, line: u32) -> Result<Spans> {
let file = parse(text, path)?;
let mut f = FindItem { line: line as usize, found: None };
f.visit_file(&file);
f.found.ok_or_else(|| refused(format!("{}:{line}: no item there", path.display())))
}
type Splice = (usize, usize, String);
fn splice(text: &mut String, mut edits: Vec<Splice>) {
edits.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
for (a, b, t) in edits {
text.replace_range(a..b, &t);
}
}
fn before_close(text: &str, close: LineColumn, lines: &str, comma: bool) -> Vec<Splice> {
let at = offset(text, close);
let before = &text[..at];
let own_line = before.rsplit('\n').next().is_some_and(|l| l.trim().is_empty());
let last = before.trim_end().chars().last();
let needs_comma = comma && !matches!(last, Some(',') | Some('{') | None);
let mut out = Vec::new();
if own_line {
if needs_comma {
let end_of_prev = before.trim_end().len();
out.push((end_of_prev, end_of_prev, ",".to_string()));
}
let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
out.push((line_start, line_start, lines.to_string()));
} else {
let inline = lines.lines().map(str::trim).collect::<Vec<_>>().join(" ");
let inline = inline.trim_end_matches(',');
let end_of_prev = before.trim_end().len();
let pre = if needs_comma { ", " } else { " " };
out.push((end_of_prev, end_of_prev, format!("{pre}{inline}")));
}
out
}
fn insert_before_close(text: &mut String, close: LineColumn, lines: &str, comma: bool) {
let e = before_close(text, close, lines, comma);
splice(text, e);
}
fn test_module_line(file: &syn::File) -> Option<usize> {
file.items.iter().find_map(|i| match i {
syn::Item::Mod(m) if m.ident == "tests" && m.attrs.iter().any(|a| a.path().is_ident("cfg")) => Some(i.span().start().line),
_ => None,
})
}
fn place_at_end(text: &mut String, path: &Path, block: &str) -> Result<()> {
let file = parse(text, path)?;
let at = match test_module_line(&file) {
Some(l) => line_start(text, l),
None => {
if !text.ends_with('\n') {
text.push('\n');
}
text.len()
}
};
let mut ins = String::new();
if at > 0 && !text[..at].ends_with("\n\n") {
ins.push('\n');
}
ins.push_str(block);
if at < text.len() {
ins.push('\n');
}
text.insert_str(at, &ins);
Ok(())
}
fn place_after(text: &mut String, sp: Spans, block: &str) {
let at = line_end(text, sp.end.line);
let mut ins = String::from("\n");
ins.push_str(block);
text.insert_str(at, &ins);
}
fn place_above(text: &mut String, sp: Spans, attr: &str) {
let lines: Vec<String> = text.lines().map(str::to_string).collect();
let lead = crate::gate::leading(&lines, sp.start.line - 1);
if let Some(inner) = attr.trim().strip_prefix("#[derive(").and_then(|r| r.strip_suffix(")]"))
&& let Some((a, _)) = lead.attrs.iter().find(|(a, b)| b - a == 1 && lines[*a].trim().starts_with("#[derive(")) {
let line = &lines[*a];
if let Some(close) = line.rfind(")]") {
let have: Vec<&str> = line.trim()[9..].trim_end_matches(")]").split(',').map(str::trim).collect();
let add: Vec<&str> = inner.split(',').map(str::trim).filter(|d| !have.contains(d)).collect();
if add.is_empty() {
return;
}
let at = line_start(text, a + 1) + close;
text.insert_str(at, &format!(", {}", add.join(", ")));
return;
}
}
let at_line = lead.attrs.first().map(|(a, _)| *a).unwrap_or(lead.decl);
let indent = crate::gate::indent_at(&lines, at_line);
let at = line_start(text, at_line + 1);
text.insert_str(at, &format!("{indent}{}\n", attr.trim()));
}
struct Literals {
want: BTreeSet<(usize, usize)>,
found: Vec<(LineColumn, bool)>,
}
impl<'ast> Visit<'ast> for Literals {
fn visit_expr_struct(&mut self, e: &'ast syn::ExprStruct) {
let s = e.path.span().start();
if self.want.contains(&(s.line, s.column)) {
self.found.push((e.brace_token.span.close().start(), e.rest.is_some()));
}
syn::visit::visit_expr_struct(self, e);
}
}
struct Calls {
want: BTreeSet<(usize, usize)>,
name: String,
found: Vec<(LineColumn, bool)>,
met: BTreeSet<(usize, usize)>,
}
impl Calls {
fn new(want: BTreeSet<(usize, usize)>, name: &str) -> Calls {
Calls { want, name: name.to_string(), found: Vec::new(), met: BTreeSet::new() }
}
fn meet(&mut self, at: &[LineColumn]) -> bool {
let hits: Vec<(usize, usize)> = at.iter().map(|s| (s.line, s.column)).filter(|k| self.want.contains(k)).collect();
self.met.extend(hits.iter().copied());
!hits.is_empty()
}
}
impl<'ast> Visit<'ast> for Calls {
fn visit_expr_call(&mut self, e: &'ast syn::ExprCall) {
let names_it = matches!(&*e.func, syn::Expr::Path(p) if p.path.segments.last().is_some_and(|s| s.ident == self.name.as_str()));
if names_it && self.meet(&[e.func.span().start()]) {
self.found.push((e.paren_token.span.close().start(), !e.args.is_empty()));
}
syn::visit::visit_expr_call(self, e);
}
fn visit_expr_method_call(&mut self, e: &'ast syn::ExprMethodCall) {
if e.method == self.name.as_str() && self.meet(&[e.receiver.span().start(), e.method.span().start()]) {
self.found.push((e.paren_token.span.close().start(), !e.args.is_empty()));
}
syn::visit::visit_expr_method_call(self, e);
}
}
fn matching_paren(text: &str, open: usize) -> Option<usize> {
let b = text.as_bytes();
let mut depth = 0i32;
let mut i = open;
while i < b.len() {
match b[i] {
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => {
depth -= 1;
if depth == 0 {
return (b[i] == b')').then_some(i);
}
}
b'"' => {
i += 1;
while i < b.len() && b[i] != b'"' {
if b[i] == b'\\' {
i += 1;
}
i += 1;
}
}
b'\'' if i + 2 < b.len() && (b[i + 2] == b'\'' || b[i + 1] == b'\\') => {
i += 1;
while i < b.len() && b[i] != b'\'' {
if b[i] == b'\\' {
i += 1;
}
i += 1;
}
}
_ => {}
}
i += 1;
}
None
}
fn arguments(text: &str, open: usize, close: usize) -> Vec<String> {
let inner = &text[open + 1..close];
let mut out = Vec::new();
let (mut depth, mut start, mut in_str, mut esc) = (0i32, 0usize, false, false);
for (i, c) in inner.char_indices() {
if in_str {
match (esc, c) {
(true, _) => esc = false,
(false, '\\') => esc = true,
(false, '"') => in_str = false,
_ => {}
}
continue;
}
match c {
'"' => in_str = true,
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
',' if depth == 0 => {
out.push(inner[start..i].trim().to_string());
start = i + 1;
}
_ => {}
}
}
let last = inner[start..].trim();
if !last.is_empty() {
out.push(last.to_string());
}
out
}
fn call_in_text(text: &str, line: u32, column: Option<u32>, name: &str) -> Option<(usize, usize)> {
let (l, c) = syn_at(line, column);
let starts = [offset(text, LineColumn { line: l, column: c }), offset(text, LineColumn { line: l, column: c + 1 })];
let from = starts.into_iter().min()?;
let end = text[from..].find('\n').map(|e| from + e).unwrap_or(text.len());
let mut at = from;
while let Some(i) = text[at..end].find(name) {
let a = at + i;
let before = text[..a].chars().last();
let mut rest = a + name.len();
let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
if !word(before) && !word(text[rest..].chars().next()) {
let tail = text[rest..].trim_start();
rest = text.len() - tail.len();
if tail.starts_with("::<") {
let mut depth = 0;
for (j, ch) in tail.char_indices() {
match ch {
'<' => depth += 1,
'>' => {
depth -= 1;
if depth == 0 {
rest += j + 1;
break;
}
}
_ => {}
}
}
rest = text.len() - text[rest..].trim_start().len();
}
if text[rest..].starts_with('(') {
return matching_paren(text, rest).map(|close| (rest, close));
}
}
at = a + name.len();
}
None
}
fn syn_at(line: u32, column: Option<u32>) -> (usize, usize) {
(line as usize, column.map(|c| c.saturating_sub(1) as usize).unwrap_or(0))
}
fn both(line: u32, column: Option<u32>) -> [(usize, usize); 2] {
let (l, c) = syn_at(line, column);
[(l, c), (l, c + 1)]
}
struct Planned {
tree: Tree,
generated: Vec<String>,
listed: Vec<String>,
also: Vec<String>,
facts: Vec<String>,
}
fn abs(root: &Path, rel: &str) -> PathBuf {
root.join(rel)
}
fn ty_name(id: &str) -> &str {
id.rsplit("::").next().unwrap_or(id)
}
fn module_path_of(id: &str) -> &str {
id.rsplit_once("::").map(|(m, _)| m).unwrap_or(id)
}
fn plan(k: &klepto::Klepto, root: &Path, spec: &ChangeSpec, land: &Landing) -> Result<Planned> {
use Operation::*;
let mut p = Planned { tree: Tree::default(), generated: Vec::new(), listed: Vec::new(), also: Vec::new(), facts: Vec::new() };
if let Some(why) = land.item.as_deref().and_then(|i| change::rewritten_by(k, spec.op(), i)) {
return Err(refused(why));
}
let file = abs(root, &land.file);
let item = land.item.clone().unwrap_or_default();
let line_of = |at: &At| -> u32 {
match at {
At::Inside { line } | At::After { line } | At::Above { line } | At::At { line } => *line,
_ => 1,
}
};
match spec.op() {
MakeStruct => {
let i = spec.item.as_ref().ok_or_else(|| refused("make_struct needs item"))?;
let text = change::render_struct(i).map_err(refused)?;
place_at_end(p.tree.text(&file)?, &file, &text)?;
p.generated.push(text);
p.facts.push(format!("exists {}::{}", land.module, i.name));
}
MakeEnum => {
let i = spec.item.as_ref().ok_or_else(|| refused("make_enum needs item"))?;
let text = change::render_enum(i);
place_at_end(p.tree.text(&file)?, &file, &text)?;
p.generated.push(text);
p.facts.push(format!("exists {}::{}", land.module, i.name));
}
MakeFn => {
let f = spec.function.as_ref().ok_or_else(|| refused("make_fn needs function"))?;
let text = change::render_fn(f, "").map_err(refused)?;
place_at_end(p.tree.text(&file)?, &file, &text)?;
let fact = if f.generator.is_some() { text_fact(&land.file, &text) } else { format!("exists {}::{}", land.module, f.name) };
p.generated.push(text);
p.facts.push(fact);
}
MakeImpl | AddTraitImpl => {
let tname = ty_name(&item).to_string();
let text = match (&spec.generator, spec.op()) {
(Some(g), AddTraitImpl) => change::render_impl_piece(g).map_err(refused)?,
_ => change::render_impl(&tname, if spec.op() == AddTraitImpl { spec.trait_name.as_deref() } else { None }, &spec.assoc, &spec.functions, None).map_err(refused)?,
};
let t = p.tree.text(&file)?;
let sp = item_at(t, &file, line_of(&land.at))?;
place_after(t, sp, &text);
if spec.generator.is_some() {
p.facts.push(text_fact(&land.file, &text));
} else if spec.op() == AddTraitImpl {
p.facts.push(format!("impl {item} {}", spec.trait_name.clone().unwrap_or_default()));
}
for f in &spec.functions {
p.facts.push(if f.generator.is_some() || spec.op() == AddTraitImpl { text_fact(&land.file, &change::render_fn(f, " ").unwrap_or_default()) } else { format!("method {item} {}", f.name) });
}
p.generated.push(text);
}
AddMethod | AddConstructor => {
let mut f = spec.function.clone().ok_or_else(|| refused("add_method needs function"))?;
if spec.op() == AddConstructor && f.body.is_empty() && f.generator.is_none() {
if f.ret.is_none() {
f.ret = Some("Self".into());
}
let names: Vec<String> = f.params.iter().map(|x| x.name.clone()).collect();
f.body = vec![change::Piece { piece: "value".into(), value: Some(format!("Self {{ {} }}", names.join(", "))), ..Default::default() }];
}
let t = p.tree.text(&file)?;
match &land.at {
At::Inside { line } => {
let sp = item_at(t, &file, *line)?;
let close = sp.close.ok_or_else(|| refused(format!("{}:{line}: the impl has no body", land.file)))?;
let indent = format!("{} ", indent_of(t, sp.start.line));
let text = change::render_fn(&f, &indent).map_err(refused)?;
insert_before_close(t, close, &format!("\n{text}"), false);
p.generated.push(text);
}
_ => {
let text = change::render_impl(ty_name(&item), None, &[], std::slice::from_ref(&f), None).map_err(refused)?;
let sp = item_at(t, &file, line_of(&land.at))?;
place_after(t, sp, &text);
p.generated.push(text);
}
}
p.facts.push(fn_fact(&f, &item, &land.file, p.generated.last()));
}
AddField => {
let fl = spec.field.as_ref().ok_or_else(|| refused("add_field needs field"))?;
let mut splices: BTreeMap<PathBuf, Vec<Splice>> = BTreeMap::new();
let t = p.tree.text(&file)?.clone();
let sp = item_at(&t, &file, line_of(&land.at))?;
let close = sp.close.ok_or_else(|| refused(format!("{item}: not a struct with named fields")))?;
let indent = format!("{} ", indent_of(&t, sp.start.line));
let text = change::render_field(fl, &indent);
splices.entry(canon(&file)).or_default().extend(before_close(&t, close, &text, true));
p.generated.push(text);
p.facts.push(format!("field {item} {}", fl.name));
let mut by_file: BTreeMap<PathBuf, BTreeSet<(usize, usize)>> = BTreeMap::new();
for o in k.paths.iter().filter(|o| o.role == klepto::PathRole::StructLiteral) {
let resolves = matches!(&o.resolution, Some(klepto::Resolution::Local(id) | klepto::Resolution::External(id)) if *id == item);
if let (true, Some(l)) = (resolves, o.location.line) {
by_file.entry(canon(&o.location.path)).or_default().extend(both(l, o.location.column));
}
}
for (f, want) in by_file {
let t = p.tree.text(&f)?.clone();
let parsed = parse(&t, &f)?;
let mut v = Literals { want, found: Vec::new() };
v.visit_file(&parsed);
for (close, rest) in v.found {
let at = format!("{}:{}", relative(root, &f).map(|r| r.display().to_string()).unwrap_or_default(), close.line);
match (&fl.default, rest) {
(_, true) => p.listed.push(format!("struct literal {at}: `..` fills the new field")),
(Some(d), false) => {
let indent = format!("{} ", indent_of(&t, close.line));
splices.entry(f.clone()).or_default().extend(before_close(&t, close, &format!("{indent}{}: {d},\n", fl.name), true));
}
(None, false) => p.listed.push(format!("struct literal {at}: needs the new field")),
}
}
}
for (f, e) in splices {
splice(p.tree.text(&f)?, e);
}
}
AddVariant => {
let v = spec.variant.as_ref().ok_or_else(|| refused("add_variant needs variant"))?;
let t = p.tree.text(&file)?;
let sp = item_at(t, &file, line_of(&land.at))?;
let close = sp.close.ok_or_else(|| refused(format!("{item}: not an enum")))?;
let indent = format!("{} ", indent_of(t, sp.start.line));
let text = change::render_variant(v, &indent);
insert_before_close(t, close, &text, true);
p.generated.push(text);
p.facts.push(format!("variant {item} {}", v.name));
for m in &k.matches {
let names_it = m.arms.iter().flat_map(|a| a.paths.iter()).any(|x| matches!(&x.resolution, Some(klepto::Resolution::Local(id) | klepto::Resolution::External(id)) if id.starts_with(&format!("{item}::"))));
if names_it && !m.arms.iter().any(|a| a.catch_all) {
p.listed.push(format!("match without a catch-all {}:{}: needs an arm for {}", relative(root, &m.location.path).map(|r| r.display().to_string()).unwrap_or_default(), m.location.line.unwrap_or(0), v.name));
}
}
}
AddDerive | AddAttribute | Gate | Anchor => {
let line = match spec.op() {
AddDerive => format!("#[derive({})]", spec.derive.join(", ")),
AddAttribute => spec.attribute.clone().unwrap_or_default(),
Gate => format!("#[cfg(feature = \"{}\")]", spec.feature.clone().unwrap_or_default()),
_ => format!("{}{} (.custodian/data/linkage/v1.{}.1o.linkage)", custodian_kit::linkage::ANCHOR, spec.linkage.clone().unwrap_or_default(), spec.linkage.clone().unwrap_or_default()),
};
let t = p.tree.text(&file)?;
let sp = item_at(t, &file, line_of(&land.at))?;
place_above(t, sp, &line);
p.generated.push(line.clone());
match spec.op() {
AddDerive => spec.derive.iter().for_each(|d| p.facts.push(format!("derive {item} {d}"))),
_ => p.facts.push(format!("text {} {line}", land.file)),
}
}
AddModule | Extract => {
let m = spec.module.as_ref().ok_or_else(|| refused("add_module needs module"))?;
let At::NewFile { parent } = &land.at else { return Err(refused("a module lands in a new file")) };
let parent_file = abs(root, parent);
let mut body = m.doc.as_ref().map(|d| d.lines().map(|l| format!("//! {l}\n")).collect::<String>()).unwrap_or_default();
let mut moved = Vec::new();
if spec.op() == Extract {
let mut spans = Vec::new();
for id in &spec.items {
let d = change::item_named(k, id).ok_or_else(|| refused(format!("{id}: no such item")))?;
if abs(root, &relative(root, &d.location.path).map(|r| r.display().to_string()).unwrap_or_default()) != parent_file {
return Err(refused(format!("{id} is not in {parent}")));
}
let t = p.tree.text(&parent_file)?;
spans.push((item_at(t, &parent_file, d.location.line.unwrap_or(1))?, d.name.clone()));
}
spans.sort_by_key(|b| std::cmp::Reverse(b.0.start.line));
let t = p.tree.text(&parent_file)?;
let mut texts = Vec::new();
for (sp, name) in &spans {
let (a, b) = (line_start(t, sp.start.line), line_end(t, sp.end.line));
texts.push(t[a..b].to_string());
t.replace_range(a..b, "");
moved.push(name.clone());
}
texts.reverse();
if !body.is_empty() {
body.push('\n');
}
body.push_str("use super::*;\n\n");
body.push_str(&texts.join("\n"));
}
let recipe = file_recipe::FileRecipe {
recipe_id: "code_passenger.rust_module.v1".into(),
extension: ".rs".into(),
mime_type: "text/x-rust".into(),
filename_template: "{name}".into(),
storage_class: file_recipe::StorageClass::InlineBytes,
read_modes: vec![file_recipe::ReadMode::Utf8Text],
validation: file_recipe::ValidationRuleSet { min_bytes: None, max_bytes: None, required_fields: vec!["name".into()], require_utf8: true },
projections: Vec::new(),
};
let made = file_recipe::materialize_file_object(&recipe, file_recipe::FileRecipeInput { source_kind: "change_spec".into(), fields: [("name".to_string(), m.name.clone())].into(), bytes: body.into_bytes() })
.map_err(|e| refused(format!("the module file: {e:?}")))?;
let new = abs(root, &land.file);
if new.file_name().map(|n| n.to_string_lossy().into_owned()) != Some(made.filename.clone()) {
return Err(refused(format!("the module file is {} where the landing says {}", made.filename, land.file)));
}
let text = String::from_utf8(made.bytes).map_err(refused)?;
p.tree.create(&new, text.clone())?;
p.generated.push(text);
let vis = m.vis.as_ref().map(|v| format!("{v} ")).unwrap_or_default();
let t = p.tree.text(&parent_file)?;
let parsed = parse(t, &parent_file)?;
let last_mod = parsed.items.iter().filter(|i| matches!(i, syn::Item::Mod(m) if m.content.is_none())).map(|i| i.span().end().line).max();
let first_item = parsed.items.first().map(|i| i.span().start().line);
let mut decl = format!("{vis}mod {};\n", m.name);
if !moved.is_empty() {
decl.push_str(&format!("pub use {}::{{{}}};\n", m.name, moved.join(", ")));
}
match (last_mod, first_item) {
(Some(l), _) => {
let at = line_end(t, l);
t.insert_str(at, &decl);
}
(None, Some(l)) => {
let at = line_start(t, l);
t.insert_str(at, &format!("{decl}\n"));
}
(None, None) => t.push_str(&decl),
}
p.facts.push(format!("module {}", land.module));
for n in &moved {
p.facts.push(format!("exists {}::{n}", land.module));
}
}
MakeLib => {
let new = abs(root, &land.file);
if new.exists() {
return Err(refused(format!("{} already exists", land.file)));
}
let text = spec.module.as_ref().and_then(|m| m.doc.as_ref()).map(|d| d.lines().map(|l| format!("//! {l}\n")).collect::<String>()).unwrap_or_default();
p.tree.create(&new, text.clone())?;
p.generated.push(text);
p.facts.push(format!("library {}", land.package));
}
AddDependency => {
let d = spec.dependency.as_ref().ok_or_else(|| refused("add_dependency needs dependency"))?;
let dep = match &d.version {
Some(v) => format!("{}@{v}", d.name),
None => d.name.clone(),
};
let e = crate::commands::edit::dep_add_plan(root, &land.package, &dep, d.path.as_deref(), &d.features, false)?;
for f in e.edits.files {
let after = match d.optional {
Some(o) => crate::commands::edit::dep_change_text_with(&f.after, &d.name, None, None, &[], Some(o))?.0,
None => f.after,
};
p.tree.files.insert(f.path.clone(), (Some(f.before), after));
}
p.facts.push(format!("dependency {} {}", land.package, d.name));
if let Some(o) = d.optional {
p.facts.push(format!("optional {} {} {o}", land.package, d.name));
}
}
AddTest => {
let f = spec.function.as_ref().ok_or_else(|| refused("add_test needs function"))?;
let mut f = f.clone();
if !f.attrs.iter().any(|a| a.trim() == "#[test]") {
f.attrs.insert(0, "#[test]".into());
}
let module = spec.to.clone().unwrap_or_else(|| "tests".into());
let t = p.tree.text(&file)?;
let parsed = parse(t, &file)?;
let tests = parsed.items.iter().find_map(|i| match i {
syn::Item::Mod(m) if m.ident == module.as_str() && m.content.is_some() => Some((i.span(), m.content.as_ref().map(|(b, _)| b.span.close().start()))),
_ => None,
});
match tests {
Some((sp, Some(close))) => {
let indent = format!("{} ", indent_of(t, sp.start().line));
let text = change::render_fn(&f, &indent).map_err(refused)?;
insert_before_close(t, close, &format!("\n{text}"), false);
p.generated.push(text);
}
_ => {
let text = format!("#[cfg(test)]\nmod {module} {{\n use super::*;\n\n{}}}\n", change::render_fn(&f, " ").map_err(refused)?);
if !t.ends_with('\n') {
t.push('\n');
}
t.push('\n');
t.push_str(&text);
p.generated.push(text);
}
}
p.facts.push(format!("exists {}::{module}::{}", land.module, f.name));
}
ReplaceBody => {
let f = spec.function.as_ref().ok_or_else(|| refused("replace_body needs function"))?;
let old = k.functions.iter().find(|x| x.fq_name == item).ok_or_else(|| refused(format!("{item}: not a function klepto captured")))?;
if f.name != ty_name(&item) {
return Err(refused(format!("{item}: the spec's function is {}, not this one", f.name)));
}
let t = p.tree.text(&file)?;
let sp = item_at(t, &file, line_of(&land.at))?;
if f.generator.is_some() {
let lines: Vec<String> = t.lines().map(str::to_string).collect();
let lead = crate::gate::leading(&lines, sp.start.line - 1);
let indent = indent_of(t, lead.decl + 1);
let call = change::render_fn(f, &indent).map_err(refused)?;
let (a, b) = (line_start(t, sp.start.line), offset(t, sp.end));
t.replace_range(a..b, call.trim_end());
p.generated.push(call.clone());
p.facts.push(text_fact(&land.file, &call));
let _ = old;
} else {
let open = sp.body_open.ok_or_else(|| refused(format!("{item}: no body")))?;
let lines: Vec<String> = t.lines().map(str::to_string).collect();
let lead = crate::gate::leading(&lines, sp.start.line - 1);
let body = change::render_body(f, &indent_of(t, lead.decl + 1)).map_err(refused)?;
let (a, b) = (offset(t, open), offset(t, sp.end));
if !t[a..b].ends_with('}') {
return Err(refused(format!("{item}: its body does not end at the item's end")));
}
t.replace_range(a..b, &body);
let first = body.lines().nth(1).unwrap_or("").trim().to_string();
p.generated.push(body);
p.facts.push(format!("args {item} {}", old.args.len()));
if !first.is_empty() {
p.facts.push(format!("text {} {first}", land.file));
}
}
}
Inline => {
let blockers = change::inline_blockers(k, &item);
if !blockers.is_empty() {
return Err(refused(blockers.join("; ")));
}
let f = k.functions.iter().find(|x| x.fq_name == item).ok_or_else(|| refused(format!("{item}: not a function klepto captured")))?;
let name = ty_name(&item).to_string();
let decl = canon(&f.location.path);
let dtext = p.tree.text(&decl)?.clone();
let parsed = parse(&dtext, &decl)?;
let mut fv = FnText { name: name.clone(), line: f.location.line.unwrap_or(1) as usize, found: None };
fv.visit_file(&parsed);
let (params, body_span, item_span) = fv.found.ok_or_else(|| refused(format!("{item}: no body at {}:{}", land.file, f.location.line.unwrap_or(0))))?;
let params: Vec<(String, String)> = params.iter().map(|(a, b, c, d)| (dtext[offset(&dtext, *a)..offset(&dtext, *b)].to_string(), dtext[offset(&dtext, *c)..offset(&dtext, *d)].to_string())).collect();
let body = dtext[offset(&dtext, body_span.0)..offset(&dtext, body_span.1)].to_string();
let mut splices: BTreeMap<PathBuf, Vec<Splice>> = BTreeMap::new();
let mut by_file: BTreeMap<PathBuf, Vec<(u32, Option<u32>)>> = BTreeMap::new();
for c in &k.calls {
if matches!(&c.resolution, Some(klepto::Resolution::Local(id) | klepto::Resolution::External(id)) if *id == item)
&& let Some(l) = c.location.line {
by_file.entry(canon(&c.location.path)).or_default().push((l, c.location.column));
}
}
let mut calls = 0;
for (cf, places) in by_file {
let t = p.tree.text(&cf)?.clone();
let mut at: Vec<(usize, usize, Vec<String>)> = Vec::new();
for (l, c) in places {
let (open, close) = call_in_text(&t, l, c, &name).ok_or_else(|| refused(format!("{}:{l}: klepto names a call of {name} there and none is in the text", relative(root, &cf).map(|r| r.display().to_string()).unwrap_or_default())))?;
let name_at = t[..open].trim_end().len() - name.len();
let mut start = name_at;
loop {
let before = t[..start].trim_end();
if let Some(b) = before.strip_suffix("::") {
let b = b.trim_end();
let seg = b.len() - b.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum::<usize>();
start = seg;
} else {
break;
}
}
at.push((start, close + 1, arguments(&t, open, close)));
}
at.sort();
at.dedup();
if at.windows(2).any(|w| w[1].0 < w[0].1) {
return Err(refused(format!("{}: a call of {name} sits inside another's arguments; inline it in two specs", relative(root, &cf).map(|r| r.display().to_string()).unwrap_or_default())));
}
for (a, b, args) in at {
let mut block = change::render_inlined(¶ms, &args, &body).map_err(|e| refused(format!("{}: {e}", relative(root, &cf).map(|r| r.display().to_string()).unwrap_or_default())))?;
let before = t[..a].trim_end();
let after = t[b..].trim_start();
let starts_statement = before.is_empty() || before.ends_with(['{', ';', '}']) || before.ends_with("=>");
if starts_statement && !after.starts_with([';', '}', ',', ')']) {
block = format!("({block})");
}
p.generated.push(block.clone());
splices.entry(cf.clone()).or_default().push((a, b, block));
calls += 1;
}
}
if !f.is_public {
let lines: Vec<String> = dtext.lines().map(str::to_string).collect();
let lead = crate::gate::leading(&lines, item_span.0.line - 1);
let first = lead.attrs.first().map(|(a, _)| *a).unwrap_or(lead.decl).min(item_span.0.line - 1);
let mut from = first;
while from > 0 && lines[from - 1].trim_start().starts_with("///") {
from -= 1;
}
let (a, mut b) = (line_start(&dtext, from + 1), line_end(&dtext, item_span.1.line));
if dtext[b..].starts_with('\n') && (a == 0 || dtext[..a].ends_with("\n\n")) {
b += 1;
}
splices.entry(decl.clone()).or_default().push((a, b, String::new()));
p.facts.push(format!("absent {item}"));
p.also.push(format!("{item} removed: not pub, and its {calls} calls are inlined"));
} else {
p.also.push(format!("{item} kept: it is pub, and other crates may name it"));
}
for (f, e) in splices {
splice(p.tree.text(&f)?, e);
}
p.facts.push(format!("calls {item} 0"));
}
Wrap => {
let f = spec.function.as_ref().ok_or_else(|| refused("wrap needs function"))?;
k.functions.iter().find(|x| x.fq_name == item).ok_or_else(|| refused(format!("{item}: not a function klepto captured")))?;
if f.name != ty_name(&item) {
return Err(refused(format!("{item}: the spec's function is {}, not this one", f.name)));
}
let t = p.tree.text(&file)?;
let sp = item_at(t, &file, line_of(&land.at))?;
let open = sp.body_open.ok_or_else(|| refused(format!("{item}: no body")))?;
let (a, b) = (offset(t, open), offset(t, sp.end));
if !t[a..b].ends_with('}') {
return Err(refused(format!("{item}: its body does not end at the item's end")));
}
let lines: Vec<String> = t.lines().map(str::to_string).collect();
let lead = crate::gate::leading(&lines, sp.start.line - 1);
let indent = indent_of(t, lead.decl + 1);
let old = t[a..b].to_string();
let old = if spans_lines_in_a_string(&old) { old } else { old.lines().enumerate().map(|(i, l)| if i == 0 || l.trim().is_empty() { l.to_string() } else { format!(" {l}") }).collect::<Vec<_>>().join("\n") };
let body = change::render_wrapped(f, &indent, &old).map_err(refused)?;
t.replace_range(a..b, &body);
let first = body.lines().nth(1).unwrap_or("").trim().to_string();
p.generated.push(body);
if !first.is_empty() {
p.facts.push(format!("text {} {first}", land.file));
}
p.facts.push(format!("exists {item}"));
}
ChangeDependency => {
for c in change::dependency_changes(k, root, spec).map_err(refused)? {
let path = abs(root, &c.manifest);
for d in spec.dependencies_named().into_iter().filter(|d| d.name.replace('-', "_") == c.name) {
let (after, n) = crate::commands::edit::dep_change_text_with(p.tree.text(&path)?, &d.name, d.version.as_deref(), d.path.as_deref(), &d.features, d.optional)?;
if n == 0 {
return Err(refused(format!("{}: names no {} to change", c.manifest, d.name)));
}
*p.tree.text(&path)? = after;
let manifest = relative(root, &path).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_else(|_| c.manifest.clone());
if d.version.is_some() || d.path.is_some() {
p.facts.push(format!("source {manifest} {} {} {}", d.name, d.version.as_deref().unwrap_or("-"), d.path.as_deref().unwrap_or("-")));
}
p.facts.push(format!("dependency {} {}", c.package, c.name));
if let Some(o) = d.optional {
p.facts.push(format!("optional {} {} {o}", c.package, c.name));
}
}
}
}
DeclareFeature => {
let d = change::feature_declaration(k, root, spec).map_err(refused)?;
if !d.problems.is_empty() {
return Err(refused(d.problems.join("; ")));
}
let path = abs(root, &land.file);
let after = crate::commands::edit::feature_declare_text(p.tree.text(&path)?, &d.feature, &d.enables, &d.made_optional)?;
*p.tree.text(&path)? = after;
p.facts.push(format!("feature {} {} {}", d.package, d.feature, d.enables.join(",")));
for n in &d.made_optional {
p.facts.push(format!("optional {} {n} true", d.package));
}
}
ChangeSignature => {
let f = spec.function.as_ref().ok_or_else(|| refused("change_signature needs function"))?;
let old = k.functions.iter().find(|x| x.fq_name == item).ok_or_else(|| refused(format!("{item}: not a function klepto captured")))?;
let mut splices: BTreeMap<PathBuf, Vec<Splice>> = BTreeMap::new();
let t = p.tree.text(&file)?.clone();
let sp = item_at(&t, &file, line_of(&land.at))?;
let open = sp.body_open.ok_or_else(|| refused(format!("{item}: no body")))?;
let lines: Vec<String> = t.lines().map(str::to_string).collect();
let lead = crate::gate::leading(&lines, sp.start.line - 1);
let (a, b) = (line_start(&t, lead.decl + 1) + indent_of(&t, lead.decl + 1).len(), offset(&t, open));
let mut sig = f.clone();
sig.vis = sig.vis.or_else(|| old.is_public.then(|| "pub".to_string()));
let new_sig = change::signature(&sig);
splices.entry(canon(&file)).or_default().push((a, b, format!("{new_sig} ")));
p.generated.push(new_sig.clone());
if !f.body.is_empty() {
let body = change::render_body(f, &indent_of(&t, lead.decl + 1)).map_err(refused)?;
let end = offset(&t, sp.end);
if !t[offset(&t, open)..end].ends_with('}') {
return Err(refused(format!("{item}: its body does not end at the item's end")));
}
splices.entry(canon(&file)).or_default().push((offset(&t, open), end, body.clone()));
if let Some(first) = body.lines().nth(1).map(str::trim).filter(|l| !l.is_empty()) {
p.facts.push(format!("text {} {first}", land.file));
}
p.generated.push(body);
}
let added = f.params.len() > old.args.iter().filter(|a| !change::is_receiver(a)).count();
let name = f.name.clone();
if added {
let old_name = ty_name(&item).to_string();
let mut by_file: BTreeMap<PathBuf, Vec<(u32, Option<u32>)>> = BTreeMap::new();
for c in &k.calls {
if matches!(&c.resolution, Some(klepto::Resolution::Local(id) | klepto::Resolution::External(id)) if *id == item)
&& let Some(l) = c.location.line {
by_file.entry(canon(&c.location.path)).or_default().push((l, c.location.column));
}
}
for (cf, places) in by_file {
let t = p.tree.text(&cf)?.clone();
let parsed = parse(&t, &cf)?;
let mut v = Calls::new(places.iter().flat_map(|(l, c)| both(*l, *c)).collect(), &old_name);
v.visit_file(&parsed);
let mut closes: Vec<(usize, bool)> = v.found.iter().map(|(close, has)| (offset(&t, *close), *has)).collect();
for (l, c) in places.iter().filter(|(l, c)| !both(*l, *c).iter().any(|k| v.met.contains(k))) {
match call_in_text(&t, *l, *c, &old_name) {
Some((open, close)) => closes.push((close, !t[open + 1..close].trim().is_empty())),
None => p.listed.push(format!("call {}:{l}: klepto names it, and no call of {old_name} is there to give the new argument", relative(root, &cf).map(|r| r.display().to_string()).unwrap_or_default())),
}
}
closes.sort();
closes.dedup();
for (at, has_args) in closes {
match spec.call_sites.as_ref().and_then(|c| c.pass.clone()) {
Some(pass) => {
splices.entry(cf.clone()).or_default().push((at, at, if has_args { format!(", {pass}") } else { pass.clone() }));
}
None => p.listed.push(format!("call {}:{}: needs the new argument", relative(root, &cf).map(|r| r.display().to_string()).unwrap_or_default(), t[..at].matches('\n').count() + 1)),
}
}
}
}
for (f, e) in splices {
splice(p.tree.text(&f)?, e);
}
let new_id = format!("{}::{name}", module_path_of(&item));
p.facts.push(format!("args {new_id} {}", f.params.len() + usize::from(f.receiver.is_some() || old.args.iter().any(|a| change::is_receiver(a)))));
if name != ty_name(&item) {
p.facts.push(format!("absent {item}"));
}
}
Rename => {
let new = spec.new_name.clone().unwrap_or_default();
let old = ty_name(&item).to_string();
let mut at: BTreeMap<PathBuf, BTreeSet<usize>> = BTreeMap::new();
let d = change::item_named(k, &item).ok_or_else(|| refused(format!("{item}: no such item")))?;
let mut push = |path: &Path, line: Option<u32>| {
if let Some(l) = line {
at.entry(path.to_path_buf()).or_default().insert(l as usize);
}
};
let t0 = std::fs::read_to_string(&d.location.path)?;
let sp = item_at(&t0, &d.location.path, d.location.line.unwrap_or(1))?;
let lines0: Vec<String> = t0.lines().map(str::to_string).collect();
let decl = crate::gate::leading(&lines0, sp.start.line - 1).decl + 1;
push(&d.location.path, Some(decl as u32));
let resolves = |r: &Option<klepto::Resolution>| matches!(r, Some(klepto::Resolution::Local(id) | klepto::Resolution::External(id)) if *id == item || id.starts_with(&format!("{item}::")));
for o in &k.paths {
if resolves(&o.resolution) {
push(&o.location.path, o.location.line);
}
}
for c in &k.calls {
if resolves(&c.resolution) {
push(&c.location.path, c.location.line);
}
}
for i in &k.imports {
if resolves(&i.resolution) {
push(&i.location.path, i.location.line);
}
}
let in_macros = k.macros_inv.iter().filter(|m| m.token_paths.iter().any(|p| p.rsplit("::").next() == Some(old.as_str()))).count();
if in_macros > 0 {
p.listed.push(format!("{in_macros} macro invocations name {old} inside their tokens: klepto resolves no paths there; not renamed"));
}
for (f, lines) in at {
let t = p.tree.text(&f)?;
let mut out = String::new();
for (i, l) in t.split_inclusive('\n').enumerate() {
if lines.contains(&(i + 1)) {
out.push_str(&rename_word(l, &old, &new));
} else {
out.push_str(l);
}
}
*t = out;
}
p.facts.push(format!("exists {}::{new}", module_path_of(&item)));
p.facts.push(format!("absent {item}"));
}
Move => {
let to = spec.to.clone().unwrap_or_default();
let (_, dest) = change::route_file(k, root, &to).ok_or_else(|| refused(format!("{to}: not a crate:<name>/<file>.rs route")))?;
let dest_module = change::module_of_file(k, &dest).map(|m| m.path.clone()).ok_or_else(|| refused(format!("{}: no module of the scan", dest.display())))?;
let d = change::item_named(k, &item).ok_or_else(|| refused(format!("{item}: no such item")))?;
let t = p.tree.text(&file)?;
let sp = item_at(t, &file, d.location.line.unwrap_or(1))?;
let (a, b) = (line_start(t, sp.start.line), line_end(t, sp.end.line));
let moved = t[a..b].to_string();
let source_text = t.clone();
let vis = if d.is_public { "pub " } else { "pub(crate) " };
let target_path = format!("crate::{}", dest_module.split_once("::").map(|(_, r)| r).unwrap_or(""));
t.replace_range(a..b, &format!("{vis}use {}::{};\n", target_path.trim_end_matches("::"), d.name));
let dest_c = canon(&dest);
let import_lines: BTreeSet<usize> = k
.imports
.iter()
.filter(|i| canon(&i.location.path) == dest_c && matches!(&i.resolution, Some(klepto::Resolution::Local(id) | klepto::Resolution::External(id)) if *id == item))
.filter_map(|i| i.location.line.map(|l| l as usize))
.collect();
if !import_lines.is_empty() {
let dt = p.tree.text(&dest)?;
let dropped = drop_imports(dt, &dest, &import_lines, &d.name)?;
for l in dropped {
p.also.push(format!("import {}:{l}: {} is declared there now, its import dropped", to, d.name));
}
}
let dt = p.tree.text(&dest)?;
let old_module = format!("crate::{}", d.module.split_once("::").map(|(_, r)| r).unwrap_or("")).trim_end_matches("::").to_string();
let glob = format!("use {old_module}::*;");
let declared: BTreeSet<String> = k.modules.get(&dest_module).map(|m| m.items.keys().cloned().collect()).unwrap_or_default();
let carried = imports_carried(&source_text, &file, &d.module, &moved, dt, &dest, &dest_module, &declared)?;
let mut head: Vec<String> = carried.clone();
if !dt.lines().any(|l| l.trim() == glob) {
head.insert(0, glob.clone());
}
if !head.is_empty() {
let parsed = parse(dt, &dest)?;
let at = parsed.items.first().map(|i| line_start(dt, i.span().start().line)).unwrap_or(dt.len());
dt.insert_str(at, &format!("{}\n\n", head.join("\n")));
}
for c in &carried {
p.also.push(format!("{to} gets `{c}`: {} uses the name, imported where it was", d.name));
}
if !dt.ends_with('\n') {
dt.push('\n');
}
dt.push('\n');
dt.push_str(&moved);
p.generated.push(moved);
p.facts.push(format!("exists {dest_module}::{}", d.name));
}
}
if change::uses_toolkit(spec) {
let has = k.features.get(&land.package).is_some_and(|m| m.dependencies.iter().any(|d| d.name == change::TOOLKIT.0));
if !has {
let e = crate::commands::edit::dep_add_plan(root, &land.package, &format!("{}@{}", change::TOOLKIT.0, change::TOOLKIT.1), None, &[], false)?;
for f in e.edits.files {
let before = p.tree.files.get(&f.path).map(|(b, _)| b.clone()).unwrap_or(Some(f.before.clone()));
p.tree.files.insert(f.path.clone(), (before, f.after));
}
p.listed.push(format!("{} gets the dependency {} = \"{}\" (its body is toolkit pieces)", land.package, change::TOOLKIT.0, change::TOOLKIT.1));
}
}
for c in &spec.postconditions {
p.facts.push(c.clone());
}
Ok(p)
}
fn fn_fact(f: &change::FnSpec, item: &str, file: &str, generated: Option<&String>) -> String {
match (&f.generator, generated) {
(Some(_), Some(text)) => text_fact(file, text),
_ => format!("method {item} {}", f.name),
}
}
fn text_fact(file: &str, text: &str) -> String {
let first = text.lines().map(str::trim).find(|l| !l.is_empty() && !l.starts_with("///") && !l.starts_with("#[")).unwrap_or("");
format!("text {file} {first}")
}
fn rename_word(line: &str, old: &str, new: &str) -> String {
let mut out = String::new();
let mut rest = line;
while let Some(i) = rest.find(old) {
let before = rest[..i].chars().last();
let after = rest[i + old.len()..].chars().next();
let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
out.push_str(&rest[..i]);
if !word(before) && !word(after) {
out.push_str(new);
} else {
out.push_str(old);
}
rest = &rest[i + old.len()..];
}
out.push_str(rest);
out
}
#[allow(clippy::type_complexity)]
struct FnText {
name: String,
line: usize,
found: Option<(Vec<(LineColumn, LineColumn, LineColumn, LineColumn)>, (LineColumn, LineColumn), (LineColumn, LineColumn))>,
}
impl FnText {
fn take(&mut self, sig: &syn::Signature, block: &syn::Block, span: proc_macro2::Span) {
if sig.ident != self.name.as_str() || !(span.start().line..=span.end().line).contains(&self.line) {
return;
}
let params = sig
.inputs
.iter()
.filter_map(|a| match a {
syn::FnArg::Typed(t) => Some((t.pat.span().start(), t.pat.span().end(), t.ty.span().start(), t.ty.span().end())),
syn::FnArg::Receiver(_) => None,
})
.collect();
self.found = Some((params, (block.brace_token.span.open().start(), block.brace_token.span.close().end()), (span.start(), span.end())));
}
}
impl<'ast> Visit<'ast> for FnText {
fn visit_item_fn(&mut self, f: &'ast syn::ItemFn) {
self.take(&f.sig, &f.block, f.span());
syn::visit::visit_item_fn(self, f);
}
fn visit_impl_item_fn(&mut self, f: &'ast syn::ImplItemFn) {
self.take(&f.sig, &f.block, f.span());
syn::visit::visit_impl_item_fn(self, f);
}
}
fn spans_lines_in_a_string(block: &str) -> bool {
struct Lits(bool);
impl<'ast> Visit<'ast> for Lits {
fn visit_lit(&mut self, l: &'ast syn::Lit) {
if l.span().start().line != l.span().end().line {
self.0 = true;
}
}
fn visit_macro(&mut self, m: &'ast syn::Macro) {
if m.tokens.clone().into_iter().any(|t| t.span().start().line != t.span().end().line) {
self.0 = true;
}
}
}
match syn::parse_str::<syn::Block>(block) {
Ok(b) => {
let mut v = Lits(false);
v.visit_block(&b);
v.0
}
Err(_) => true,
}
}
fn use_leaves(t: &syn::UseTree, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, Option<String>)>) {
match t {
syn::UseTree::Path(p) => {
prefix.push(p.ident.to_string());
use_leaves(&p.tree, prefix, out);
prefix.pop();
}
syn::UseTree::Name(n) if n.ident == "self" => out.push((prefix.clone(), prefix.last().cloned())),
syn::UseTree::Name(n) => out.push(([prefix.clone(), vec![n.ident.to_string()]].concat(), Some(n.ident.to_string()))),
syn::UseTree::Rename(r) => {
let path = if r.ident == "self" { prefix.clone() } else { [prefix.clone(), vec![r.ident.to_string()]].concat() };
out.push((path, Some(r.rename.to_string())))
}
syn::UseTree::Glob(_) => out.push((prefix.clone(), None)),
syn::UseTree::Group(g) => g.items.iter().for_each(|i| use_leaves(i, prefix, out)),
}
}
#[allow(clippy::too_many_arguments)]
fn imports_carried(source: &str, source_path: &Path, module: &str, moved: &str, dest: &str, dest_path: &Path, dest_module: &str, declared: &BTreeSet<String>) -> Result<Vec<String>> {
let src = parse(source, source_path)?;
let dst = parse(dest, dest_path)?;
let mut taken: BTreeSet<String> = declared.clone();
let dest_abs: Vec<String> = std::iter::once("crate".to_string()).chain(dest_module.split("::").skip(1).map(str::to_string)).collect();
for i in &dst.items {
let declared = match i {
syn::Item::Use(u) => {
let mut leaves = Vec::new();
use_leaves(&u.tree, &mut Vec::new(), &mut leaves);
taken.extend(leaves.into_iter().filter_map(|(_, n)| n));
None
}
syn::Item::Struct(x) => Some(&x.ident),
syn::Item::Enum(x) => Some(&x.ident),
syn::Item::Fn(x) => Some(&x.sig.ident),
syn::Item::Trait(x) => Some(&x.ident),
syn::Item::Type(x) => Some(&x.ident),
syn::Item::Const(x) => Some(&x.ident),
syn::Item::Static(x) => Some(&x.ident),
syn::Item::Mod(x) => Some(&x.ident),
_ => None,
};
taken.extend(declared.map(|i| i.to_string()));
}
let here: Vec<String> = module.split("::").skip(1).map(str::to_string).collect();
let word = |name: &str| {
let mut rest = moved;
while let Some(i) = rest.find(name) {
let w = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
if !w(rest[..i].chars().last()) && !w(rest[i + name.len()..].chars().next()) {
return true;
}
rest = &rest[i + name.len()..];
}
false
};
let mut out = Vec::new();
for i in &src.items {
let syn::Item::Use(u) = i else { continue };
if !matches!(u.vis, syn::Visibility::Inherited) {
continue;
}
let mut leaves = Vec::new();
use_leaves(&u.tree, &mut Vec::new(), &mut leaves);
for (path, name) in leaves {
let Some(name) = name else { continue };
if taken.contains(&name) || !word(&name) {
continue;
}
let abs: Vec<String> = match path.first().map(String::as_str) {
Some("self") => [vec!["crate".to_string()], here.clone(), path[1..].to_vec()].concat(),
Some("super") => {
let ups = path.iter().take_while(|s| *s == "super").count();
[vec!["crate".to_string()], here[..here.len().saturating_sub(ups)].to_vec(), path[ups..].to_vec()].concat()
}
_ => path.clone(),
};
if abs.is_empty() || abs[..abs.len() - 1] == dest_abs[..] {
continue;
}
let last = abs.last().cloned().unwrap_or_default();
let line = if last == name { format!("use {};", abs.join("::")) } else { format!("use {} as {name};", abs.join("::")) };
taken.insert(name);
out.push(line);
}
}
Ok(out)
}
fn use_without(t: &syn::UseTree, name: &str) -> Option<String> {
match t {
syn::UseTree::Path(p) => use_without(&p.tree, name).map(|rest| format!("{}::{rest}", p.ident)),
syn::UseTree::Name(n) => (n.ident != name).then(|| n.ident.to_string()),
syn::UseTree::Rename(r) => Some(format!("{} as {}", r.ident, r.rename)),
syn::UseTree::Glob(_) => Some("*".into()),
syn::UseTree::Group(g) => {
let kept: Vec<String> = g.items.iter().filter_map(|i| use_without(i, name)).collect();
(!kept.is_empty()).then(|| format!("{{{}}}", kept.join(", ")))
}
}
}
fn drop_imports(text: &mut String, path: &Path, lines: &BTreeSet<usize>, name: &str) -> Result<Vec<usize>> {
let parsed = parse(text, path)?;
let mut edits: Vec<Splice> = Vec::new();
let mut dropped = Vec::new();
for i in &parsed.items {
let syn::Item::Use(u) = i else { continue };
let (a, b) = (u.span().start().line, u.span().end().line);
if !lines.iter().any(|l| (a..=b).contains(l)) {
continue;
}
match use_without(&u.tree, name) {
Some(kept) => {
let (x, y) = (offset(text, u.tree.span().start()), offset(text, u.tree.span().end()));
if text[x..y].replace(char::is_whitespace, "") != kept.replace(char::is_whitespace, "") {
edits.push((x, y, kept));
dropped.push(a);
}
}
None => {
edits.push((line_start(text, i.span().start().line), line_end(text, b), String::new()));
dropped.push(a);
}
}
}
splice(text, edits);
Ok(dropped)
}
fn checked_packages(k: &klepto::Klepto, package: &str) -> Vec<String> {
let mut out = BTreeSet::from([package.to_string()]);
let mut work = vec![package.to_string()];
while let Some(p) = work.pop() {
for (dependent, deps) in &k.package_deps {
if deps.contains(&p) && out.insert(dependent.clone()) {
work.push(dependent.clone());
}
}
}
out.into_iter().filter(|p| k.features.contains_key(p)).collect()
}
fn holds(k: &klepto::Klepto, root: &Path, fact: &str) -> bool {
let w: Vec<&str> = fact.splitn(3, ' ').collect();
let item = |id: &str| k.items.iter().any(|d| d.id == id);
match w.as_slice() {
["exists", id] | ["resolves", id] => item(id) || k.graph.resolve_id(id).is_some(),
["absent", id] => !item(id),
["field", t, name] => k.type_decl(t).is_some_and(|d| d.fields.iter().any(|f| f.name.as_deref() == Some(*name))),
["variant", t, name] => k.type_decl(t).is_some_and(|d| d.variants.iter().any(|v| v.name == *name)),
["method", t, name] => k.impls_of(t).iter().flat_map(|i| i.methods.iter()).any(|m| m.rsplit("::").next() == Some(*name)) || item(&format!("{t}::{name}")),
["impl", t, tr] => k.impls_of(t).iter().any(|i| i.trait_.as_ref().is_some_and(|x| x.raw.rsplit("::").next().is_some_and(|n| tr.contains(n) || n.contains(tr.trim_start_matches("impl_")))) ),
["derive", t, d] => k.type_decl(t).is_some_and(|x| x.derives.iter().any(|r| matches!(r, klepto::Resolution::Local(p) | klepto::Resolution::External(p) if p.rsplit("::").next() == Some(d.rsplit("::").next().unwrap_or(d))) || matches!(r, klepto::Resolution::Unresolved { raw, .. } if raw.ends_with(*d)))),
["module", m] => k.modules.get(m).is_some(),
["library", p] => k.packages.get(&p.replace('-', "_")).is_some_and(|x| x.targets.iter().any(|t| t.kind == "lib")),
["dependency", p, d] => k.features.get(&p.replace('-', "_")).or_else(|| k.features.get(*p)).is_some_and(|m| m.dependencies.iter().any(|x| x.name.replace('-', "_") == d.replace('-', "_"))),
["feature", p, rest] => {
let (name, list) = rest.split_once(' ').unwrap_or((rest, ""));
let want: Vec<String> = list.split(',').filter(|x| !x.is_empty()).map(|x| x.replace('-', "_")).collect();
k.features.get(&p.replace('-', "_")).and_then(|m| m.features.get(name)).is_some_and(|i| i.enables.iter().map(|e| enable_text(e).replace('-', "_")).collect::<Vec<_>>() == want)
}
["optional", p, rest] => {
let (d, o) = rest.split_once(' ').unwrap_or((rest, "true"));
k.features.get(&p.replace('-', "_")).is_some_and(|m| m.dependencies.iter().any(|x| x.name.replace('-', "_") == d.replace('-', "_") && x.optional.to_string() == o))
}
["calls", id, n] => k.calls.iter().filter(|c| matches!(&c.resolution, Some(klepto::Resolution::Local(x) | klepto::Resolution::External(x)) if x == id)).count().to_string() == *n,
["args", id, n] => k.functions.iter().find(|f| f.fq_name == *id).is_some_and(|f| f.args.len().to_string() == *n),
["text", f, line] => std::fs::read_to_string(root.join(f)).is_ok_and(|t| t.lines().any(|l| l.trim() == line.trim())),
["source", f, rest] => {
let w: Vec<&str> = rest.split(' ').collect();
let want = |x: &str| (x != "-").then(|| x.to_string());
match w.as_slice() {
[name, version, path] => std::fs::read_to_string(root.join(f)).ok().and_then(|t| crate::commands::edit::dep_source(&t, name)) == Some((want(version), want(path))),
_ => false,
}
}
_ => false,
}
}
fn desired_model(model: &klepto::FeatureModel, spec: &ChangeSpec, k: &klepto::Klepto, root: &Path, pkg: &str) -> klepto::FeatureModel {
let mut m = model.clone();
let mut asked = klepto::PackageSpec::default();
match spec.op() {
Operation::DeclareFeature => {
if let Some(d) = change::feature_declaration(k, root, spec).ok().filter(|d| d.package == pkg) {
asked.features.insert(d.feature.clone(), d.enables.clone());
asked.optional.extend(d.made_optional.iter().cloned());
}
}
Operation::AddDependency | Operation::ChangeDependency => {
for d in spec.dependencies_named() {
if let Some(o) = d.optional {
for x in m.dependencies.iter_mut().filter(|x| x.name == d.name.replace('-', "_")) {
x.optional = o;
}
}
}
}
_ => return m,
}
m = m.apply_spec(&asked);
m
}
fn enable_text(e: &klepto::FeatureEnable) -> String {
match e {
klepto::FeatureEnable::Feature(f) => f.clone(),
klepto::FeatureEnable::Dep(d) => format!("dep:{d}"),
klepto::FeatureEnable::DepFeature { dep, feature, weak } => format!("{dep}{}/{feature}", if *weak { "?" } else { "" }),
}
}
fn hex(bytes: &[u8]) -> String {
stable_digest::StableDigest::sha256(bytes).as_str().to_string()
}
fn written_lines(before: &str, after: &str) -> Vec<(u32, u32)> {
let d = similar::TextDiff::from_lines(before, after);
let mut out = Vec::new();
for op in d.ops() {
let r = op.new_range();
if !matches!(op.tag(), similar::DiffTag::Equal) && !r.is_empty() {
out.push((r.start as u32 + 1, r.end as u32));
}
}
out
}
fn record_path(root: &Path, opts: &Options, spec: &ChangeSpec) -> Option<PathBuf> {
if let Some(o) = &opts.out {
return Some(o.clone());
}
let store = custodian_kit::store::Store::find(root)?;
store.path(&custodian_kit::store::named::TRANSFORM, &[("name", &spec.name), ("stamp", &custodian_kit::runs::stamp())]).ok()
}
pub fn run(root: &Path, opts: &Options) -> Result<Outcome> {
let root = workspace_root(root)?;
let spec_path = if opts.spec.is_absolute() { opts.spec.clone() } else { root.join(&opts.spec) };
let spec = ChangeSpec::from_path(&spec_path).map_err(refused)?;
let k = crate::cargo::builder(&root)?.ignore_parse_errors(true).parse().map_err(|e| PassengerError::Scan { detail: e.to_string() })?;
let land = change::landing(&k, &root, &spec).map_err(refused)?;
let mut said = vec![format!("{} {}: lands {}", spec.op().as_str(), spec.name, land.line())];
let planned = plan(&k, &root, &spec, &land)?;
let mut edits = planned.tree.edits();
if edits.is_empty() {
return Err(refused(format!("{}: nothing to write", spec.name)));
}
let mut checks = Vec::new();
let mut oracle = crate::oracle::CargoOracle::new(&root);
oracle.tests = true;
let scratch_root: PathBuf;
let _held;
if opts.no_cargo {
let dir = crate::gate::scratch_copy(&root)?;
edits.write_to(&root, dir.path())?;
scratch_root = dir.path().to_path_buf();
_held = Some(dir);
said.push("cargo not run (--no-cargo)".into());
} else {
let landed: Vec<String> = if spec.op() == Operation::ChangeDependency {
change::dependency_changes(&k, &root, &spec).map_err(refused)?.into_iter().map(|c| c.package).collect::<BTreeSet<_>>().into_iter().collect()
} else {
vec![land.package.clone()]
};
let packages: BTreeSet<String> = landed.iter().flat_map(|p| checked_packages(&k, p)).collect();
said.push(format!("cargo check over {} packages (the ones it lands in and every one that depends on them)", packages.len()));
for pkg in packages {
let Some(model) = k.features.get(&pkg) else { continue };
let desired = desired_model(model, &spec, &k, &root, &pkg);
for job in klepto::check_matrix(&desired) {
let r = oracle.check_edits(&job, &edits);
let error = r.diagnostics.iter().find(|d| d.level == "error").map(|d| d.message.clone());
checks.push(Checked { package: pkg.clone(), features: job.features.clone(), no_default: job.no_default, ok: r.ok, error });
if !r.ok {
break;
}
}
if checks.iter().any(|c| !c.ok) {
break;
}
}
match oracle.scratch_dir() {
Some(d) => {
let ws = oracle.workspace().map(Path::to_path_buf).unwrap_or_else(|| root.clone());
scratch_root = d.join(relative(&ws, &root).unwrap_or_default());
_held = None;
if matches!(spec.op(), Operation::AddDependency | Operation::ChangeDependency | Operation::DeclareFeature)
&& let Ok(after) = std::fs::read_to_string(d.join("Cargo.lock"))
{
let lock = ws.join("Cargo.lock");
let before = std::fs::read_to_string(&lock).unwrap_or_default();
if after != before && !edits.files.iter().any(|f| f.path == lock) {
edits.files.push(crate::gate::FileEdit { path: lock, before, after });
}
}
}
None => {
let dir = crate::gate::scratch_copy(&root)?;
edits.write_to(&root, dir.path())?;
scratch_root = dir.path().to_path_buf();
_held = Some(dir);
said.push("no package was checked: the change is not proved".into());
}
}
}
let after_scan = crate::cargo::builder(&scratch_root)?.ignore_parse_errors(true).parse().map_err(|e| PassengerError::Scan { detail: e.to_string() })?;
let postconditions: Vec<(String, bool)> = planned.facts.iter().map(|f| (f.clone(), holds(&after_scan, &scratch_root, f))).collect();
let checks_ok = checks.iter().all(|c| c.ok);
let posts_ok = postconditions.iter().all(|(_, h)| *h);
said.push(format!("cargo check: {} of {} configurations compile; postconditions: {} of {} hold", checks.iter().filter(|c| c.ok).count(), checks.len(), postconditions.iter().filter(|(_, h)| *h).count(), postconditions.len()));
for c in checks.iter().filter(|c| !c.ok) {
said.push(format!("does not compile: {} {}{}: {}", c.package, if c.no_default { "--no-default-features " } else { "" }, c.features.join(","), c.error.clone().unwrap_or_default()));
}
for (f, h) in &postconditions {
if !h {
said.push(format!("postcondition does not hold: {f}"));
}
}
let proved = checks_ok && posts_ok && (if opts.no_cargo { opts.unproven } else { !checks.is_empty() });
let mut files: Vec<FileWrite> = edits
.files
.iter()
.filter(|f| f.before != f.after || !f.path.exists())
.map(|f| {
let created = match planned.tree.files.get(&f.path) {
Some((b, _)) => b.is_none(),
None => !f.path.exists(),
};
FileWrite {
path: relative(&root, &f.path).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default(),
before: if created { "none".into() } else { hex(f.before.as_bytes()) },
after: hex(f.after.as_bytes()),
lines: written_lines(if created { "" } else { &f.before }, &f.after),
}
})
.collect();
files.sort_by(|a, b| a.path.cmp(&b.path));
let mut record = TransformRecord {
schema: TRANSFORM_SCHEMA.into(),
spec_name: spec.name.clone(),
spec: relative(&root, &spec_path).map(|r| r.display().to_string()).unwrap_or_else(|_| spec_path.display().to_string()),
spec_digest: spec.digest(),
operation: spec.op().as_str().into(),
target: spec.target.clone(),
preflight_seq: opts.preflight,
code_digest: custodian_kit::code_digest(&root, &custodian_kit::reading::spec::Scope { tests: true, ..Default::default() }.scan_options()).ok(),
files,
checkpoint_before: None,
checkpoint_after: None,
checks,
postconditions,
listed: planned.listed.clone(),
generated: planned.generated.clone(),
written: false,
reverts: None,
};
for l in &planned.also {
said.push(format!("also: {l}"));
}
for l in &planned.listed {
said.push(format!("listed, not edited: {l}"));
}
let mut exit_code = if proved { 0 } else { 1 };
if opts.write {
if !proved {
said.push("nothing written: the change is not proved".into());
} else {
let store = crate::passenger::PassengerStore::open_or_init(&root)?;
let before = store.checkpoint_tree(&format!("before transform {}", spec.name), None)?;
edits.write()?;
let after = store.checkpoint_tree(&format!("transform {} ({})", spec.name, spec.op().as_str()), None)?;
record.checkpoint_before = Some(before.id.clone());
record.checkpoint_after = Some(after.id.clone());
record.written = true;
said.push(format!("written: {} files; passenger {} → {}", record.files.len(), before.id, after.id));
}
} else {
exit_code = if proved { 0 } else { 1 };
said.push(format!("proved on a scratch copy, not written (--write writes): {} files", record.files.len()));
}
let record_file = if record.written || opts.out.is_some() { record_path(&root, opts, &spec) } else { None };
if let Some(p) = &record_file {
if let Some(d) = p.parent() {
std::fs::create_dir_all(d)?;
}
std::fs::write(p, serde_json::to_string_pretty(&record)? + "\n")?;
}
Ok(Outcome { root, record, record_file, edits, exit_code, said })
}
pub fn revert(root: &Path, record_file: &Path) -> Result<Outcome> {
let root = workspace_root(root)?;
let text = std::fs::read_to_string(record_file).map_err(|e| PassengerError::Io { detail: format!("{}: {e}", record_file.display()) })?;
let rec: TransformRecord = serde_json::from_str(&text)?;
if !rec.written {
return Err(refused(format!("{}: it wrote nothing", record_file.display())));
}
let store = crate::passenger::PassengerStore::open(&root)?;
let mut files = Vec::new();
let mut edits = EditSet::default();
for f in &rec.files {
let p = root.join(&f.path);
let now = std::fs::read(&p).ok();
if now.as_deref().map(hex) != Some(f.after.clone()) {
return Err(PassengerError::Dirty { detail: format!("{} changed since the transform wrote it", f.path) });
}
let back = if f.before == "none" { None } else { Some(String::from_utf8(store.object(&f.before)?).map_err(refused)?) };
let now_text = String::from_utf8(now.unwrap_or_default()).unwrap_or_default();
files.push(FileWrite { path: f.path.clone(), before: f.after.clone(), after: back.as_deref().map(|b| hex(b.as_bytes())).unwrap_or_else(|| "none".into()), lines: back.as_deref().map(|b| written_lines(&now_text, b)).unwrap_or_default() });
match back {
Some(b) => edits.files.push(FileEdit { path: p, before: now_text, after: b }),
None => {
std::fs::remove_file(&p)?;
}
}
}
let before = store.checkpoint_tree(&format!("before reverting {}", rec.spec_name), None)?;
edits.write()?;
let after = store.checkpoint_tree(&format!("reverted {}", rec.spec_name), None)?;
let out = TransformRecord { files, checkpoint_before: Some(before.id.clone()), checkpoint_after: Some(after.id.clone()), written: true, reverts: Some(relative(&root, record_file).map(|r| r.display().to_string()).unwrap_or_default()), checks: Vec::new(), postconditions: Vec::new(), listed: Vec::new(), generated: Vec::new(), ..rec.clone() };
let file = record_file.with_file_name(format!("{}.revert.json", record_file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()));
std::fs::write(&file, serde_json::to_string_pretty(&out)? + "\n")?;
let said = vec![format!("reverted {}: {} files back as they were; passenger {} → {}", rec.spec_name, out.files.len(), before.id, after.id)];
Ok(Outcome { root, record: out, record_file: Some(file), edits, exit_code: 0, said })
}
pub fn render(o: &Outcome) -> String {
let mut s = String::new();
for l in &o.said {
s.push_str(&format!("{l}\n"));
}
s.push_str(&o.edits.diff_from(&o.root));
if let Some(f) = &o.record_file {
s.push_str(&format!("record: {}\n", f.display()));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_name_is_replaced_only_where_it_is_a_whole_word() {
assert_eq!(rename_word("let c = Config::new(Configs, config);", "Config", "Settings"), "let c = Settings::new(Configs, config);");
assert_eq!(written_lines("a\nb\n", "a\nx\nb\n"), [(2, 2)]);
let t = "struct A {\n x: u8\n}\n".to_string();
let mut t2 = t.clone();
insert_before_close(&mut t2, LineColumn { line: 3, column: 0 }, " y: u16,\n", true);
assert_eq!(t2, "struct A {\n x: u8,\n y: u16,\n}\n");
let mut one = "struct B { x: u8 }\n".to_string();
insert_before_close(&mut one, LineColumn { line: 1, column: 17 }, " y: u16,\n", true);
assert_eq!(one, "struct B { x: u8, y: u16 }\n");
}
}