## Problem being solved
- We need a single, reliable way to mark app-server API surface as
experimental so that:
1. the runtime can reject experimental usage unless the client opts in
2. generated TS/JSON schemas can exclude experimental methods/fields for
stable clients.
Right now that’s easy to drift or miss when done ad-hoc.
## How to declare experimental methods and fields
- **Experimental method**: add `#[experimental("method/name")]` to the
`ClientRequest` variant in `client_request_definitions!`.
- **Experimental field**: on the params struct, derive `ExperimentalApi`
and annotate the field with `#[experimental("method/name.field")]` + set
`inspect_params: true` for the method variant so
`ClientRequest::experimental_reason()` inspects params for experimental
fields.
## How the macro solves it
- The new derive macro lives in
`codex-rs/codex-experimental-api-macros/src/lib.rs` and is used via
`#[derive(ExperimentalApi)]` plus `#[experimental("reason")]`
attributes.
- **Structs**:
- Generates `ExperimentalApi::experimental_reason(&self)` that checks
only annotated fields.
- The “presence” check is type-aware:
- `Option<T>`: `is_some_and(...)` recursively checks inner.
- `Vec`/`HashMap`/`BTreeMap`: must be non-empty.
- `bool`: must be `true`.
- Other types: considered present (returns `true`).
- Registers each experimental field in an `inventory` with `(type_name,
serialized field name, reason)` and exposes `EXPERIMENTAL_FIELDS` for
that type. Field names are converted from `snake_case` to `camelCase`
for schema/TS filtering.
- **Enums**:
- Generates an exhaustive `match` returning `Some(reason)` for annotated
variants and `None` otherwise (no wildcard arm).
- **Wiring**:
- Runtime gating uses `ExperimentalApi::experimental_reason()` in
`codex-rs/app-server/src/message_processor.rs` to reject requests unless
`InitializeParams.capabilities.experimental_api == true`.
- Schema/TS export filters use the inventory list and
`EXPERIMENTAL_CLIENT_METHODS` from `client_request_definitions!` to
strip experimental methods/fields when `experimental_api` is false.
293 lines
9.2 KiB
Rust
293 lines
9.2 KiB
Rust
use proc_macro::TokenStream;
|
|
use proc_macro2::Span;
|
|
use quote::quote;
|
|
use syn::Attribute;
|
|
use syn::Data;
|
|
use syn::DataEnum;
|
|
use syn::DataStruct;
|
|
use syn::DeriveInput;
|
|
use syn::Field;
|
|
use syn::Fields;
|
|
use syn::Ident;
|
|
use syn::LitStr;
|
|
use syn::Type;
|
|
use syn::parse_macro_input;
|
|
|
|
#[proc_macro_derive(ExperimentalApi, attributes(experimental))]
|
|
pub fn derive_experimental_api(input: TokenStream) -> TokenStream {
|
|
let input = parse_macro_input!(input as DeriveInput);
|
|
match &input.data {
|
|
Data::Struct(data) => derive_for_struct(&input, data),
|
|
Data::Enum(data) => derive_for_enum(&input, data),
|
|
Data::Union(_) => {
|
|
syn::Error::new_spanned(&input.ident, "ExperimentalApi does not support unions")
|
|
.to_compile_error()
|
|
.into()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn derive_for_struct(input: &DeriveInput, data: &DataStruct) -> TokenStream {
|
|
let name = &input.ident;
|
|
let type_name_lit = LitStr::new(&name.to_string(), Span::call_site());
|
|
|
|
let (checks, experimental_fields, registrations) = match &data.fields {
|
|
Fields::Named(named) => {
|
|
let mut checks = Vec::new();
|
|
let mut experimental_fields = Vec::new();
|
|
let mut registrations = Vec::new();
|
|
for field in &named.named {
|
|
let reason = experimental_reason(&field.attrs);
|
|
if let Some(reason) = reason {
|
|
let expr = experimental_presence_expr(field, false);
|
|
checks.push(quote! {
|
|
if #expr {
|
|
return Some(#reason);
|
|
}
|
|
});
|
|
|
|
if let Some(field_name) = field_serialized_name(field) {
|
|
let field_name_lit = LitStr::new(&field_name, Span::call_site());
|
|
experimental_fields.push(quote! {
|
|
crate::experimental_api::ExperimentalField {
|
|
type_name: #type_name_lit,
|
|
field_name: #field_name_lit,
|
|
reason: #reason,
|
|
}
|
|
});
|
|
registrations.push(quote! {
|
|
::inventory::submit! {
|
|
crate::experimental_api::ExperimentalField {
|
|
type_name: #type_name_lit,
|
|
field_name: #field_name_lit,
|
|
reason: #reason,
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
(checks, experimental_fields, registrations)
|
|
}
|
|
Fields::Unnamed(unnamed) => {
|
|
let mut checks = Vec::new();
|
|
let mut experimental_fields = Vec::new();
|
|
let mut registrations = Vec::new();
|
|
for (index, field) in unnamed.unnamed.iter().enumerate() {
|
|
let reason = experimental_reason(&field.attrs);
|
|
if let Some(reason) = reason {
|
|
let expr = index_presence_expr(index, &field.ty);
|
|
checks.push(quote! {
|
|
if #expr {
|
|
return Some(#reason);
|
|
}
|
|
});
|
|
|
|
let field_name_lit = LitStr::new(&index.to_string(), Span::call_site());
|
|
experimental_fields.push(quote! {
|
|
crate::experimental_api::ExperimentalField {
|
|
type_name: #type_name_lit,
|
|
field_name: #field_name_lit,
|
|
reason: #reason,
|
|
}
|
|
});
|
|
registrations.push(quote! {
|
|
::inventory::submit! {
|
|
crate::experimental_api::ExperimentalField {
|
|
type_name: #type_name_lit,
|
|
field_name: #field_name_lit,
|
|
reason: #reason,
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
(checks, experimental_fields, registrations)
|
|
}
|
|
Fields::Unit => (Vec::new(), Vec::new(), Vec::new()),
|
|
};
|
|
|
|
let checks = if checks.is_empty() {
|
|
quote! { None }
|
|
} else {
|
|
quote! {
|
|
#(#checks)*
|
|
None
|
|
}
|
|
};
|
|
|
|
let experimental_fields = if experimental_fields.is_empty() {
|
|
quote! { &[] }
|
|
} else {
|
|
quote! { &[ #(#experimental_fields,)* ] }
|
|
};
|
|
|
|
let expanded = quote! {
|
|
#(#registrations)*
|
|
|
|
impl #name {
|
|
pub(crate) const EXPERIMENTAL_FIELDS: &'static [crate::experimental_api::ExperimentalField] =
|
|
#experimental_fields;
|
|
}
|
|
|
|
impl crate::experimental_api::ExperimentalApi for #name {
|
|
fn experimental_reason(&self) -> Option<&'static str> {
|
|
#checks
|
|
}
|
|
}
|
|
};
|
|
expanded.into()
|
|
}
|
|
|
|
fn derive_for_enum(input: &DeriveInput, data: &DataEnum) -> TokenStream {
|
|
let name = &input.ident;
|
|
let mut match_arms = Vec::new();
|
|
|
|
for variant in &data.variants {
|
|
let variant_name = &variant.ident;
|
|
let pattern = match &variant.fields {
|
|
Fields::Named(_) => quote!(Self::#variant_name { .. }),
|
|
Fields::Unnamed(_) => quote!(Self::#variant_name ( .. )),
|
|
Fields::Unit => quote!(Self::#variant_name),
|
|
};
|
|
let reason = experimental_reason(&variant.attrs);
|
|
if let Some(reason) = reason {
|
|
match_arms.push(quote! {
|
|
#pattern => Some(#reason),
|
|
});
|
|
} else {
|
|
match_arms.push(quote! {
|
|
#pattern => None,
|
|
});
|
|
}
|
|
}
|
|
|
|
let expanded = quote! {
|
|
impl crate::experimental_api::ExperimentalApi for #name {
|
|
fn experimental_reason(&self) -> Option<&'static str> {
|
|
match self {
|
|
#(#match_arms)*
|
|
}
|
|
}
|
|
}
|
|
};
|
|
expanded.into()
|
|
}
|
|
|
|
fn experimental_reason(attrs: &[Attribute]) -> Option<LitStr> {
|
|
let attr = attrs
|
|
.iter()
|
|
.find(|attr| attr.path().is_ident("experimental"))?;
|
|
attr.parse_args::<LitStr>().ok()
|
|
}
|
|
|
|
fn field_serialized_name(field: &Field) -> Option<String> {
|
|
let ident = field.ident.as_ref()?;
|
|
let name = ident.to_string();
|
|
Some(snake_to_camel(&name))
|
|
}
|
|
|
|
fn snake_to_camel(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let mut upper = false;
|
|
for ch in s.chars() {
|
|
if ch == '_' {
|
|
upper = true;
|
|
continue;
|
|
}
|
|
if upper {
|
|
out.push(ch.to_ascii_uppercase());
|
|
upper = false;
|
|
} else {
|
|
out.push(ch);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn experimental_presence_expr(
|
|
field: &Field,
|
|
tuple_struct: bool,
|
|
) -> Option<proc_macro2::TokenStream> {
|
|
if tuple_struct {
|
|
return None;
|
|
}
|
|
let ident = field.ident.as_ref()?;
|
|
Some(presence_expr_for_access(quote!(self.#ident), &field.ty))
|
|
}
|
|
|
|
fn index_presence_expr(index: usize, ty: &Type) -> proc_macro2::TokenStream {
|
|
let index = syn::Index::from(index);
|
|
presence_expr_for_access(quote!(self.#index), ty)
|
|
}
|
|
|
|
fn presence_expr_for_access(
|
|
access: proc_macro2::TokenStream,
|
|
ty: &Type,
|
|
) -> proc_macro2::TokenStream {
|
|
if let Some(inner) = option_inner(ty) {
|
|
let inner_expr = presence_expr_for_ref(quote!(value), inner);
|
|
return quote! {
|
|
#access.as_ref().is_some_and(|value| #inner_expr)
|
|
};
|
|
}
|
|
if is_vec_like(ty) || is_map_like(ty) {
|
|
return quote! { !#access.is_empty() };
|
|
}
|
|
if is_bool(ty) {
|
|
return quote! { #access };
|
|
}
|
|
quote! { true }
|
|
}
|
|
|
|
fn presence_expr_for_ref(access: proc_macro2::TokenStream, ty: &Type) -> proc_macro2::TokenStream {
|
|
if let Some(inner) = option_inner(ty) {
|
|
let inner_expr = presence_expr_for_ref(quote!(value), inner);
|
|
return quote! {
|
|
#access.as_ref().is_some_and(|value| #inner_expr)
|
|
};
|
|
}
|
|
if is_vec_like(ty) || is_map_like(ty) {
|
|
return quote! { !#access.is_empty() };
|
|
}
|
|
if is_bool(ty) {
|
|
return quote! { *#access };
|
|
}
|
|
quote! { true }
|
|
}
|
|
|
|
fn option_inner(ty: &Type) -> Option<&Type> {
|
|
let Type::Path(type_path) = ty else {
|
|
return None;
|
|
};
|
|
let segment = type_path.path.segments.last()?;
|
|
if segment.ident != "Option" {
|
|
return None;
|
|
}
|
|
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
|
|
return None;
|
|
};
|
|
args.args.iter().find_map(|arg| match arg {
|
|
syn::GenericArgument::Type(inner) => Some(inner),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn is_vec_like(ty: &Type) -> bool {
|
|
type_last_ident(ty).is_some_and(|ident| ident == "Vec")
|
|
}
|
|
|
|
fn is_map_like(ty: &Type) -> bool {
|
|
type_last_ident(ty).is_some_and(|ident| ident == "HashMap" || ident == "BTreeMap")
|
|
}
|
|
|
|
fn is_bool(ty: &Type) -> bool {
|
|
type_last_ident(ty).is_some_and(|ident| ident == "bool")
|
|
}
|
|
|
|
fn type_last_ident(ty: &Type) -> Option<Ident> {
|
|
let Type::Path(type_path) = ty else {
|
|
return None;
|
|
};
|
|
type_path.path.segments.last().map(|seg| seg.ident.clone())
|
|
}
|