impish/lib.rs
1//! Contract tests for Rust: a test that names the promise it proves, checked
2//! as it compiles.
3//!
4//! A *Contract* is the list of promises a piece of code makes, written down in
5//! `contract/<primitive>/README.md` as numbered *obligations*. A contract test
6//! names the obligations it proves with [`#[impish::contract]`](macro@contract):
7//!
8//! ```rust,ignore
9//! #[impish::contract("slugify/A1")]
10//! fn lowercases_letters_and_digits() {
11//! assert_eq!(slug::slugify("Rust 2024"), "rust-2024");
12//! }
13//! ```
14//!
15//! The attribute reads `contract/slugify/README.md` while the test compiles. If
16//! `A1` isn't declared there, or is retired, the test doesn't compile, so a
17//! typo or a broken promise can't slip into a green build.
18//!
19//! This crate is the attribute only. The [`cargo-impish`] command checks that
20//! every obligation has a test (`cargo impish check`) and that the tests catch
21//! real bugs (`cargo impish mutate`). The [README] explains the method.
22//!
23//! # Getting started
24//!
25//! Add the crate as a dev-dependency, and the command once per machine:
26//!
27//! ```sh
28//! cargo add --dev impish
29//! cargo install cargo-impish
30//! ```
31//!
32//! Write the Contract for a primitive, here `slugify`, in
33//! `contract/slugify/README.md`:
34//!
35//! ````markdown
36//! # slugify
37//!
38//! Turns a title into a URL slug.
39//!
40//! ## Obligations
41//!
42//! ### A1 — Letters and digits are kept, lowercased
43//! - **must:** keep ASCII letters and digits, lowercased
44//! - **kind:** functional
45//! - **status:** active
46//!
47//! ```covers
48//! src/lib.rs::slugify
49//! ```
50//! ````
51//!
52//! Prove it in `tests/contract/slugify.rs`, and list the primitive in
53//! `tests/contract/main.rs` (`mod slugify;`), which Cargo builds as the
54//! `contract` test target:
55//!
56//! ```rust,ignore
57//! #[impish::contract("slugify/A1")]
58//! fn lowercases_letters_and_digits() {
59//! assert_eq!(slug::slugify("Rust 2024"), "rust-2024");
60//! }
61//! ```
62//!
63//! Then `cargo test` runs it, and `cargo impish check` confirms every
64//! obligation is proved:
65//!
66//! ```text
67//! The Contract holds: 1 obligations across 1 primitives.
68//! ```
69//!
70//! # Existing tests
71//!
72//! The attribute doesn't replace a test attribute that is already there. Add
73//! it on the line below (or above) `#[test]`, `#[tokio::test]` or any
74//! attribute whose path ends in `test`, and the test still runs once:
75//!
76//! ```rust,ignore
77//! #[tokio::test]
78//! #[impish::contract("fetch/A2")]
79//! async fn retries_after_a_timeout() {
80//! // ...
81//! }
82//! ```
83//!
84//! On its own, the attribute makes the function a `#[test]`.
85//!
86//! # What is checked as the test compiles
87//!
88//! For each tag, the attribute reports a compile error on that tag when:
89//!
90//! | Error | Meaning and fix |
91//! | --- | --- |
92//! | ``name the obligations this test proves: #[impish::contract("<primitive>/A<n>")]`` | The attribute has no tags. Name at least one. Tags are string literals. |
93//! | `` `x` isn't <primitive>/A<n> `` | A tag is not a primitive and an obligation number, such as `"slugify/A1"`. |
94//! | `` `x`: the primitive must be letters, digits, `-` and `_` `` | A tag tried to name a path outside `contract/`. Nothing is read. |
95//! | `` `x`: contract/p/README.md can't be read `` | The primitive has no Contract yet. Create its README, or fix the name. |
96//! | ``…isn't a valid Contract; `cargo impish check` names its problems`` | The README doesn't parse. Run `cargo impish check` for the line. |
97//! | `` `x`: contract/p/README.md doesn't declare A9 `` | No such obligation. Add it to the Contract first, or fix the number. |
98//! | `` `x`: contract/p/README.md declares A2 retired `` | The promise was withdrawn. Delete the test, or name the obligation that replaced it. |
99//!
100//! The README is part of the test's build, so editing it checks the tags again
101//! on the next `cargo test`.
102//!
103//! # Where it belongs
104//!
105//! Use the attribute only in `tests/contract/<primitive>.rs`, the tests of
106//! that primitive's Contract; `cargo impish check` reports it anywhere else.
107//! Tags are resolved against `contract/` in the crate being tested
108//! (`CARGO_MANIFEST_DIR`). Several tags on one test are fine:
109//! `#[impish::contract("cli/A1", "cli/A2")]`.
110//!
111//! The Contract format, every `check` and `mutate` message, and how to adopt
112//! impish in an existing crate are in the [README].
113//!
114//! [`cargo-impish`]: https://crates.io/crates/cargo-impish
115//! [README]: https://github.com/Abarbesgaard/impish#readme
116
117use std::path::PathBuf;
118
119use proc_macro::TokenStream;
120use proc_macro2::Span;
121use quote::quote;
122use syn::parse::Parser;
123use syn::punctuated::Punctuated;
124use syn::{Attribute, LitStr, Token};
125
126/// Marks a contract test with the obligations it proves.
127///
128/// ```text
129/// #[impish::contract("<primitive>/A<n>", …)]
130/// ```
131///
132/// Each argument is a string literal naming one obligation: the primitive (a
133/// directory under `contract/`) and the obligation's number in its README.
134/// Every tag is checked against `contract/<primitive>/README.md` as the test
135/// compiles. A tag that is malformed, unknown or retired is a compile error on
136/// that tag; the [crate documentation](crate#what-is-checked-as-the-test-compiles)
137/// lists each error and its fix.
138///
139/// The function becomes a `#[test]`, unless it already has a test attribute
140/// (`#[test]`, `#[tokio::test]` or any path ending in `test`), above or below
141/// this one, which it keeps:
142///
143/// ```rust,ignore
144/// #[impish::contract("parse/A1", "parse/A3")]
145/// fn rejects_an_empty_line() {
146/// assert!(parse("").is_err());
147/// }
148///
149/// #[tokio::test]
150/// #[impish::contract("fetch/A2")]
151/// async fn retries_after_a_timeout() {
152/// // ...
153/// }
154/// ```
155///
156/// `#[ignore]` works as usual, but an ignored test proves nothing:
157/// `cargo impish check` reports its obligations as unproved.
158#[proc_macro_attribute]
159pub fn contract(attr: TokenStream, item: TokenStream) -> TokenStream {
160 let item = proc_macro2::TokenStream::from(item);
161 let names = match Punctuated::<LitStr, Token![,]>::parse_terminated.parse(attr) {
162 Ok(names) if !names.is_empty() => names,
163 Ok(_) => {
164 return syn::Error::new(
165 Span::call_site(),
166 "name the obligations this test proves: #[impish::contract(\"<primitive>/A<n>\")]",
167 )
168 .into_compile_error()
169 .into();
170 }
171 Err(e) => return e.into_compile_error().into(),
172 };
173 let root = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default());
174 let mut readmes = Vec::new();
175 let mut errors = Vec::new();
176 for name in &names {
177 match check(&root, &name.value()) {
178 Ok(readme) => readmes.push(readme),
179 Err(problem) => errors.push(syn::Error::new(name.span(), problem).into_compile_error()),
180 }
181 }
182 let test = (!is_test(&item)).then(|| quote!(#[test]));
183 // Reading each README into the build makes cargo check the tags again when one changes.
184 quote! {
185 #(#errors)*
186 #(const _: &[u8] = include_bytes!(#readmes);)*
187 #test
188 #item
189 }
190 .into()
191}
192
193/// Whether `item` already carries a test attribute: `#[test]`, or one whose
194/// path ends in `test`. When one sits above this attribute, rustc expands it
195/// first and passes the bare function on, so the source file is asked too.
196fn is_test(item: &proc_macro2::TokenStream) -> bool {
197 let Ok(item) = syn::parse2::<syn::ItemFn>(item.clone()) else {
198 return false;
199 };
200 has_test(&item.attrs) || tested_in_source(&item)
201}
202
203fn has_test(attrs: &[Attribute]) -> bool {
204 attrs
205 .iter()
206 .any(|a| a.path().segments.last().is_some_and(|s| s.ident == "test"))
207}
208
209/// Whether the file this attribute is written in has `item`, the same
210/// signature and body, with a test attribute.
211fn tested_in_source(item: &syn::ItemFn) -> bool {
212 let Some(file) = proc_macro::Span::call_site().local_file() else {
213 return false;
214 };
215 let Ok(text) = std::fs::read_to_string(file) else {
216 return false;
217 };
218 let Ok(file) = syn::parse_file(&text) else {
219 return false;
220 };
221 let same = |f: &syn::ItemFn| {
222 let tokens = |f: &syn::ItemFn| {
223 let (sig, block) = (&f.sig, &f.block);
224 quote!(#sig #block).to_string()
225 };
226 tokens(f) == tokens(item)
227 };
228 fn any(items: &[syn::Item], same: &dyn Fn(&syn::ItemFn) -> bool) -> bool {
229 items.iter().any(|item| match item {
230 syn::Item::Fn(f) => same(f) && has_test(&f.attrs),
231 syn::Item::Mod(m) => m
232 .content
233 .as_ref()
234 .is_some_and(|(_, items)| any(items, same)),
235 _ => false,
236 })
237 }
238 any(&file.items, &same)
239}
240
241/// The README that declares `tag`'s obligation, or why it doesn't.
242fn check(root: &std::path::Path, tag: &str) -> Result<String, String> {
243 let (primitive, id) = tag
244 .rsplit_once("/A")
245 .and_then(|(p, n)| Some((p, n.parse::<u32>().ok()?)))
246 .ok_or_else(|| format!("`{tag}` isn't <primitive>/A<n>"))?;
247 let plain = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_';
248 if primitive.is_empty() || !primitive.chars().all(plain) {
249 return Err(format!(
250 "`{tag}`: the primitive must be letters, digits, `-` and `_`"
251 ));
252 }
253 let file = format!("contract/{primitive}/README.md");
254 let path = root.join(&file);
255 let text =
256 std::fs::read_to_string(&path).map_err(|_| format!("`{tag}`: {file} can't be read"))?;
257 let declared = impish_core::obligations(&text, &file).map_err(|_| {
258 format!("`{tag}`: {file} isn't a valid Contract; `cargo impish check` names its problems")
259 })?;
260 match declared.get(&id) {
261 None => Err(format!("`{tag}`: {file} doesn't declare A{id}")),
262 Some(o) if o.retired => Err(format!("`{tag}`: {file} declares A{id} retired")),
263 Some(_) => Ok(path.display().to_string()),
264 }
265}