diff --git a/Cargo.lock b/Cargo.lock index 739ba9de6a..f2c5f0050d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1021,6 +1021,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-db-backend-macro" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "trybuild", +] + [[package]] name = "buzz-dev-mcp" version = "0.1.0" @@ -3259,6 +3269,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "globset" version = "0.4.18" @@ -9488,6 +9504,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-triple" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" + [[package]] name = "tempfile" version = "3.27.0" @@ -9501,6 +9523,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termina" version = "0.3.3" @@ -10178,6 +10209,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 1.1.2+spec-1.1.0", +] + [[package]] name = "tungstenite" version = "0.26.2" diff --git a/Cargo.toml b/Cargo.toml index 98d68fb521..be9cb5aa7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/buzz-conformance", "crates/buzz-push-gateway", "crates/buzz-db", + "crates/buzz-db-backend-macro", "crates/buzz-pubsub", "crates/buzz-auth", "crates/buzz-search", diff --git a/crates/buzz-db-backend-macro/Cargo.toml b/crates/buzz-db-backend-macro/Cargo.toml new file mode 100644 index 0000000000..31c4718aba --- /dev/null +++ b/crates/buzz-db-backend-macro/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "buzz-db-backend-macro" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } + +[dev-dependencies] +trybuild = "1" diff --git a/crates/buzz-db-backend-macro/src/lib.rs b/crates/buzz-db-backend-macro/src/lib.rs new file mode 100644 index 0000000000..8ba800378b --- /dev/null +++ b/crates/buzz-db-backend-macro/src/lib.rs @@ -0,0 +1,104 @@ +//! Compile-time enforcement for explicit SQLite decisions on public `Db` methods. +//! +//! The impl-level attribute inspects only public associated methods. Private and +//! `pub(crate)` plumbing is intentionally outside this policy boundary. + +use proc_macro::TokenStream; +use quote::quote; +use syn::spanned::Spanned; +use syn::{parse_macro_input, Attribute, ImplItem, ItemImpl, LitStr, Visibility}; + +#[proc_macro_attribute] +pub fn enforce_sqlite_backend_declarations(_args: TokenStream, input: TokenStream) -> TokenStream { + let mut item = parse_macro_input!(input as ItemImpl); + let mut errors = Vec::new(); + let mut inventory = Vec::new(); + + for impl_item in &mut item.items { + let ImplItem::Fn(method) = impl_item else { + continue; + }; + if !matches!(method.vis, Visibility::Public(_)) { + continue; + } + + let mut markers = Vec::new(); + let mut retained = Vec::new(); + for attr in method.attrs.drain(..) { + if attr.path().is_ident("sqlite_backend") { + markers.push(attr); + } else { + retained.push(attr); + } + } + method.attrs = retained; + + let name = method.sig.ident.to_string(); + match markers.as_slice() { + [] => errors.push(syn::Error::new( + method.sig.ident.span(), + format!("public Db method `{name}` is missing #[sqlite_backend(...)]"), + )), + [marker] => match parse_marker(marker) { + Ok(reason) => inventory.push((name, reason)), + Err(error) => errors.push(error), + }, + _ => errors.push(syn::Error::new( + method.sig.ident.span(), + format!("public Db method `{name}` has duplicate #[sqlite_backend] markers"), + )), + } + } + + inventory.sort_by(|a, b| a.0.cmp(&b.0)); + let names = inventory + .iter() + .map(|(name, _)| LitStr::new(name, proc_macro2::Span::call_site())); + let reasons = inventory.iter().map(|(_, reason)| match reason { + Some(reason) => quote! { Some(#reason) }, + None => quote! { None }, + }); + let errors = errors.into_iter().map(|e| e.to_compile_error()); + + quote! { + #(#errors)* + #item + #[doc(hidden)] + pub const SQLITE_BACKEND_INVENTORY: &[(&str, Option<&str>)] = &[#((#names, #reasons)),*]; + } + .into() +} + +fn parse_marker(attr: &Attribute) -> syn::Result> { + let mut result = None; + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("implemented") { + if result.is_some() { + return Err(meta.error("duplicate backend decision")); + } + result = Some(None); + return Ok(()); + } + if meta.path.is_ident("unsupported") { + if result.is_some() { + return Err(meta.error("duplicate backend decision")); + } + let value = meta.value()?.parse::()?; + if value.value().trim().is_empty() { + return Err(syn::Error::new( + value.span(), + "unsupported reason must not be empty", + )); + } + result = Some(Some(value)); + return Ok(()); + } + Err(meta.error("expected `implemented` or `unsupported = \"reason\"`")) + })?; + result.ok_or_else(|| { + syn::Error::new( + attr.span(), + "backend decision must be `implemented` or `unsupported = \"reason\"`", + ) + }) +} diff --git a/crates/buzz-db-backend-macro/tests/compile.rs b/crates/buzz-db-backend-macro/tests/compile.rs new file mode 100644 index 0000000000..f7fc2773fd --- /dev/null +++ b/crates/buzz-db-backend-macro/tests/compile.rs @@ -0,0 +1,9 @@ +#[test] +fn ui() { + let tests = trybuild::TestCases::new(); + tests.compile_fail("tests/ui/missing_marker.rs"); + tests.compile_fail("tests/ui/duplicate_marker.rs"); + tests.compile_fail("tests/ui/empty_reason.rs"); + tests.compile_fail("tests/ui/malformed_marker.rs"); + tests.pass("tests/ui/valid_markers.rs"); +} diff --git a/crates/buzz-db-backend-macro/tests/ui/duplicate_marker.rs b/crates/buzz-db-backend-macro/tests/ui/duplicate_marker.rs new file mode 100644 index 0000000000..1059d2ae9f --- /dev/null +++ b/crates/buzz-db-backend-macro/tests/ui/duplicate_marker.rs @@ -0,0 +1,9 @@ +use buzz_db_backend_macro::enforce_sqlite_backend_declarations; +struct Db; +#[enforce_sqlite_backend_declarations] +impl Db { + #[sqlite_backend(implemented)] + #[sqlite_backend(implemented)] + pub fn duplicated(&self) {} +} +fn main() {} diff --git a/crates/buzz-db-backend-macro/tests/ui/duplicate_marker.stderr b/crates/buzz-db-backend-macro/tests/ui/duplicate_marker.stderr new file mode 100644 index 0000000000..5784cfc9c8 --- /dev/null +++ b/crates/buzz-db-backend-macro/tests/ui/duplicate_marker.stderr @@ -0,0 +1,5 @@ +error: public Db method `duplicated` has duplicate #[sqlite_backend] markers + --> tests/ui/duplicate_marker.rs:7:12 + | +7 | pub fn duplicated(&self) {} + | ^^^^^^^^^^ diff --git a/crates/buzz-db-backend-macro/tests/ui/empty_reason.rs b/crates/buzz-db-backend-macro/tests/ui/empty_reason.rs new file mode 100644 index 0000000000..4454aa8d41 --- /dev/null +++ b/crates/buzz-db-backend-macro/tests/ui/empty_reason.rs @@ -0,0 +1,8 @@ +use buzz_db_backend_macro::enforce_sqlite_backend_declarations; +struct Db; +#[enforce_sqlite_backend_declarations] +impl Db { + #[sqlite_backend(unsupported = " ")] + pub fn empty(&self) {} +} +fn main() {} diff --git a/crates/buzz-db-backend-macro/tests/ui/empty_reason.stderr b/crates/buzz-db-backend-macro/tests/ui/empty_reason.stderr new file mode 100644 index 0000000000..4fb735d115 --- /dev/null +++ b/crates/buzz-db-backend-macro/tests/ui/empty_reason.stderr @@ -0,0 +1,5 @@ +error: unsupported reason must not be empty + --> tests/ui/empty_reason.rs:5:36 + | +5 | #[sqlite_backend(unsupported = " ")] + | ^^^^ diff --git a/crates/buzz-db-backend-macro/tests/ui/malformed_marker.rs b/crates/buzz-db-backend-macro/tests/ui/malformed_marker.rs new file mode 100644 index 0000000000..69b2b934d1 --- /dev/null +++ b/crates/buzz-db-backend-macro/tests/ui/malformed_marker.rs @@ -0,0 +1,8 @@ +use buzz_db_backend_macro::enforce_sqlite_backend_declarations; +struct Db; +#[enforce_sqlite_backend_declarations] +impl Db { + #[sqlite_backend(sometimes)] + pub fn malformed(&self) {} +} +fn main() {} diff --git a/crates/buzz-db-backend-macro/tests/ui/malformed_marker.stderr b/crates/buzz-db-backend-macro/tests/ui/malformed_marker.stderr new file mode 100644 index 0000000000..c6e3f84432 --- /dev/null +++ b/crates/buzz-db-backend-macro/tests/ui/malformed_marker.stderr @@ -0,0 +1,5 @@ +error: expected `implemented` or `unsupported = "reason"` + --> tests/ui/malformed_marker.rs:5:22 + | +5 | #[sqlite_backend(sometimes)] + | ^^^^^^^^^ diff --git a/crates/buzz-db-backend-macro/tests/ui/missing_marker.rs b/crates/buzz-db-backend-macro/tests/ui/missing_marker.rs new file mode 100644 index 0000000000..f8f735e8e9 --- /dev/null +++ b/crates/buzz-db-backend-macro/tests/ui/missing_marker.rs @@ -0,0 +1,10 @@ +use buzz_db_backend_macro::enforce_sqlite_backend_declarations; + +struct Db; + +#[enforce_sqlite_backend_declarations] +impl Db { + pub fn omitted(&self) {} +} + +fn main() {} diff --git a/crates/buzz-db-backend-macro/tests/ui/missing_marker.stderr b/crates/buzz-db-backend-macro/tests/ui/missing_marker.stderr new file mode 100644 index 0000000000..41ba319441 --- /dev/null +++ b/crates/buzz-db-backend-macro/tests/ui/missing_marker.stderr @@ -0,0 +1,5 @@ +error: public Db method `omitted` is missing #[sqlite_backend(...)] + --> tests/ui/missing_marker.rs:7:12 + | +7 | pub fn omitted(&self) {} + | ^^^^^^^ diff --git a/crates/buzz-db-backend-macro/tests/ui/valid_markers.rs b/crates/buzz-db-backend-macro/tests/ui/valid_markers.rs new file mode 100644 index 0000000000..7bb0258c81 --- /dev/null +++ b/crates/buzz-db-backend-macro/tests/ui/valid_markers.rs @@ -0,0 +1,18 @@ +use buzz_db_backend_macro::enforce_sqlite_backend_declarations; + +struct Db; + +#[enforce_sqlite_backend_declarations] +impl Db { + #[sqlite_backend(implemented)] + #[allow(dead_code)] + pub fn implemented(&self) {} + + /// Deliberately unsupported operation. + #[sqlite_backend(unsupported = "requires postgres-only advisory locks")] + pub async fn unsupported(&self) {} + + fn private(&self) {} +} + +fn main() {}