use proc_macro2::{Delimiter, Group, TokenStream, TokenTree};
use std::collections::{BTreeMap, BTreeSet};
use super::migrations::{AppMigration, CallForm, ReceiverShape, Rewrite};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManualReason {
Macro,
Attribute,
UnexpectedReceiver,
NotACall,
UnverifiedReceiver,
}
impl ManualReason {
pub const fn describe(self) -> &'static str {
match self {
Self::Macro => "inside a macro invocation",
Self::Attribute => "inside an attribute",
Self::NotACall => "referenced without being called",
Self::UnexpectedReceiver => "receiver is not a generated repository",
Self::UnverifiedReceiver => {
"no `#[repository]` trait in this app generates this receiver"
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Site {
pub line: usize,
pub column: usize,
pub migration: &'static str,
pub manual: Option<ManualReason>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SourceRewrite {
pub updated: Option<String>,
pub rewritten: Vec<Site>,
pub manual: Vec<Site>,
}
const NON_MACRO_KEYWORDS: &[&str] = &[
"return", "break", "if", "while", "match", "else", "in", "yield", "loop", "await", "move",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Context {
Code,
Macro,
Attribute,
}
impl Context {
const fn manual_reason(self) -> Option<ManualReason> {
match self {
Self::Code => None,
Self::Macro => Some(ManualReason::Macro),
Self::Attribute => Some(ManualReason::Attribute),
}
}
}
#[derive(Debug, Clone)]
struct Hit {
range: std::ops::Range<usize>,
site: Site,
expected: String,
replacement: String,
}
struct Rename {
id: &'static str,
from: &'static str,
to: &'static str,
form: CallForm,
args: usize,
receiver: Option<ReceiverShape>,
}
pub fn rewrite_source(
source: &str,
migrations: &[&'static AppMigration],
generated: &GeneratedRepositories,
) -> Result<SourceRewrite, String> {
let renames: Vec<Rename> = migrations
.iter()
.filter_map(|migration| match migration.rewrite {
Rewrite::CallRename {
from,
to,
form,
args,
receiver,
} => Some(Rename {
id: migration.id,
from,
to,
form,
args,
receiver,
}),
Rewrite::GuideOnly => None,
})
.collect();
if renames.is_empty() {
return Ok(SourceRewrite::default());
}
syn::parse_file(source).map_err(|error| error.to_string())?;
let stream: TokenStream = source
.parse()
.map_err(|error: proc_macro2::LexError| error.to_string())?;
let mut hits = Vec::new();
scan(&stream, Context::Code, &renames, generated, &mut hits);
hits.sort_by_key(|hit| hit.range.start);
let mut result = SourceRewrite::default();
let mut updated = String::new();
let mut cursor = 0;
for hit in hits {
if hit.site.manual.is_some() {
result.manual.push(hit.site);
continue;
}
let matched = source.get(hit.range.clone()).ok_or_else(|| {
format!(
"internal error: span {}..{} is not a character range of this file",
hit.range.start, hit.range.end
)
})?;
if matched != hit.expected {
return Err(format!(
"internal error: expected `{}` at byte {}, found `{matched}`",
hit.expected, hit.range.start
));
}
if cursor > hit.range.start {
return Err(format!(
"internal error: overlapping rewrite at byte {}",
hit.range.start
));
}
updated.push_str(&source[cursor..hit.range.start]);
updated.push_str(&hit.replacement);
cursor = hit.range.end;
result.rewritten.push(hit.site);
}
if result.rewritten.is_empty() {
return Ok(result);
}
updated.push_str(&source[cursor..]);
if let Err(error) = updated.parse::<TokenStream>() {
return Err(format!(
"internal error: the rewritten file no longer parses ({error}); left untouched"
));
}
result.updated = Some(updated);
Ok(result)
}
pub fn rewrite_source_for_releases(
source: &str,
migrations: &[&'static AppMigration],
generated: &GeneratedRepositories,
) -> Result<SourceRewrite, String> {
let mut current = source.to_owned();
let mut combined = SourceRewrite::default();
let mut rewritten_anything = false;
for release in releases(migrations) {
let pass = rewrite_source(¤t, &release, generated)?;
combined.rewritten.extend(pass.rewritten);
combined.manual.extend(pass.manual);
if let Some(updated) = pass.updated {
current = updated;
rewritten_anything = true;
}
}
combined
.rewritten
.sort_by_key(|site| (site.line, site.column));
combined.manual.sort_by_key(|site| (site.line, site.column));
if rewritten_anything {
combined.updated = Some(current);
}
Ok(combined)
}
fn releases(migrations: &[&'static AppMigration]) -> Vec<Vec<&'static AppMigration>> {
let mut groups: Vec<Vec<&'static AppMigration>> = Vec::new();
for migration in migrations {
match groups.last_mut() {
Some(group) if group[0].version == migration.version => group.push(migration),
_ => groups.push(vec![migration]),
}
}
groups
}
fn scan(
stream: &TokenStream,
context: Context,
renames: &[Rename],
generated: &GeneratedRepositories,
hits: &mut Vec<Hit>,
) {
let trees: Vec<TokenTree> = stream.clone().into_iter().collect();
for (index, tree) in trees.iter().enumerate() {
match tree {
TokenTree::Group(group) => {
scan(
&group.stream(),
group_context(&trees, index, context),
renames,
generated,
hits,
);
}
TokenTree::Ident(ident) => {
let Some(separator) = receiver_separator(&trees, index) else {
continue;
};
let name = ident.to_string();
let (prefix, bare) = name
.strip_prefix("r#")
.map_or(("", name.as_str()), |bare| ("r#", bare));
let Some(rename) = renames
.iter()
.find(|rename| rename.from == bare && rename.form == separator)
else {
continue;
};
let manual = context
.manual_reason()
.or_else(|| {
(!takes_arguments(&trees, index, rename.args))
.then_some(ManualReason::NotACall)
})
.or_else(|| receiver_verdict(&trees, index, rename.receiver, generated));
let span = ident.span();
let start = span.start();
hits.push(Hit {
range: span.byte_range(),
site: Site {
line: start.line,
column: start.column + 1,
migration: rename.id,
manual,
},
expected: name.clone(),
replacement: format!("{prefix}{}", rename.to),
});
}
TokenTree::Punct(_) | TokenTree::Literal(_) => {}
}
}
}
fn group_context(trees: &[TokenTree], index: usize, outer: Context) -> Context {
if outer != Context::Code {
return outer;
}
let TokenTree::Group(group) = &trees[index] else {
return outer;
};
if group.delimiter() == Delimiter::Bracket {
let hash_at = previous_punct(trees, index, '!').map_or_else(
|| previous_punct(trees, index, '#'),
|bang| previous_punct(trees, bang, '#'),
);
if hash_at.is_some() {
return Context::Attribute;
}
}
if let Some(bang) = previous_punct(trees, index, '!')
&& is_macro_name(trees, bang)
{
return Context::Macro;
}
if is_macro_definition_body(trees, index) {
return Context::Macro;
}
outer
}
fn is_macro_definition_body(trees: &[TokenTree], index: usize) -> bool {
let ident_at = |at: usize, want: &str| matches!(trees.get(at), Some(TokenTree::Ident(ident)) if ident == want);
let is_ident = |at: usize| matches!(trees.get(at), Some(TokenTree::Ident(_)));
if index >= 3
&& ident_at(index - 3, "macro_rules")
&& matches!(&trees[index - 2], TokenTree::Punct(punct) if punct.as_char() == '!')
&& is_ident(index - 1)
{
return true;
}
if index >= 2 && ident_at(index - 2, "macro") && is_ident(index - 1) {
return true;
}
index >= 3
&& ident_at(index - 3, "macro")
&& is_ident(index - 2)
&& matches!(&trees[index - 1], TokenTree::Group(group)
if group.delimiter() == Delimiter::Parenthesis)
}
fn previous_punct(trees: &[TokenTree], index: usize, ch: char) -> Option<usize> {
let before = index.checked_sub(1)?;
match &trees[before] {
TokenTree::Punct(punct) if punct.as_char() == ch => Some(before),
_ => None,
}
}
fn is_macro_name(trees: &[TokenTree], bang: usize) -> bool {
let Some(before) = bang.checked_sub(1) else {
return false;
};
match &trees[before] {
TokenTree::Punct(punct) => punct.as_char() == ':',
TokenTree::Ident(ident) => !NON_MACRO_KEYWORDS.contains(&ident.to_string().as_str()),
TokenTree::Group(_) | TokenTree::Literal(_) => false,
}
}
fn takes_arguments(trees: &[TokenTree], index: usize, want: usize) -> bool {
let arguments = match trees.get(index + 1) {
Some(TokenTree::Group(group)) if group.delimiter() == Delimiter::Parenthesis => group,
Some(TokenTree::Punct(punct)) if punct.as_char() == ':' => {
if !matches!(trees.get(index + 2), Some(TokenTree::Punct(second)) if second.as_char() == ':')
|| !matches!(trees.get(index + 3), Some(TokenTree::Punct(angle)) if angle.as_char() == '<')
{
return false;
}
match turbofish_argument_list(trees, index + 3) {
Some(group) => group,
None => return false,
}
}
_ => return false,
};
count_arguments(arguments) == want
}
fn count_arguments(group: &proc_macro2::Group) -> usize {
let mut arguments = 0;
let mut in_argument = false;
let mut generics = 0usize;
let mut previous_colons = 0usize;
for tree in group.stream() {
match &tree {
TokenTree::Punct(punct) if punct.as_char() == ',' && generics == 0 => {
arguments += 1;
in_argument = false;
previous_colons = 0;
}
TokenTree::Punct(punct) => {
in_argument = true;
match punct.as_char() {
':' => previous_colons += 1,
'<' if previous_colons >= 2 => {
generics += 1;
previous_colons = 0;
}
'>' if generics > 0 => {
generics -= 1;
previous_colons = 0;
}
_ => previous_colons = 0,
}
}
_ => {
in_argument = true;
previous_colons = 0;
}
}
}
arguments + usize::from(in_argument)
}
fn turbofish_argument_list(trees: &[TokenTree], angle: usize) -> Option<&proc_macro2::Group> {
let mut depth = 0usize;
for at in angle..trees.len() {
let TokenTree::Punct(punct) = &trees[at] else {
continue;
};
match punct.as_char() {
'<' => depth += 1,
'>' if !matches!(trees.get(at.wrapping_sub(1)),
Some(TokenTree::Punct(dash)) if dash.as_char() == '-') =>
{
depth -= 1;
if depth == 0 {
return match trees.get(at + 1) {
Some(TokenTree::Group(group))
if group.delimiter() == Delimiter::Parenthesis =>
{
Some(group)
}
_ => None,
};
}
}
_ => {}
}
}
None
}
#[must_use]
pub fn generated_repository_types(source: &str) -> Vec<(String, Vec<String>)> {
if syn::parse_file(source).is_err() {
return Vec::new();
}
let Ok(stream) = source.parse::<TokenStream>() else {
return Vec::new();
};
let mut found = Vec::new();
collect_repository_types(&stream, Context::Code, &[], Scope::Module, &mut found);
found
}
#[must_use]
pub fn defined_type_names(source: &str) -> Vec<(String, Vec<String>)> {
let Ok(stream) = source.parse::<TokenStream>() else {
return Vec::new();
};
let mut found = Vec::new();
collect_defined_types(&stream, Context::Code, &[], &mut found);
found
}
fn collect_defined_types(
stream: &TokenStream,
context: Context,
module: &[String],
found: &mut Vec<(String, Vec<String>)>,
) {
let trees: Vec<TokenTree> = stream.clone().into_iter().collect();
for (index, tree) in trees.iter().enumerate() {
match tree {
TokenTree::Ident(keyword)
if context == Context::Code
&& matches!(keyword.to_string().as_str(), "struct" | "enum" | "union") =>
{
if let Some(TokenTree::Ident(name)) = trees.get(index + 1) {
found.push((name.to_string(), module.to_vec()));
}
}
TokenTree::Ident(keyword) if context == Context::Code && keyword == "type" => {
if let Some(TokenTree::Ident(name)) = trees.get(index + 1) {
found.push((name.to_string(), module.to_vec()));
}
}
TokenTree::Group(group) => {
let nested = module_name_before(&trees, index).map(|name| {
let mut nested = module.to_vec();
nested.push(name);
nested
});
collect_defined_types(
&group.stream(),
group_context(&trees, index, context),
nested.as_deref().unwrap_or(module),
found,
);
}
_ => {}
}
}
}
#[derive(Debug, Default)]
pub struct GeneratedRepositories {
declared: BTreeMap<String, BTreeSet<Vec<String>>>,
handwritten: BTreeMap<String, BTreeSet<Vec<String>>>,
}
impl FromIterator<(String, Vec<String>)> for GeneratedRepositories {
fn from_iter<I: IntoIterator<Item = (String, Vec<String>)>>(iter: I) -> Self {
let mut declared: BTreeMap<String, BTreeSet<Vec<String>>> = BTreeMap::new();
for (name, module) in iter {
declared.entry(name).or_default().insert(module);
}
Self {
declared,
handwritten: BTreeMap::new(),
}
}
}
impl GeneratedRepositories {
pub fn note_handwritten<I: IntoIterator<Item = (String, Vec<String>)>>(&mut self, names: I) {
for (name, module) in names {
self.handwritten.entry(name).or_default().insert(module);
}
}
fn any_path_matches(paths: Option<&BTreeSet<Vec<String>>>, qualifier: &[String]) -> bool {
paths.is_some_and(|paths| {
paths
.iter()
.any(|path| path.len() >= qualifier.len() && path.ends_with(qualifier))
})
}
#[must_use]
pub fn accepts(&self, name: &str, qualifier: &[String]) -> bool {
let Some(declared) = self.declared.get(name) else {
return false;
};
if matches!(
qualifier.first().map(String::as_str),
Some("self" | "super")
) {
return false;
}
let qualifier: Vec<String> = qualifier
.iter()
.skip_while(|segment| matches!(segment.as_str(), "crate" | "$crate"))
.cloned()
.collect();
if qualifier.is_empty() {
return !self.handwritten.contains_key(name);
}
if Self::any_path_matches(self.handwritten.get(name), &qualifier) {
return false;
}
Self::any_path_matches(Some(declared), &qualifier)
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
Module,
Block,
}
fn collect_repository_types(
stream: &TokenStream,
context: Context,
module: &[String],
scope: Scope,
found: &mut Vec<(String, Vec<String>)>,
) {
let trees: Vec<TokenTree> = stream.clone().into_iter().collect();
for (index, tree) in trees.iter().enumerate() {
match tree {
TokenTree::Punct(punct) if punct.as_char() == '#' && context == Context::Code => {
let Some(TokenTree::Group(group)) = trees.get(index + 1) else {
continue;
};
if group.delimiter() != Delimiter::Bracket {
continue;
}
if !is_repository_attribute(&group.stream()) {
continue;
}
if attribute_run_is_cfg_gated(&trees, index) {
continue;
}
if scope == Scope::Module
&& let Some(trait_name) = trait_name_after(&trees, index + 2)
{
found.push((format!("Pg{trait_name}"), module.to_vec()));
}
}
TokenTree::Group(group) => {
let nested = module_name_before(&trees, index).map(|name| {
let mut nested = module.to_vec();
nested.push(name);
nested
});
let inner = nested.as_deref().unwrap_or(module);
let inner_scope = if nested.is_some() && scope == Scope::Module {
Scope::Module
} else {
Scope::Block
};
collect_repository_types(
&group.stream(),
group_context(&trees, index, context),
inner,
inner_scope,
found,
);
}
_ => {}
}
}
}
fn is_repository_attribute(stream: &TokenStream) -> bool {
let segments = attribute_path_segments(stream);
let Some((last, crate_segment)) = segments.split_last() else {
return false;
};
if last != "repository" {
return false;
}
crate_segment
.first()
.is_none_or(|krate| matches!(krate.as_str(), "autumn_web" | "autumn"))
}
fn attribute_path_segments(stream: &TokenStream) -> Vec<String> {
let mut segments = Vec::new();
for tree in stream.clone() {
match tree {
TokenTree::Ident(ident) => segments.push(ident.to_string()),
TokenTree::Punct(punct) if punct.as_char() == ':' => {}
_ => break,
}
}
segments
}
fn attribute_last_segment(stream: &TokenStream) -> Option<String> {
let mut last_segment = None;
for tree in stream.clone() {
match tree {
TokenTree::Ident(ident) => last_segment = Some(ident.to_string()),
TokenTree::Punct(punct) if punct.as_char() == ':' => {}
_ => break,
}
}
last_segment
}
fn is_cfg_attribute(stream: &TokenStream) -> bool {
attribute_last_segment(stream).is_some_and(|segment| segment == "cfg" || segment == "cfg_attr")
}
fn attribute_run_is_cfg_gated(trees: &[TokenTree], index: usize) -> bool {
fn attribute_at(trees: &[TokenTree], at: usize) -> Option<&Group> {
match (trees.get(at), trees.get(at + 1)) {
(Some(TokenTree::Punct(punct)), Some(TokenTree::Group(group)))
if punct.as_char() == '#' && group.delimiter() == Delimiter::Bracket =>
{
Some(group)
}
_ => None,
}
}
let mut before = index;
while before >= 2 {
let Some(group) = attribute_at(trees, before - 2) else {
break;
};
if is_cfg_attribute(&group.stream()) {
return true;
}
before -= 2;
}
let mut after = index + 2;
while let Some(group) = attribute_at(trees, after) {
if is_cfg_attribute(&group.stream()) {
return true;
}
after += 2;
}
false
}
fn module_name_before(trees: &[TokenTree], index: usize) -> Option<String> {
let TokenTree::Group(group) = &trees[index] else {
return None;
};
if group.delimiter() != Delimiter::Brace {
return None;
}
let Some(TokenTree::Ident(name)) = trees.get(index.checked_sub(1)?) else {
return None;
};
let keyword = trees.get(index.checked_sub(2)?);
matches!(keyword, Some(TokenTree::Ident(keyword)) if keyword == "mod").then(|| name.to_string())
}
fn trait_name_after(trees: &[TokenTree], from: usize) -> Option<String> {
let mut index = from;
while let Some(tree) = trees.get(index) {
match tree {
TokenTree::Punct(punct) if punct.as_char() == '#' => {
index += 2;
}
TokenTree::Ident(ident) if ident == "pub" => {
index += 1;
if matches!(trees.get(index), Some(TokenTree::Group(group))
if group.delimiter() == Delimiter::Parenthesis)
{
index += 1;
}
}
TokenTree::Ident(ident) if ident == "unsafe" || ident == "auto" => index += 1,
TokenTree::Ident(ident) if ident == "trait" => {
return match trees.get(index + 1) {
Some(TokenTree::Ident(name)) => Some(name.to_string()),
_ => None,
};
}
_ => return None,
}
}
None
}
fn receiver_verdict(
trees: &[TokenTree],
index: usize,
required: Option<ReceiverShape>,
generated: &GeneratedRepositories,
) -> Option<ManualReason> {
let required = required?;
let Some(at) = index.checked_sub(3) else {
return Some(ManualReason::UnexpectedReceiver);
};
let Some(TokenTree::Ident(ident)) = trees.get(at) else {
return Some(ManualReason::UnexpectedReceiver);
};
let receiver = ident.to_string();
if !required.matches(&receiver) {
return Some(ManualReason::UnexpectedReceiver);
}
if generated.accepts(&receiver, &path_qualifier(trees, at)) {
return None;
}
Some(ManualReason::UnverifiedReceiver)
}
fn path_qualifier(trees: &[TokenTree], at: usize) -> Vec<String> {
let mut segments = Vec::new();
let mut index = at;
while index >= 3 {
let separator = matches!(&trees[index - 1], TokenTree::Punct(punct) if punct.as_char() == ':')
&& matches!(&trees[index - 2], TokenTree::Punct(punct) if punct.as_char() == ':');
if !separator {
break;
}
let Some(TokenTree::Ident(ident)) = trees.get(index - 3) else {
break;
};
segments.push(ident.to_string());
index -= 3;
}
segments.reverse();
segments
}
fn receiver_separator(trees: &[TokenTree], index: usize) -> Option<CallForm> {
let before = index.checked_sub(1)?;
let TokenTree::Punct(punct) = &trees[before] else {
return None;
};
let preceded_by = |ch: char| {
matches!(
before.checked_sub(1).map(|at| &trees[at]),
Some(TokenTree::Punct(first)) if first.as_char() == ch
)
};
match punct.as_char() {
'.' if !preceded_by('.') => Some(CallForm::Method),
':' if preceded_by(':') => Some(CallForm::AssociatedFunction),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::upgrade::migrations::{AppMigration, CallForm, Confidence};
static RENAME: AppMigration = AppMigration {
id: "test-with-pool",
version: "0.6.0",
title: "test rename",
confidence: Confidence::Auto,
guide: "docs/migrations/0.6.0.md#anchor",
rewrite: Rewrite::CallRename {
from: "with_pool",
to: "with_pool_untracked",
form: CallForm::AssociatedFunction,
args: 1,
receiver: Some(ReceiverShape {
prefix: "Pg",
suffix: "Repository",
}),
},
};
fn assumed_generated() -> GeneratedRepositories {
[
"PgPostRepository",
"PgCommentRepository",
"PgAlphaRepository",
"PgBetaRepository",
"PgGammaRepository",
]
.into_iter()
.map(|name| (name.to_owned(), Vec::new()))
.collect()
}
fn generated_names(source: &str) -> Vec<String> {
generated_repository_types(source)
.into_iter()
.map(|(name, _)| name)
.collect()
}
#[test]
fn collects_the_type_each_repository_trait_generates() {
let types = generated_names(
"#[repository]\npub trait PostRepository {}\n\
#[repository(tenant_scoped)]\ntrait CommentRepository {}\n\
#[repository]\n/// docs\npub(crate) trait TagRepository {}\n",
);
assert_eq!(
types,
vec!["PgPostRepository", "PgCommentRepository", "PgTagRepository"]
);
}
#[test]
fn a_foreign_repository_attribute_is_not_evidence() {
for attribute in [
"#[other_macros::repository]",
"#[diesel::repository(Audit)]",
"#[::sea_orm::repository]",
] {
assert!(
generated_names(&format!("{attribute}\npub trait AuditRepository {{}}\n"))
.is_empty(),
"for {attribute}",
);
}
}
#[test]
fn recognises_the_qualified_attribute_spelling() {
for attribute in [
"#[autumn_web::repository(Post, table = \"posts\")]",
"#[autumn::repository(Post)]",
"#[::autumn_web::repository]",
] {
let types = generated_names(&format!("{attribute}\npub trait PostRepository {{}}\n"));
assert_eq!(types, vec!["PgPostRepository"], "for {attribute}");
}
}
#[test]
fn does_not_read_repository_out_of_another_attributes_arguments() {
assert!(
generated_names("#[cfg(feature = \"repository\")]\npub trait PostRepository {}\n")
.is_empty()
);
assert!(generated_names("#[derive(repository)]\npub trait PostRepository {}\n").is_empty());
}
#[test]
fn ignores_repository_attributes_that_are_not_on_a_trait() {
assert!(generated_names("#[repository]\nstruct Post;\n").is_empty());
assert!(generated_names("#[serde(rename)]\ntrait Post {}\n").is_empty());
assert!(generated_names("fn f( {").is_empty());
}
#[test]
fn a_handwritten_type_of_the_same_name_makes_an_unqualified_call_ambiguous() {
let mut generated: GeneratedRepositories = std::iter::once((
"PgAuditRepository".to_owned(),
vec!["repositories".to_owned()],
))
.collect();
assert!(
generated.accepts("PgAuditRepository", &[]),
"with nothing else of that name, the generated type is the only reading"
);
generated.note_handwritten(std::iter::once((
"PgAuditRepository".to_owned(),
vec!["custom".to_owned()],
)));
assert!(
!generated.accepts("PgAuditRepository", &[]),
"once the source writes out a type of that name the call is ambiguous"
);
assert!(generated.accepts("PgAuditRepository", &["repositories".to_owned()]));
assert!(!generated.accepts("PgAuditRepository", &["custom".to_owned()]));
generated.note_handwritten(std::iter::once((
"PgAuditRepository".to_owned(),
vec!["repositories".to_owned()],
)));
assert!(!generated.accepts("PgAuditRepository", &["repositories".to_owned()]));
}
#[test]
fn reads_the_type_names_the_source_writes_out() {
assert_eq!(
defined_type_names("pub struct PgAuditRepository;\nenum E {}\ntype Alias = u8;")
.into_iter()
.map(|(name, _)| name)
.collect::<Vec<_>>(),
vec!["PgAuditRepository", "E", "Alias"]
);
assert!(defined_type_names("#[repository]\npub trait PostRepository {}").is_empty());
assert!(
defined_type_names("macro_rules! m { () => { struct PgAuditRepository; } }").is_empty()
);
}
#[test]
fn a_qualifier_decides_between_same_named_types() {
let generated: GeneratedRepositories = std::iter::once((
"PgAuditRepository".to_owned(),
vec!["repositories".to_owned()],
))
.collect();
assert!(generated.accepts("PgAuditRepository", &[]));
assert!(generated.accepts("PgAuditRepository", &["repositories".to_owned()]));
assert!(generated.accepts(
"PgAuditRepository",
&["crate".to_owned(), "repositories".to_owned()]
));
assert!(!generated.accepts("PgAuditRepository", &["custom".to_owned()]));
assert!(!generated.accepts("PgOtherRepository", &[]));
}
#[test]
fn records_the_module_a_trait_is_declared_in() {
assert_eq!(
generated_repository_types(
"mod repositories {\n mod inner { #[repository] pub trait PostRepository {} }\n}"
),
vec![(
"PgPostRepository".to_owned(),
vec!["repositories".to_owned(), "inner".to_owned()]
)]
);
assert!(
generated_repository_types("fn f() { #[repository] trait PostRepository {} }")
.is_empty()
);
}
#[test]
fn a_block_local_repository_is_not_module_wide_evidence() {
for source in [
"fn f() { #[repository] trait PostRepository {} }",
"mod repositories { fn f() { #[repository] trait PostRepository {} } }",
"fn f() { mod inner { #[repository] trait PostRepository {} } }",
] {
assert!(
generated_repository_types(source).is_empty(),
"for {source}",
);
}
}
#[test]
fn a_module_level_repository_is_still_evidence() {
assert_eq!(
generated_repository_types(
"mod repositories { #[repository] trait PostRepository {} }"
),
vec![(
"PgPostRepository".to_owned(),
vec!["repositories".to_owned()]
)]
);
}
#[test]
fn a_trait_inside_macro_input_is_not_evidence() {
assert!(
generated_names(
"macro_rules! template { () => { #[repository] trait AuditRepository {} } }"
)
.is_empty()
);
assert!(generated_names("declare! { #[repository] trait AuditRepository {} }").is_empty());
}
#[test]
fn finds_traits_nested_inside_a_module() {
let types = generated_names(
"mod repositories {\n #[repository]\n pub trait PostRepository {}\n}\n",
);
assert_eq!(types, vec!["PgPostRepository"]);
}
#[test]
fn a_conventional_name_no_trait_generates_is_reported_not_rewritten() {
let result = rewrite_source(
"fn f(p: P) { PgAuditRepository::with_pool(p); }",
&[&RENAME],
&assumed_generated(),
)
.expect("parses");
assert!(result.updated.is_none(), "nothing may be rewritten");
assert_eq!(
result.manual.first().map(|site| site.manual),
Some(Some(ManualReason::UnverifiedReceiver)),
"and the site is reported with the reason, got {:?}",
result.manual
);
}
#[test]
fn a_receiver_of_the_wrong_shape_is_still_the_shape_complaint() {
let result = rewrite_source(
"fn f(p: P) { PgCache::with_pool(p); }",
&[&RENAME],
&assumed_generated(),
)
.expect("parses");
assert_eq!(
result.manual.first().map(|site| site.manual),
Some(Some(ManualReason::UnexpectedReceiver)),
"got {:?}",
result.manual
);
}
fn run(source: &str) -> SourceRewrite {
rewrite_source(source, &[&RENAME], &assumed_generated()).expect("source parses")
}
fn rewritten(source: &str) -> String {
let out = run(source).updated.expect("source was rewritten");
out.parse::<TokenStream>()
.unwrap_or_else(|error| panic!("rewritten source no longer parses: {error}\n{out}"));
out
}
#[test]
fn rewrites_an_associated_function_call() {
let out = rewritten("fn f(p: Pool) { let r = PgPostRepository::with_pool(p); }");
assert_eq!(
out,
"fn f(p: Pool) { let r = PgPostRepository::with_pool_untracked(p); }"
);
}
#[test]
fn leaves_a_same_named_builder_method_alone() {
let result = run("fn f(s: AppState, p: Pool) { let s = s.with_pool(p); }");
assert_eq!(result.updated, None);
assert!(
result.manual.is_empty(),
"not an ambiguous site to flag — a different function entirely: {:?}",
result.manual
);
}
#[test]
fn rewrites_a_method_call_for_a_method_form_rename() {
static METHOD_RENAME: AppMigration = AppMigration {
id: "test-method-rename",
version: "0.7.0",
title: "method rename",
confidence: Confidence::Auto,
guide: "docs/migrations/0.7.0.md#anchor",
rewrite: Rewrite::CallRename {
from: "old_step",
to: "new_step",
form: CallForm::Method,
args: 1,
receiver: None,
},
};
let result = rewrite_source(
"fn f(b: B, p: P) { b.old_step(p); }",
&[&METHOD_RENAME],
&assumed_generated(),
)
.expect("parses");
assert_eq!(
result.updated.as_deref(),
Some("fn f(b: B, p: P) { b.new_step(p); }")
);
let path_form = rewrite_source(
"fn f(p: P) { B::old_step(p); }",
&[&METHOD_RENAME],
&assumed_generated(),
)
.expect("parses");
assert_eq!(path_form.updated, None, "a method rename leaves `::` alone");
}
#[test]
fn a_turbofish_inside_the_argument_does_not_inflate_the_arity() {
let out =
rewritten("fn f() { PgPostRepository::with_pool(make_pool::<Primary, Replica>()); }");
assert!(
out.contains("with_pool_untracked(make_pool::<Primary, Replica>())"),
"{out}"
);
}
#[test]
fn a_comparison_in_the_argument_does_not_hide_a_separator() {
let result = run("fn f() { PgPostRepository::with_pool(a < b, pool); }");
assert_eq!(result.updated, None);
}
#[test]
fn leaves_a_ufcs_call_with_the_wrong_arity_alone() {
let result = run("fn f(s: AppState, p: Pool) { let s = AppState::with_pool(s, p); }");
assert_eq!(result.updated, None);
}
#[test]
fn arity_is_counted_at_the_top_level_only() {
let out = rewritten("fn f() { PgPostRepository::with_pool(make(a, b),); }");
assert!(out.contains("with_pool_untracked(make(a, b),)"), "{out}");
}
#[test]
fn rewrites_a_turbofished_call() {
let out = rewritten("fn f() { PgPostRepository::with_pool::<Pg>(p); }");
assert_eq!(
out,
"fn f() { PgPostRepository::with_pool_untracked::<Pg>(p); }"
);
}
#[test]
fn preserves_formatting_comments_and_line_endings() {
let source = "fn f() {\r\n // build it with_pool, historically\r\n let r = PgPostRepository::with_pool(p); // trailing\r\n}\r\n";
let out = rewritten(source);
assert_eq!(
out,
"fn f() {\r\n // build it with_pool, historically\r\n let r = PgPostRepository::with_pool_untracked(p); // trailing\r\n}\r\n"
);
}
#[test]
fn leaves_the_already_renamed_call_untouched() {
let result = run("fn f() { PgPostRepository::with_pool_untracked(p); }");
assert_eq!(result.updated, None);
assert!(result.rewritten.is_empty());
}
#[test]
fn leaves_a_different_identifier_with_the_same_prefix_untouched() {
let result = run("fn f() { PgPostRepository::with_pool_provider(p); }");
assert_eq!(result.updated, None);
}
#[test]
fn leaves_a_local_binding_of_the_same_name_untouched() {
let result = run("fn f() { let with_pool = 1; dbg(with_pool); }");
assert_eq!(result.updated, None);
assert!(result.rewritten.is_empty());
assert!(result.manual.is_empty());
}
#[test]
fn leaves_a_struct_field_and_free_function_untouched() {
let result = run(
"struct S { with_pool: bool } fn with_pool() {} fn g(s: S) { let _ = s.with_pool; }",
);
assert_eq!(result.updated, None);
}
#[test]
fn leaves_string_literals_and_comments_untouched() {
let result = run("fn f() { let s = \"with_pool\"; /* with_pool */ }");
assert_eq!(result.updated, None);
}
#[test]
fn reports_a_macro_body_site_as_manual_without_rewriting_it() {
let source = "fn f() {\n make_repo! { PgPostRepository::with_pool(p) }\n}\n";
let result = run(source);
assert_eq!(result.updated, None, "a macro body is never rewritten");
assert!(result.rewritten.is_empty());
assert_eq!(result.manual.len(), 1, "the site must still be reported");
let site = &result.manual[0];
assert_eq!(site.line, 2);
assert_eq!(site.manual, Some(ManualReason::Macro));
assert_eq!(site.migration, "test-with-pool");
}
#[test]
fn reports_a_nested_macro_body_site_as_manual() {
let source = "fn f() {\n outer!(inner!(PgPostRepository::with_pool(p)));\n}\n";
let result = run(source);
assert_eq!(result.updated, None);
assert_eq!(result.manual.len(), 1);
assert_eq!(result.manual[0].manual, Some(ManualReason::Macro));
}
#[test]
fn reports_an_attribute_site_as_manual() {
let source = "#[derive_repo(build = PgPostRepository::with_pool(p))]\nstruct S;\n";
let result = run(source);
assert_eq!(result.updated, None);
assert_eq!(result.manual.len(), 1);
assert_eq!(result.manual[0].manual, Some(ManualReason::Attribute));
assert_eq!(result.manual[0].line, 1);
}
#[test]
fn a_macro_path_segment_is_not_itself_a_call_site() {
let result = run("fn f() { with_pool!(p); }");
assert_eq!(result.updated, None);
assert!(result.manual.is_empty());
}
#[test]
fn records_line_and_column_for_every_rewritten_site() {
let source = "fn f() {\n let a = PgPostRepository::with_pool(p);\n let b = PgCommentRepository::with_pool(q);\n}\n";
let result = run(source);
assert_eq!(result.rewritten.len(), 2);
assert_eq!(result.rewritten[0].line, 2);
assert_eq!(
result.rewritten[0].column, 31,
"1-based column of `with_pool`"
);
assert_eq!(result.rewritten[1].line, 3);
assert!(result.rewritten.iter().all(|s| s.manual.is_none()));
}
#[test]
fn splices_correctly_after_multibyte_characters() {
let source =
"fn f() {\n // — a note —\n let r = PgPostRepository::with_pool(p);\n}\n";
let out = rewritten(source);
assert_eq!(
out,
"fn f() {\n // — a note —\n let r = PgPostRepository::with_pool_untracked(p);\n}\n"
);
}
#[test]
fn rewrites_every_site_in_a_file() {
let source = "fn f() { PgAlphaRepository::with_pool(p); PgBetaRepository::with_pool(q); PgGammaRepository::with_pool(r); }";
let result = run(source);
assert_eq!(result.rewritten.len(), 3);
assert_eq!(
result.updated.as_deref(),
Some(
"fn f() { PgAlphaRepository::with_pool_untracked(p); PgBetaRepository::with_pool_untracked(q); PgGammaRepository::with_pool_untracked(r); }"
)
);
}
#[test]
fn rewrites_inside_cfg_disabled_code() {
let source = "#[cfg(feature = \"db\")]\nfn f() { PgPostRepository::with_pool(p); }\n";
let out = rewritten(source);
assert!(out.contains("with_pool_untracked"));
}
#[test]
fn a_guide_only_migration_rewrites_nothing() {
static GUIDE_ONLY: AppMigration = AppMigration {
id: "test-guide-only",
version: "0.6.0",
title: "guide only",
confidence: Confidence::Manual,
guide: "docs/migrations/0.6.0.md#anchor",
rewrite: Rewrite::GuideOnly,
};
let result = rewrite_source(
"fn f() { PgPostRepository::with_pool(p); }",
&[&GUIDE_ONLY],
&assumed_generated(),
)
.expect("parses");
assert_eq!(result.updated, None);
assert!(result.rewritten.is_empty());
assert!(result.manual.is_empty());
}
#[test]
fn an_unparsable_file_is_an_error_not_a_rewrite() {
let err = rewrite_source("fn f( {", &[&RENAME], &assumed_generated())
.expect_err("must not silently succeed");
assert!(!err.is_empty(), "the parse error is reported to the user");
}
#[test]
fn a_macro_rules_definition_is_never_rewritten_matcher_or_transcriber() {
let source = "macro_rules! forward {\n ($t:ident :: with_pool ( $p:expr )) => { $t::with_pool($p) };\n}\n";
let result = run(source);
assert_eq!(
result.updated, None,
"a macro definition body is macro input"
);
assert_eq!(
result.manual.len(),
2,
"both sites are reported: {:?}",
result.manual
);
assert!(
result
.manual
.iter()
.all(|s| s.manual == Some(ManualReason::Macro))
);
}
#[test]
fn every_macro_definition_shape_is_treated_as_macro_input() {
for source in [
"macro_rules! m { () => { PgPostRepository::with_pool(p) }; }",
"macro_rules! m ( () => ( PgPostRepository::with_pool(p) ); );",
"macro_rules! m [ () => [ PgPostRepository::with_pool(p) ]; ];",
"#[macro_export]\nmacro_rules! m { () => { PgPostRepository::with_pool(p) }; }",
"fn outer() { macro_rules! m { () => { PgPostRepository::with_pool(p) }; } }",
"pub macro m() { PgPostRepository::with_pool(p) }",
"pub macro m { () => { PgPostRepository::with_pool(p) } }",
] {
let result = run(source);
assert_eq!(result.updated, None, "must not rewrite: {source}");
assert!(
result
.manual
.iter()
.all(|s| s.manual == Some(ManualReason::Macro)),
"must report as macro input: {source} -> {:?}",
result.manual
);
assert!(!result.manual.is_empty(), "must not stay silent: {source}");
}
}
#[test]
fn a_range_or_struct_update_expression_is_not_a_method_call() {
for source in [
"fn f() { for i in 0..with_pool(n) {} }",
"fn f() { let c = C { a: 1, ..with_pool(d) }; }",
"fn f() { let s = &v[a..with_pool(b)]; }",
"fn f() { let s = &v[..with_pool(b)]; }",
] {
let result = run(source);
assert_eq!(result.updated, None, "must not rewrite: {source}");
assert!(result.manual.is_empty(), "not a site at all: {source}");
}
}
#[test]
fn a_reference_that_is_never_called_is_reported_not_skipped() {
for source in [
"fn f() { xs.iter().map(PgPostRepository::with_pool); }",
"fn f() { let g = PgPostRepository::with_pool; g(p); }",
"fn f() { let g: fn(P) -> R = PgPostRepository::with_pool; }",
] {
let result = run(source);
assert_eq!(
result.updated, None,
"a reference is not rewritten: {source}"
);
assert!(
result
.manual
.iter()
.any(|s| s.manual == Some(ManualReason::NotACall)),
"must be reported for a human: {source} -> {:?}",
result.manual
);
}
}
#[test]
fn a_raw_identifier_call_site_is_rewritten_keeping_its_prefix() {
let out = rewritten("fn f() { PgPostRepository::r#with_pool(p); }");
assert_eq!(
out,
"fn f() { PgPostRepository::r#with_pool_untracked(p); }"
);
}
#[test]
fn a_turbofish_that_is_not_a_call_is_not_rewritten() {
let result = run("fn f(x: Vec<PgPostRepository::with_pool::<T>>) {}");
assert_eq!(result.updated, None);
assert!(
result
.manual
.iter()
.any(|s| s.manual == Some(ManualReason::NotACall))
);
}
#[test]
fn a_turbofish_returning_a_function_type_still_reads_as_a_call() {
let out = rewritten("fn f() { PgPostRepository::with_pool::<fn(A) -> B>(p); }");
assert!(
out.contains("with_pool_untracked::<fn(A) -> B>(p)"),
"{out}"
);
}
#[test]
fn chained_renames_across_releases_land_on_the_final_name_in_one_run() {
static FIRST: AppMigration = AppMigration {
id: "0.6.0-first",
version: "0.6.0",
title: "first",
confidence: Confidence::Auto,
guide: "docs/migrations/0.6.0.md#a",
rewrite: Rewrite::CallRename {
from: "with_pool",
to: "with_pool_untracked",
form: CallForm::AssociatedFunction,
args: 1,
receiver: None,
},
};
static SECOND: AppMigration = AppMigration {
id: "0.7.0-second",
version: "0.7.0",
title: "second",
confidence: Confidence::Auto,
guide: "docs/migrations/0.7.0.md#a",
rewrite: Rewrite::CallRename {
from: "with_pool_untracked",
to: "untracked_pool",
form: CallForm::AssociatedFunction,
args: 1,
receiver: None,
},
};
let chain: [&'static AppMigration; 2] = [&FIRST, &SECOND];
let first_run = rewrite_source_for_releases(
"fn f() { PgPostRepository::with_pool(p); }",
&chain,
&assumed_generated(),
)
.expect("parses");
assert_eq!(
first_run.updated.as_deref(),
Some("fn f() { PgPostRepository::untracked_pool(p); }"),
"one run reaches the newest name"
);
let second_run = rewrite_source_for_releases(
first_run.updated.as_deref().expect("rewritten"),
&chain,
&assumed_generated(),
)
.expect("parses");
assert_eq!(second_run.updated, None, "and a second run is a no-op");
}
#[test]
fn sites_from_several_releases_are_reported_in_source_order() {
static A: AppMigration = AppMigration {
id: "0.6.0-a",
version: "0.6.0",
title: "a",
confidence: Confidence::Auto,
guide: "docs/migrations/0.6.0.md#a",
rewrite: Rewrite::CallRename {
from: "with_pool",
to: "with_pool_untracked",
form: CallForm::AssociatedFunction,
args: 1,
receiver: None,
},
};
static B: AppMigration = AppMigration {
id: "0.7.0-b",
version: "0.7.0",
title: "b",
confidence: Confidence::Auto,
guide: "docs/migrations/0.7.0.md#b",
rewrite: Rewrite::CallRename {
from: "old_name",
to: "new_name",
form: CallForm::AssociatedFunction,
args: 1,
receiver: None,
},
};
let source = "fn f() {\n R::old_name(p);\n PgPostRepository::with_pool(p);\n}\n";
let result =
rewrite_source_for_releases(source, &[&A, &B], &assumed_generated()).expect("parses");
let lines: Vec<usize> = result.rewritten.iter().map(|site| site.line).collect();
assert_eq!(
lines,
vec![2, 3],
"reported top-to-bottom, not pass by pass"
);
}
#[test]
fn leaves_an_unrelated_associated_function_with_the_same_name_alone() {
let result = run("fn f(p: Pool) { let c = Cache::with_pool(p); }");
assert_eq!(result.updated, None, "an unrelated type is not rewritten");
assert_eq!(result.manual.len(), 1, "and not silently skipped either");
assert_eq!(
result.manual[0].manual,
Some(ManualReason::UnexpectedReceiver)
);
}
#[test]
fn leaves_an_app_type_that_merely_starts_with_pg_alone() {
let result = run("fn f(p: Pool) { let c = PgCache::with_pool(p); }");
assert_eq!(result.updated, None);
assert_eq!(
result.manual[0].manual,
Some(ManualReason::UnexpectedReceiver)
);
}
#[test]
fn rewrites_a_generated_repository_receiver_however_it_is_pathed() {
let generated: GeneratedRepositories =
std::iter::once(("PgPostRepository".to_owned(), vec!["repos".to_owned()])).collect();
let out = rewrite_source(
"fn f(p: Pool) { crate::repos::PgPostRepository::with_pool(p); }",
&[&RENAME],
&generated,
)
.expect("parses")
.updated
.expect("source was rewritten");
assert!(
out.contains("PgPostRepository::with_pool_untracked(p)"),
"{out}"
);
}
#[test]
fn a_self_qualified_receiver_is_reported_not_rewritten() {
let generated: GeneratedRepositories =
std::iter::once(("PgPostRepository".to_owned(), vec!["repos".to_owned()])).collect();
let result = rewrite_source(
"fn f(p: Pool) { self::PgPostRepository::with_pool(p); }",
&[&RENAME],
&generated,
)
.expect("parses");
assert_eq!(result.updated, None);
assert_eq!(
result.manual[0].manual,
Some(ManualReason::UnverifiedReceiver)
);
}
#[test]
fn a_super_qualified_receiver_is_reported_not_rewritten() {
let generated: GeneratedRepositories =
std::iter::once(("PgPostRepository".to_owned(), vec!["repos".to_owned()])).collect();
let result = rewrite_source(
"fn f(p: Pool) { super::PgPostRepository::with_pool(p); }",
&[&RENAME],
&generated,
)
.expect("parses");
assert_eq!(result.updated, None);
assert_eq!(
result.manual[0].manual,
Some(ManualReason::UnverifiedReceiver)
);
}
#[test]
fn a_cfg_gated_repository_declaration_is_not_evidence() {
assert!(
generated_names(
"#[cfg(feature = \"postgres\")]\n#[repository]\npub trait AuditRepository {}\n"
)
.is_empty(),
"a conditional declaration is conditional evidence",
);
}
#[test]
fn a_cfg_gated_repository_declaration_is_not_evidence_when_cfg_follows() {
assert!(
generated_names(
"#[repository]\n#[cfg(feature = \"postgres\")]\npub trait AuditRepository {}\n"
)
.is_empty(),
);
}
#[test]
fn an_unconditional_repository_declaration_is_still_evidence() {
assert_eq!(
generated_names("#[repository]\npub trait AuditRepository {}\n"),
vec!["PgAuditRepository".to_owned()],
);
}
#[test]
fn a_qualified_trait_call_is_reported_not_rewritten() {
let result = run("fn f(p: Pool) { <T as Repo>::with_pool(p); }");
assert_eq!(result.updated, None);
assert_eq!(
result.manual[0].manual,
Some(ManualReason::UnexpectedReceiver)
);
}
#[test]
fn a_rename_with_no_receiver_constraint_accepts_any_receiver() {
static ANY: AppMigration = AppMigration {
id: "test-any-receiver",
version: "0.7.0",
title: "any receiver",
confidence: Confidence::Auto,
guide: "docs/migrations/0.7.0.md#anchor",
rewrite: Rewrite::CallRename {
from: "old_ctor",
to: "new_ctor",
form: CallForm::AssociatedFunction,
args: 1,
receiver: None,
},
};
let result = rewrite_source(
"fn f(p: P) { Anything::old_ctor(p); }",
&[&ANY],
&assumed_generated(),
)
.expect("parses");
assert_eq!(
result.updated.as_deref(),
Some("fn f(p: P) { Anything::new_ctor(p); }")
);
}
#[test]
fn manual_reasons_read_as_a_sentence_fragment() {
assert_eq!(ManualReason::Macro.describe(), "inside a macro invocation");
assert_eq!(ManualReason::Attribute.describe(), "inside an attribute");
assert_eq!(
ManualReason::NotACall.describe(),
"referenced without being called"
);
assert_eq!(
ManualReason::UnexpectedReceiver.describe(),
"receiver is not a generated repository"
);
}
}