make spec infra more ergonomic

Signed-off-by: Onur Özkan <work@onurozkan.dev>
This commit is contained in:
Onur Özkan committed 2026-07-18 13:28:01 +03:00
1 parent b0a7a9bb03
commit 85559bd2d5
20 files changed
+74 -123

No files matched your search

+18 -33
View File
@@ -1,27 +1,26 @@
use proc_macro::{Literal, TokenStream, TokenTree};
#[proc_macro_attribute]
pub fn spec(attr: TokenStream, item: TokenStream) -> TokenStream {
match parse_spec_path(attr) {
Ok(path) if is_module_item(&item) => expand_spec_guard(&path, item),
Ok(_) => compile_error("`#[spec]` may only be applied to a module"),
#[proc_macro]
pub fn no_spec(input: TokenStream) -> TokenStream {
match parse_no_spec_reason(input) {
Ok(reason) => expand_no_spec_reason(&reason),
Err(message) => compile_error(&message),
}
}
fn parse_spec_path(attr: TokenStream) -> Result<String, String> {
let tokens: Vec<TokenTree> = attr.into_iter().collect();
fn parse_no_spec_reason(input: TokenStream) -> Result<String, String> {
let tokens: Vec<TokenTree> = input.into_iter().collect();
match tokens.as_slice() {
[TokenTree::Literal(path)] if is_string_literal(path) => {
if has_spec_suffix(path) {
if has_non_empty_string(path) {
Ok(path.to_string())
} else {
Err("spec path must end in `.spec.md`; use #[spec(\"file.spec.md\")]".into())
Err("no-spec explanation must not be empty; use no_spec!(\"reason\");".into())
}
}
[] => Err("missing spec path; use #[spec(\"file.spec.md\")]".into()),
_ => Err("invalid spec attribute; use #[spec(\"file.spec.md\")]".into()),
[] => Err("missing no-spec explanation; use no_spec!(\"reason\");".into()),
_ => Err("invalid no-spec invocation; use no_spec!(\"reason\");".into()),
}
}
@@ -31,30 +30,16 @@ fn is_string_literal(literal: &Literal) -> bool {
value.starts_with('"') && value.ends_with('"')
}
fn has_spec_suffix(literal: &Literal) -> bool {
literal
.to_string()
.strip_suffix('"')
.is_some_and(|path| path.ends_with(".spec.md"))
fn has_non_empty_string(literal: &Literal) -> bool {
let value = literal.to_string();
!value[1..value.len() - 1].trim().is_empty()
}
fn is_module_item(item: &TokenStream) -> bool {
item.clone().into_iter().any(
|token| matches!(token, TokenTree::Ident(identifier) if identifier.to_string() == "mod"),
)
}
fn expand_spec_guard(path: &str, item: TokenStream) -> TokenStream {
let guard = format!("const _: &str = include_str!({path});");
let mut output = TokenStream::new();
output.extend(
guard
.parse::<TokenStream>()
.expect("spec macro must generate valid Rust tokens"),
);
output.extend(item);
output
fn expand_no_spec_reason(reason: &str) -> TokenStream {
format!("const _: &str = {reason};")
.parse::<TokenStream>()
.expect("no_spec macro must generate valid Rust tokens")
}
fn compile_error(message: &str) -> TokenStream {
+4 -7
View File
@@ -1,17 +1,14 @@
#[test]
fn accepts_existing_sidecar_spec() {
fn accepts_no_spec_reason() {
let tests = trybuild::TestCases::new();
tests.pass("tests/ui/flight_core_contract.rs");
tests.pass("tests/ui/no_spec.rs");
}
#[test]
fn rejects_invalid_spec_uses() {
fn rejects_invalid_no_spec_invocations() {
let tests = trybuild::TestCases::new();
tests.compile_fail("tests/ui/fail_missing_argument.rs");
tests.compile_fail("tests/ui/fail_legacy_named_argument.rs");
tests.compile_fail("tests/ui/fail_non_string_argument.rs");
tests.compile_fail("tests/ui/fail_invalid_suffix.rs");
tests.compile_fail("tests/ui/fail_missing_sidecar.rs");
tests.compile_fail("tests/ui/fail_non_module_item.rs");
tests.compile_fail("tests/ui/fail_empty_explanation.rs");
}
@@ -0,0 +1,5 @@
use kaptan_spec::no_spec;
no_spec!(" ");
fn main() {}
@@ -0,0 +1,7 @@
error: no-spec explanation must not be empty; use no_spec!("reason");
--> tests/ui/fail_empty_explanation.rs:3:1
|
3 | no_spec!(" ");
| ^^^^^^^^^^^^^^^
|
= note: this error originates in the macro `no_spec` (in Nightly builds, run with -Z macro-backtrace for more info)
@@ -1,6 +0,0 @@
use kaptan_spec::spec;
#[spec("flight_core_contract.md")]
struct InvalidSuffix;
fn main() {}
@@ -1,7 +0,0 @@
error: spec path must end in `.spec.md`; use #[spec("file.spec.md")]
--> tests/ui/fail_invalid_suffix.rs:3:1
|
3 | #[spec("flight_core_contract.md")]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this error originates in the attribute macro `spec` (in Nightly builds, run with -Z macro-backtrace for more info)
@@ -1,6 +0,0 @@
use kaptan_spec::spec;
#[spec(path = "flight_core_contract.spec.md")]
struct LegacyNamedArgument;
fn main() {}
@@ -1,7 +0,0 @@
error: invalid spec attribute; use #[spec("file.spec.md")]
--> tests/ui/fail_legacy_named_argument.rs:3:1
|
3 | #[spec(path = "flight_core_contract.spec.md")]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this error originates in the attribute macro `spec` (in Nightly builds, run with -Z macro-backtrace for more info)
@@ -1,6 +1,5 @@
use kaptan_spec::spec;
use kaptan_spec::no_spec;
#[spec]
struct MissingArgument;
no_spec!();
fn main() {}
@@ -1,7 +1,7 @@
error: missing spec path; use #[spec("file.spec.md")]
error: missing no-spec explanation; use no_spec!("reason");
--> tests/ui/fail_missing_argument.rs:3:1
|
3 | #[spec]
| ^^^^^^^
3 | no_spec!();
| ^^^^^^^^^^
|
= note: this error originates in the attribute macro `spec` (in Nightly builds, run with -Z macro-backtrace for more info)
= note: this error originates in the macro `no_spec` (in Nightly builds, run with -Z macro-backtrace for more info)
@@ -1,7 +0,0 @@
use kaptan_spec::spec;
#[spec("fail_missing_sidecar.spec.md")]
mod missing_spec {}
fn main() {
}
@@ -1,5 +0,0 @@
error: couldn't read `$DIR/tests/ui/fail_missing_sidecar.spec.md`: No such file or directory (os error 2)
--> tests/ui/fail_missing_sidecar.rs:3:1
|
3 | #[spec("fail_missing_sidecar.spec.md")]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -1,6 +0,0 @@
use kaptan_spec::spec;
#[spec("flight_core_contract.spec.md")]
struct FlightCore;
fn main() {}
@@ -1,7 +0,0 @@
error: `#[spec]` may only be applied to a module
--> tests/ui/fail_non_module_item.rs:3:1
|
3 | #[spec("flight_core_contract.spec.md")]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this error originates in the attribute macro `spec` (in Nightly builds, run with -Z macro-backtrace for more info)
@@ -1,6 +1,5 @@
use kaptan_spec::spec;
use kaptan_spec::no_spec;
#[spec(42)]
struct NonStringArgument;
no_spec!(42);
fn main() {}
@@ -1,7 +1,7 @@
error: invalid spec attribute; use #[spec("file.spec.md")]
error: invalid no-spec invocation; use no_spec!("reason");
--> tests/ui/fail_non_string_argument.rs:3:1
|
3 | #[spec(42)]
| ^^^^^^^^^^^
3 | no_spec!(42);
| ^^^^^^^^^^^^
|
= note: this error originates in the attribute macro `spec` (in Nightly builds, run with -Z macro-backtrace for more info)
= note: this error originates in the macro `no_spec` (in Nightly builds, run with -Z macro-backtrace for more info)
+1 -2
View File
@@ -1,7 +1,6 @@
use kaptan_spec::spec;
#[spec("flight_core_contract.spec.md")]
mod flight_core {}
spec!("flight_core_contract.spec.md");
fn main() {
assert_eq!(core::mem::size_of::<()>(), 0);
+5
View File
@@ -0,0 +1,5 @@
use kaptan_spec::no_spec;
no_spec!("This fixture has no governed runtime behavior.");
fn main() {}