Skip to content

Commit 802c581

Browse files
authored
[anodized] feat: try_call! macro (#158)
- Introduce the `try_call!` proc macro in `anodized::runtime`. - Add a stand-alone example: `bin-search`. - Add a fuzz test to `bin-search`, using `cargo-fuzz` and the new `try_call!` macro. - Extend the CI setup to include the new `bin-search` example. - Set the minimum Rust version to 1.95.
1 parent b15af9d commit 802c581

20 files changed

Lines changed: 351 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,37 @@ jobs:
150150

151151
- name: Smoke test fuzz target (fmt_ws_invariant)
152152
run: cargo +nightly fuzz build
153+
154+
test-example-bin-search:
155+
name: Lint and smoke-test the `bin-search` example
156+
runs-on: ubuntu-latest
157+
needs: fmt
158+
defaults:
159+
run:
160+
working-directory: ./examples/bin-search
161+
steps:
162+
- name: Checkout repository
163+
uses: actions/checkout@v4
164+
165+
- name: Install Rust nightly toolchain
166+
uses: dtolnay/rust-toolchain@nightly
167+
with:
168+
components: clippy rustfmt
169+
170+
- name: Check formatting
171+
run: cargo fmt --all -- --check
172+
173+
- name: Lint
174+
run: cargo clippy --all-targets -- -D warnings
175+
176+
- name: Build
177+
run: cargo build
178+
179+
- name: Lint fuzz targets
180+
run: cargo clippy --manifest-path ./fuzz/Cargo.toml --all-targets --config 'build.rustflags=["--cfg","anodized_print", "--cfg","anodized_panic", "--cfg","anodized_split_func"]' -- -D warnings
181+
182+
- name: Install cargo-fuzz
183+
run: cargo install cargo-fuzz
184+
185+
- name: Build fuzz targets
186+
run: cargo +nightly fuzz build -Ztarget-applies-to-host -Zhost-config

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ members = [
88
"crates/test-util",
99
"fuzz",
1010
]
11+
exclude = [
12+
"examples/bin-search",
13+
]
1114
resolver = "2"
1215

1316
[workspace.package]
1417
version = "0.5.1"
1518
edition = "2024"
19+
rust-version = "1.95"
1620
license = "MIT OR Apache-2.0"
1721
repository = "https://github.com/anodized-rs/anodized"
1822
description = "A common specification layer for Rust"

crates/anodized-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ description = "Core interoperability for the Anodized specification system"
44

55
version.workspace = true
66
edition.workspace = true
7+
rust-version.workspace = true
78
readme = "README.md"
89
repository.workspace = true
910
categories.workspace = true

crates/anodized-core/src/instrument.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ impl Mode {
4242
!matches!(self, Mode::ChangeNothing)
4343
}
4444

45+
pub fn does_split_func(&self) -> bool {
46+
if let Self::InjectChecks(check_settings) = self
47+
&& let Some(panic_settings) = &check_settings.does_panic
48+
{
49+
panic_settings.split_func
50+
} else {
51+
false
52+
}
53+
}
54+
4555
pub fn with_split_func(&self, value: bool) -> Self {
4656
match self {
4757
Mode::ChangeNothing => Mode::ChangeNothing,
@@ -136,10 +146,7 @@ Instead, you likely need to place a `#[spec]` attribute on an enclosing trait or
136146
}
137147

138148
fn build_split_fn(is_impl: bool, sig: &mut Signature, body: &mut Block) -> Ident {
139-
let mangled_ident = Ident::new(
140-
&format!("__anodized_fn_split_{}", sig.ident),
141-
sig.ident.span(),
142-
);
149+
let mangled_ident = fns::make_split_fn_ident(&sig.ident);
143150

144151
Self::build_wrapper_fn_signature(sig);
145152

crates/anodized-core/src/instrument/fns.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,3 +378,31 @@ impl CheckSettings {
378378
}
379379
}
380380
}
381+
382+
pub(crate) fn make_split_fn_ident(ident: &Ident) -> Ident {
383+
Ident::new(&format!("__anodized_fn_split_{ident}"), ident.span())
384+
}
385+
386+
pub fn make_try_call(mut expr: Expr) -> Result<Expr> {
387+
match &mut expr {
388+
Expr::Call(fn_call) => {
389+
if let Expr::Path(path) = fn_call.func.as_mut()
390+
&& (path.qself.is_some() || path.path.segments.len() > 1)
391+
{
392+
let last_segment = path.path.segments.last_mut().expect("last segment");
393+
last_segment.ident = make_split_fn_ident(&last_segment.ident);
394+
return Ok(expr);
395+
}
396+
}
397+
Expr::MethodCall(method_call) => {
398+
method_call.method = make_split_fn_ident(&method_call.method);
399+
return Ok(expr);
400+
}
401+
_ => {}
402+
}
403+
404+
Err(syn::Error::new_spanned(
405+
expr,
406+
"must be a method call or a qualified function call",
407+
))
408+
}

crates/anodized-core/src/instrument/fns_tests.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,3 +1015,85 @@ fn captures() {
10151015
.unwrap();
10161016
assert_tokens_eq(&observed, &expected);
10171017
}
1018+
1019+
#[test]
1020+
fn try_call_free_fn() {
1021+
let input: Expr = parse_quote! {
1022+
module::FUNC(arg_1, arg_2)
1023+
};
1024+
1025+
let expected: Expr = parse_quote! {
1026+
module::__anodized_fn_split_FUNC(arg_1, arg_2)
1027+
};
1028+
1029+
let observed = make_try_call(input).expect("tryify");
1030+
assert_eq!(expected, observed);
1031+
}
1032+
1033+
#[test]
1034+
fn try_call_method() {
1035+
let input: Expr = parse_quote! {
1036+
receiver.METHOD(arg_1, arg_2)
1037+
};
1038+
1039+
let expected: Expr = parse_quote! {
1040+
receiver.__anodized_fn_split_METHOD(arg_1, arg_2)
1041+
};
1042+
1043+
let observed = make_try_call(input).expect("tryify");
1044+
assert_eq!(expected, observed);
1045+
}
1046+
1047+
#[test]
1048+
fn try_call_associated_fn() {
1049+
let input: Expr = parse_quote! {
1050+
Type::FUNC(arg_1, arg_2)
1051+
};
1052+
1053+
let expected: Expr = parse_quote! {
1054+
Type::__anodized_fn_split_FUNC(arg_1, arg_2)
1055+
};
1056+
1057+
let observed = make_try_call(input).expect("tryify");
1058+
assert_eq!(expected, observed);
1059+
}
1060+
1061+
#[test]
1062+
fn try_call_turbofish_associated_fn() {
1063+
let input: Expr = parse_quote! {
1064+
<Type>::FUNC(arg_1, arg_2)
1065+
};
1066+
1067+
let expected: Expr = parse_quote! {
1068+
<Type>::__anodized_fn_split_FUNC(arg_1, arg_2)
1069+
};
1070+
1071+
let observed = make_try_call(input).expect("tryify");
1072+
assert_eq!(expected, observed);
1073+
}
1074+
1075+
#[test]
1076+
fn try_call_trait_fn() {
1077+
let input: Expr = parse_quote! {
1078+
<Type as Trait>::FUNC(arg_1, arg_2)
1079+
};
1080+
1081+
let expected: Expr = parse_quote! {
1082+
<Type as Trait>::__anodized_fn_split_FUNC(arg_1, arg_2)
1083+
};
1084+
1085+
let observed = make_try_call(input).expect("tryify");
1086+
assert_eq!(expected, observed);
1087+
}
1088+
1089+
#[test]
1090+
fn try_call_invalid() {
1091+
let input = parse_quote! {
1092+
free_fn(value)
1093+
};
1094+
let error = make_try_call(input).expect_err("invalid input");
1095+
assert_eq!(
1096+
error.to_string(),
1097+
"must be a method call or a qualified function call",
1098+
);
1099+
}

crates/anodized-fmt/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ description = "Formatter for #[spec] annotations in Anodized"
44

55
version.workspace = true
66
edition.workspace = true
7+
rust-version.workspace = true
78
readme = "README.md"
89
repository.workspace = true
910
categories.workspace = true

crates/anodized-logic/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ description = "Helps Anodized embed non-trivial elements of logic into Rust code
44

55
version.workspace = true
66
edition.workspace = true
7+
rust-version.workspace = true
78
readme = "README.md"
89
repository.workspace = true
910
categories.workspace = true

crates/anodized-macros/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ description = "Proc macros used by the Anodized specification system"
44

55
version.workspace = true
66
edition.workspace = true
7+
rust-version.workspace = true
78
readme = "README.md"
89
repository.workspace = true
910
categories.workspace = true

crates/anodized-macros/src/lib.rs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
#![doc = include_str!("../README.md")]
22

33
use proc_macro::TokenStream;
4-
use syn::{Item, TraitItemFn, parse_macro_input};
4+
use proc_macro2::Span;
5+
use quote::ToTokens;
6+
use syn::{Expr, Item, TraitItemFn, parse_macro_input};
57

68
use anodized_core::{
79
DataSpec, Spec,
8-
instrument::{CheckSettings, Mode, PanicSettings, make_item_error},
10+
instrument::{CheckSettings, Mode, PanicSettings, fns::make_try_call, make_item_error},
911
};
1012

1113
const CONFIG: Mode = if cfg!(anodized_discard_specs) {
@@ -87,3 +89,38 @@ pub fn spec(args: TokenStream, input: TokenStream) -> TokenStream {
8789
Err(e) => e.to_compile_error().into(),
8890
}
8991
}
92+
93+
/// Try to call a function with a `spec` and defer acting on pre/postcondition failures.
94+
///
95+
/// Returns `Result<T, (bool, String)>` where `T` is the original return type:
96+
/// - `Ok(output)` if pre- and postconditions passed.
97+
/// - `Err((false, errors))` if preconditions failed.
98+
/// - `Err((true, errors))` if postconditions failed.
99+
///
100+
/// The macro must wrap a call expression, targeting one of the following cases:
101+
/// - a free function with a qualified name:
102+
/// - `qualified::free_fn(...)`
103+
/// - a method:
104+
/// - `receiver.method(...)`
105+
/// - a function qualified by a type or trait:
106+
/// - `Type::associated_fn(...)`
107+
/// - `<Type>::associated_fn(...)`
108+
/// - `<Type as Trait>::trait_fn(...)`
109+
#[proc_macro]
110+
pub fn try_call(args: TokenStream) -> TokenStream {
111+
if !CONFIG.does_split_func() {
112+
return syn::Error::new(
113+
Span::call_site(),
114+
"`try_call` needs the `anodized_split_func` build `cfg` to be enabled",
115+
)
116+
.to_compile_error()
117+
.into();
118+
}
119+
120+
let expr = parse_macro_input!(args as Expr);
121+
122+
match make_try_call(expr) {
123+
Ok(call) => call.into_token_stream().into(),
124+
Err(error) => error.to_compile_error().into(),
125+
}
126+
}

0 commit comments

Comments
 (0)