use std::path::{Path, PathBuf};
use aristo_core::walk::WalkOptions;
use proc_macro2::{LineColumn, Span};
use syn::visit::Visit;
use crate::{CliError, CliResult, Workspace};
const IGNORED_DIRS: &[&str] = &["target", ".git", ".aristo", "node_modules"];
pub(crate) fn run_fix(ws: &Workspace) -> CliResult<(usize, usize)> {
let mut total_fixes = 0usize;
let mut files_modified = 0usize;
let opts =
WalkOptions::from_index_config(&ws.load_config().index).map_err(|e| CliError::Other {
message: format!("aristo.toml [index].exclude: {e}"),
exit_code: 2,
})?;
for path in collect_rs_files(&ws.root, &opts) {
let source = std::fs::read_to_string(&path).map_err(|e| CliError::Other {
message: format!("read {}: {e}", path.display()),
exit_code: 1,
})?;
let Some((new_source, n)) = rewrite_source(&source) else {
continue;
};
if n == 0 {
continue;
}
atomic_write(&path, &new_source)?;
total_fixes += n;
files_modified += 1;
}
Ok((total_fixes, files_modified))
}
fn collect_rs_files(root: &Path, opts: &WalkOptions) -> Vec<PathBuf> {
walkdir::WalkDir::new(root)
.follow_links(false)
.sort_by_file_name()
.into_iter()
.filter_entry(|e| {
!(e.file_type().is_dir()
&& e.file_name()
.to_str()
.map(|n| IGNORED_DIRS.contains(&n))
.unwrap_or(false))
})
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("rs"))
.filter(|e| {
let rel = e.path().strip_prefix(root).unwrap_or(e.path());
!opts.excludes_path(rel)
})
.map(|e| e.into_path())
.collect()
}
#[aristo::intent(
"When proc_macro2 span info is missing (the rare case where \
span-locations is disabled), the offending edit is skipped rather \
than applied. Corrupting source bytes from a wrong offset is a far \
worse failure mode than leaving an annotation unfixed: the user \
sees a persistent lint finding and investigates, instead of silent \
file damage.",
verify = "neural",
id = "lint_fix_skips_on_missing_span"
)]
fn rewrite_source(source: &str) -> Option<(String, usize)> {
let file: syn::File = syn::parse_str(source).ok()?;
let mut visitor = LiteralFinder { hits: Vec::new() };
visitor.visit_file(&file);
let offsets = LineOffsets::new(source);
let mut edits: Vec<Edit> = Vec::new();
let mut total_fixes = 0usize;
for hit in visitor.hits {
let (fixed, n) = apply_autofix(&hit.value);
if n == 0 {
continue;
}
let span = hit.span;
let (start, end) = match (
offsets.byte_offset(span.start()),
offsets.byte_offset(span.end()),
) {
(Some(s), Some(e)) if s <= e && e <= source.len() => (s, e),
_ => continue, };
let replacement = render_str_literal(&fixed);
edits.push(Edit {
start,
end,
replacement,
});
total_fixes += n;
}
if edits.is_empty() {
return Some((source.to_string(), 0));
}
edits.sort_by_key(|e| std::cmp::Reverse(e.start));
let mut out = source.to_string();
for edit in edits {
out.replace_range(edit.start..edit.end, &edit.replacement);
}
Some((out, total_fixes))
}
struct Edit {
start: usize,
end: usize,
replacement: String,
}
struct LiteralHit {
span: Span,
value: String,
}
struct LiteralFinder {
hits: Vec<LiteralHit>,
}
impl<'ast> Visit<'ast> for LiteralFinder {
fn visit_attribute(&mut self, attr: &'ast syn::Attribute) {
if is_aristo_annotation_attr(attr) {
if let Some(lit) = first_string_lit_in_attr(attr) {
self.hits.push(LiteralHit {
span: lit.span(),
value: lit.value(),
});
}
}
syn::visit::visit_attribute(self, attr);
}
fn visit_stmt_macro(&mut self, node: &'ast syn::StmtMacro) {
if is_aristo_annotation_stmt_macro(&node.mac) {
if let Some(lit) = first_string_lit_in_macro(&node.mac) {
self.hits.push(LiteralHit {
span: lit.span(),
value: lit.value(),
});
}
}
syn::visit::visit_stmt_macro(self, node);
}
}
fn is_aristo_annotation_attr(attr: &syn::Attribute) -> bool {
let segs: Vec<String> = attr
.path()
.segments
.iter()
.map(|s| s.ident.to_string())
.collect();
let last = match segs.last() {
Some(s) => s.as_str(),
None => return false,
};
if last != "intent" && last != "assume" {
return false;
}
segs.len() == 1 || (segs.len() == 2 && segs[0] == "aristo")
}
fn is_aristo_annotation_stmt_macro(mac: &syn::Macro) -> bool {
let segs: Vec<String> = mac
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect();
let last = match segs.last() {
Some(s) => s.as_str(),
None => return false,
};
if last != "intent_stmt" && last != "assume_stmt" {
return false;
}
segs.len() == 1 || (segs.len() == 2 && segs[0] == "aristo")
}
fn first_string_lit_in_attr(attr: &syn::Attribute) -> Option<syn::LitStr> {
let args = attr.parse_args::<FirstLit>().ok()?;
Some(args.0)
}
fn first_string_lit_in_macro(mac: &syn::Macro) -> Option<syn::LitStr> {
let args = mac.parse_body::<FirstLit>().ok()?;
Some(args.0)
}
struct FirstLit(syn::LitStr);
impl syn::parse::Parse for FirstLit {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let lit: syn::LitStr = input.parse()?;
Ok(FirstLit(lit))
}
}
#[aristo::intent(
"The count is rule-applications, not anomaly count. An annotation \
with five doubled-space runs is one fix, not five; with both rule \
classes triggering, the count is at most 2. The spec line \
`fixed: N whitespace issues across M files` depends on this — \
counting anomalies would inflate N misleadingly and diverge from \
the trycmd scenario.",
verify = "test",
id = "lint_fix_count_is_rule_applications"
)]
fn apply_autofix(text: &str) -> (String, usize) {
let mut work = text.to_string();
let mut count = 0usize;
let after_trail = strip_trailing_whitespace(&work);
if after_trail != work {
work = after_trail;
count += 1;
}
let after_doubled = collapse_doubled_spaces(&work);
if after_doubled != work {
work = after_doubled;
count += 1;
}
(work, count)
}
fn strip_trailing_whitespace(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut first = true;
for line in s.split('\n') {
if !first {
out.push('\n');
}
first = false;
out.push_str(line.trim_end_matches([' ', '\t']));
}
out
}
fn collapse_doubled_spaces(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_space = false;
for ch in s.chars() {
if ch == ' ' {
if !prev_space {
out.push(ch);
}
prev_space = true;
} else {
out.push(ch);
prev_space = false;
}
}
out
}
fn render_str_literal(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\0' => out.push_str("\\0"),
c if (c as u32) < 0x20 => {
use std::fmt::Write;
let _ = write!(out, "\\u{{{:x}}}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
struct LineOffsets<'a> {
source: &'a str,
line_starts: Vec<usize>,
}
impl<'a> LineOffsets<'a> {
fn new(source: &'a str) -> Self {
let mut line_starts = vec![0];
for (i, b) in source.bytes().enumerate() {
if b == b'\n' {
line_starts.push(i + 1);
}
}
Self {
source,
line_starts,
}
}
fn byte_offset(&self, lc: LineColumn) -> Option<usize> {
if lc.line == 0 {
return None; }
let line_idx = lc.line - 1;
let line_start = *self.line_starts.get(line_idx)?;
let line_end = self
.line_starts
.get(line_idx + 1)
.copied()
.unwrap_or(self.source.len());
let line_slice = self.source.get(line_start..line_end)?;
let mut bytes = 0usize;
for (i, ch) in line_slice.chars().enumerate() {
if i == lc.column {
return Some(line_start + bytes);
}
bytes += ch.len_utf8();
}
if lc.column >= line_slice.chars().count() {
return Some(line_start + bytes);
}
None
}
}
fn atomic_write(path: &Path, contents: &str) -> CliResult<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let tmp = parent.join(format!(
".{}.aristo-fix.{pid}.{nanos}.tmp",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("source")
));
std::fs::write(&tmp, contents).map_err(|e| CliError::Other {
message: format!("write {}: {e}", tmp.display()),
exit_code: 1,
})?;
std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
CliError::Other {
message: format!("rename to {}: {e}", path.display()),
exit_code: 1,
}
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_trailing_whitespace_handles_multi_line() {
let s = "alpha \nbeta\ngamma \t ";
assert_eq!(strip_trailing_whitespace(s), "alpha\nbeta\ngamma");
}
#[test]
fn collapse_doubled_spaces_leaves_single_spaces_alone() {
assert_eq!(collapse_doubled_spaces("one two three"), "one two three");
assert_eq!(collapse_doubled_spaces("one two three"), "one two three");
}
#[test]
fn apply_autofix_counts_each_rule_once() {
let (fixed, n) = apply_autofix("with doubled and \ntrailing");
assert_eq!(fixed, "with doubled and\ntrailing");
assert_eq!(n, 2);
}
#[test]
fn apply_autofix_clean_text_is_zero_fixes() {
let (fixed, n) = apply_autofix("nothing to fix here");
assert_eq!(fixed, "nothing to fix here");
assert_eq!(n, 0);
}
#[test]
fn render_str_literal_escapes_control_chars() {
assert_eq!(render_str_literal("a\nb"), r#""a\nb""#);
assert_eq!(
render_str_literal(r#"with "quotes""#),
r#""with \"quotes\"""#
);
assert_eq!(render_str_literal("back\\slash"), r#""back\\slash""#);
}
#[test]
fn rewrite_source_replaces_text_literal_in_attribute_form() {
let src = "#[aristo::intent(\"hello world \")]\nfn x() {}\n";
let (out, n) = rewrite_source(src).unwrap();
assert_eq!(n, 2);
assert!(out.contains("\"hello world\""), "got: {out}");
}
#[test]
fn rewrite_source_no_changes_when_text_is_clean() {
let src = "#[aristo::intent(\"already clean\")]\nfn x() {}\n";
let (out, n) = rewrite_source(src).unwrap();
assert_eq!(n, 0);
assert_eq!(out, src);
}
#[test]
fn rewrite_source_handles_statement_form() {
let src = "fn f() { aristo::intent_stmt!(\"trail \"); }\n";
let (out, n) = rewrite_source(src).unwrap();
assert_eq!(n, 1);
assert!(out.contains("\"trail\""), "got: {out}");
}
}