use syn::visit::Visit;
use crate::index::AnnotationKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdOccurrenceKind {
Id,
ParentSingle,
ParentArrayElement,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdOccurrence {
pub kind: IdOccurrenceKind,
pub value: String,
pub line: usize,
pub byte_start: usize,
pub byte_end: usize,
pub host_kind: AnnotationKind,
}
#[aristo::intent(
"scan_id_occurrences returns byte-range spans that splice exactly the id \
value when used as `source[byte_start..byte_end]`. Slice 32's rename \
command rewrites source by byte substitution at these spans rather than \
by syn::visit_mut re-serialization — re-serialization destroys \
whitespace + comments and produces user-visible churn. Spans MUST \
exclude surrounding quotes so the new id can be spliced in verbatim.",
verify = "test",
id = "scan_id_occurrences_byte_spans_round_trip_exactly"
)]
pub fn scan_id_occurrences(source: &str) -> Result<Vec<IdOccurrence>, syn::Error> {
let file: syn::File = syn::parse_str(source)?;
let line_offsets = compute_line_byte_offsets(source);
let mut visitor = Scanner {
source,
line_offsets: &line_offsets,
found: Vec::new(),
};
visitor.visit_file(&file);
visitor.found.sort_by_key(|o| o.byte_start);
Ok(visitor.found)
}
struct Scanner<'a> {
source: &'a str,
line_offsets: &'a [usize],
found: Vec<IdOccurrence>,
}
impl Scanner<'_> {
fn record_lit(&mut self, lit: &syn::LitStr, kind: IdOccurrenceKind, host_kind: AnnotationKind) {
let span = lit.span();
let start = span.start();
let end = span.end();
if start.line == 0 || end.line == 0 {
return;
}
let outer_start = match line_col_to_byte(self.source, self.line_offsets, start) {
Some(b) => b,
None => return,
};
let outer_end = match line_col_to_byte(self.source, self.line_offsets, end) {
Some(b) => b,
None => return,
};
if outer_end <= outer_start + 2 {
return;
}
let bytes = self.source.as_bytes();
if bytes[outer_start] != b'"' || bytes[outer_end - 1] != b'"' {
return;
}
self.found.push(IdOccurrence {
kind,
value: lit.value(),
line: start.line,
byte_start: outer_start + 1,
byte_end: outer_end - 1,
host_kind,
});
}
fn record_annotation_args(&mut self, args: AnnotationArgs, host_kind: AnnotationKind) {
if let Some(lit) = args.id_lit {
self.record_lit(&lit, IdOccurrenceKind::Id, host_kind);
}
match args.parent {
Some(ParentLits::Single(lit)) => {
self.record_lit(&lit, IdOccurrenceKind::ParentSingle, host_kind);
}
Some(ParentLits::Multiple(lits)) => {
for lit in lits {
self.record_lit(&lit, IdOccurrenceKind::ParentArrayElement, host_kind);
}
}
None => {}
}
}
fn process_attrs(&mut self, attrs: &[syn::Attribute]) {
for attr in attrs {
let Some(host_kind) = match_aristo_attr(attr) else {
continue;
};
let Ok(args) = attr.parse_args::<AnnotationArgs>() else {
continue;
};
self.record_annotation_args(args, host_kind);
}
}
}
impl<'ast> Visit<'ast> for Scanner<'_> {
fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
self.process_attrs(&node.attrs);
syn::visit::visit_block(self, &node.block);
}
fn visit_stmt_macro(&mut self, node: &'ast syn::StmtMacro) {
if let Some(host_kind) = match_aristo_stmt_macro(&node.mac) {
if let Ok(args) = node.mac.parse_body::<AnnotationArgs>() {
self.record_annotation_args(args, host_kind);
}
}
syn::visit::visit_stmt_macro(self, node);
}
fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
self.process_attrs(&node.attrs);
}
fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
self.process_attrs(&node.attrs);
}
fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) {
self.process_attrs(&node.attrs);
for item in &node.items {
if let syn::TraitItem::Fn(method) = item {
self.process_attrs(&method.attrs);
}
}
}
fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
self.process_attrs(&node.attrs);
for item in &node.items {
if let syn::ImplItem::Fn(method) = item {
self.process_attrs(&method.attrs);
syn::visit::visit_block(self, &method.block);
}
}
}
fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
self.process_attrs(&node.attrs);
if let Some((_, items)) = &node.content {
for item in items {
self.visit_item(item);
}
}
}
fn visit_item_type(&mut self, node: &'ast syn::ItemType) {
self.process_attrs(&node.attrs);
}
}
fn match_aristo_attr(attr: &syn::Attribute) -> Option<AnnotationKind> {
let segs: Vec<String> = attr
.path()
.segments
.iter()
.map(|s| s.ident.to_string())
.collect();
match segs.as_slice() {
[name] => match name.as_str() {
"intent" => Some(AnnotationKind::Intent),
"assume" => Some(AnnotationKind::Assume),
_ => None,
},
[outer, name] if outer == "aristo" => match name.as_str() {
"intent" => Some(AnnotationKind::Intent),
"assume" => Some(AnnotationKind::Assume),
_ => None,
},
_ => None,
}
}
fn match_aristo_stmt_macro(mac: &syn::Macro) -> Option<AnnotationKind> {
let segs: Vec<String> = mac
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect();
let name = segs.last()?;
let qualified_ok = segs.len() == 1 || (segs.len() == 2 && segs[0] == "aristo");
if !qualified_ok {
return None;
}
match name.as_str() {
"intent_stmt" => Some(AnnotationKind::Intent),
"assume_stmt" => Some(AnnotationKind::Assume),
_ => None,
}
}
struct AnnotationArgs {
id_lit: Option<syn::LitStr>,
parent: Option<ParentLits>,
}
enum ParentLits {
Single(syn::LitStr),
Multiple(Vec<syn::LitStr>),
}
impl syn::parse::Parse for AnnotationArgs {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let mut id_lit = None;
let mut parent = None;
if input.is_empty() {
return Ok(Self { id_lit, parent });
}
let _: syn::LitStr = input.parse()?;
while input.peek(syn::Token![,]) {
input.parse::<syn::Token![,]>()?;
if input.is_empty() {
break;
}
let key: syn::Ident = input.parse()?;
input.parse::<syn::Token![=]>()?;
match key.to_string().as_str() {
"verify" => {
let _: syn::Expr = input.parse()?;
}
"parent" => {
if input.peek(syn::token::Bracket) {
let content;
syn::bracketed!(content in input);
let mut lits = Vec::new();
while !content.is_empty() {
let lit: syn::LitStr = content.parse()?;
lits.push(lit);
if content.peek(syn::Token![,]) {
content.parse::<syn::Token![,]>()?;
}
}
parent = Some(ParentLits::Multiple(lits));
} else {
let lit: syn::LitStr = input.parse()?;
parent = Some(ParentLits::Single(lit));
}
}
"id" => {
let lit: syn::LitStr = input.parse()?;
id_lit = Some(lit);
}
_ => {
let _: syn::Expr = input.parse()?;
}
}
}
Ok(Self { id_lit, parent })
}
}
fn compute_line_byte_offsets(source: &str) -> Vec<usize> {
let mut offsets = vec![0, 0]; for (idx, byte) in source.bytes().enumerate() {
if byte == b'\n' {
offsets.push(idx + 1);
}
}
offsets
}
fn line_col_to_byte(
source: &str,
line_offsets: &[usize],
lc: proc_macro2::LineColumn,
) -> Option<usize> {
if lc.line == 0 || lc.line >= line_offsets.len() {
return None;
}
let line_start = line_offsets[lc.line];
let rest = &source[line_start..];
let mut chars = rest.char_indices();
let mut byte_in_line = 0;
for _ in 0..lc.column {
match chars.next() {
Some((_, ch)) => {
if ch == '\n' {
return None;
}
byte_in_line += ch.len_utf8();
}
None => return None,
}
}
Some(line_start + byte_in_line)
}
#[cfg(test)]
mod tests {
use super::*;
fn scan(s: &str) -> Vec<IdOccurrence> {
scan_id_occurrences(s).expect("test source must parse as Rust")
}
#[test]
fn finds_id_argument_on_attribute_form() {
let src = r#"#[aristo::intent("the claim", verify = "test", id = "my_intent")] fn f() {}"#;
let occs = scan(src);
assert_eq!(occs.len(), 1);
assert_eq!(occs[0].kind, IdOccurrenceKind::Id);
assert_eq!(occs[0].value, "my_intent");
assert_eq!(&src[occs[0].byte_start..occs[0].byte_end], "my_intent");
}
#[test]
fn finds_parent_single_form() {
let src = r#"#[aristo::intent("c", parent = "anc", id = "child")] fn f() {}"#;
let occs = scan(src);
assert_eq!(occs.len(), 2);
assert_eq!(occs[0].kind, IdOccurrenceKind::ParentSingle);
assert_eq!(occs[0].value, "anc");
assert_eq!(occs[1].kind, IdOccurrenceKind::Id);
assert_eq!(occs[1].value, "child");
}
#[test]
fn finds_each_parent_array_element_separately() {
let src = r#"#[aristo::intent("c", parent = ["a", "b", "c_par"], id = "kid")] fn f() {}"#;
let occs = scan(src);
assert_eq!(occs.len(), 4); assert_eq!(occs[0].kind, IdOccurrenceKind::ParentArrayElement);
assert_eq!(occs[0].value, "a");
assert_eq!(&src[occs[0].byte_start..occs[0].byte_end], "a");
assert_eq!(occs[1].kind, IdOccurrenceKind::ParentArrayElement);
assert_eq!(occs[1].value, "b");
assert_eq!(occs[2].kind, IdOccurrenceKind::ParentArrayElement);
assert_eq!(occs[2].value, "c_par");
assert_eq!(occs[3].kind, IdOccurrenceKind::Id);
assert_eq!(occs[3].value, "kid");
}
#[test]
fn occurrences_are_returned_in_source_order() {
let src = r#"
#[aristo::intent("first", id = "alpha")] fn a() {}
#[aristo::intent("second", id = "beta")] fn b() {}
#[aristo::intent("third", id = "gamma")] fn c() {}
"#;
let occs = scan(src);
let ids: Vec<&str> = occs.iter().map(|o| o.value.as_str()).collect();
assert_eq!(ids, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn handles_bare_intent_after_use_aristo_intent() {
let src = r#"
use aristo::intent;
#[intent("hi", id = "bare_form")] fn f() {}
"#;
let occs = scan(src);
assert_eq!(occs.len(), 1);
assert_eq!(occs[0].value, "bare_form");
}
#[test]
fn handles_assume_attribute() {
let src = r#"#[aristo::assume("an OS guarantee", id = "the_assume")] fn f() {}"#;
let occs = scan(src);
assert_eq!(occs.len(), 1);
assert_eq!(occs[0].kind, IdOccurrenceKind::Id);
assert_eq!(occs[0].host_kind, AnnotationKind::Assume);
}
#[test]
fn handles_intent_stmt_macro_inside_fn() {
let src = r#"
fn f() {
aristo::intent_stmt!("statement claim", id = "stmt_intent");
let _ = 0;
}
"#;
let occs = scan(src);
assert_eq!(occs.len(), 1);
assert_eq!(occs[0].value, "stmt_intent");
assert_eq!(occs[0].host_kind, AnnotationKind::Intent);
}
#[test]
fn handles_assume_stmt_macro() {
let src = r#"
fn f() {
aristo::assume_stmt!("caller holds the lock", id = "lock_held");
}
"#;
let occs = scan(src);
assert_eq!(occs.len(), 1);
assert_eq!(occs[0].host_kind, AnnotationKind::Assume);
}
#[test]
fn descends_into_impl_method_attributes_and_stmt_macros() {
let src = r#"
impl Holder {
#[aristo::intent("ctor preserves value", id = "ctor_invariant")]
fn new(v: i32) -> Self { Self { v } }
fn check(&self) -> bool {
aristo::intent_stmt!("stmt inside method", id = "method_stmt");
self.v % 2 == 0
}
}
"#;
let occs = scan(src);
let ids: Vec<&str> = occs.iter().map(|o| o.value.as_str()).collect();
assert_eq!(ids, vec!["ctor_invariant", "method_stmt"]);
}
#[test]
fn ignores_unrelated_attributes() {
let src = r#"
#[derive(Debug)] #[serde(rename = "foo")]
struct X;
fn main() { let _id = "not_an_aristo_id"; }
"#;
assert!(scan(src).is_empty());
}
#[test]
fn malformed_annotation_args_skip_silently() {
let src = r#"#[aristo::intent(id = "lonely")] fn f() {}"#;
assert!(
scan(src).is_empty(),
"malformed annotation has no occurrences"
);
}
#[test]
fn outer_parse_error_surfaces_as_err() {
assert!(scan_id_occurrences("fn unbalanced(").is_err());
}
#[test]
fn aristos_namespace_id_value_is_captured_verbatim() {
let src = r#"#[aristo::intent("c", parent = "aristos:anc", id = "aristos:foo")] fn f() {}"#;
let occs = scan(src);
assert_eq!(occs.len(), 2);
assert_eq!(occs[0].value, "aristos:anc");
assert_eq!(occs[1].value, "aristos:foo");
}
#[test]
fn byte_spans_round_trip_for_each_occurrence() {
let src = r#"
#[aristo::intent("c", parent = ["one", "two"], id = "three")]
fn f() {}
"#;
let occs = scan(src);
for occ in &occs {
assert_eq!(
&src[occ.byte_start..occ.byte_end],
occ.value,
"byte span must equal the value for {:?}",
occ.kind
);
}
}
#[test]
fn byte_spans_account_for_multibyte_chars_earlier_in_file() {
let src = "// 🦀 leading multibyte rust crab\n#[aristo::intent(\"c\", id = \"after_crab\")] fn f() {}";
let occs = scan(src);
assert_eq!(occs.len(), 1);
assert_eq!(&src[occs[0].byte_start..occs[0].byte_end], "after_crab");
}
}