Skip to content

Commit 6523879

Browse files
authored
fix(macro): nullable parameters #538 (#617)
1 parent 4ef666b commit 6523879

File tree

9 files changed

+358
-45
lines changed

9 files changed

+358
-45
lines changed

build.rs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -231,19 +231,16 @@ fn generate_bindings(defines: &[(&str, &str)], includes: &[PathBuf]) -> Result<S
231231
// Add macOS SDK path for system headers (stdlib.h, etc.)
232232
// Required for libclang 19+ with preserve_none calling convention support
233233
#[cfg(target_os = "macos")]
234+
if let Ok(sdk_path) = std::process::Command::new("xcrun")
235+
.args(["--show-sdk-path"])
236+
.output()
237+
&& sdk_path.status.success()
234238
{
235-
if let Ok(sdk_path) = std::process::Command::new("xcrun")
236-
.args(["--show-sdk-path"])
237-
.output()
238-
{
239-
if sdk_path.status.success() {
240-
let path = String::from_utf8_lossy(&sdk_path.stdout);
241-
let path = path.trim();
242-
bindgen = bindgen
243-
.clang_arg(format!("-isysroot{}", path))
244-
.clang_arg(format!("-I{}/usr/include", path));
245-
}
246-
}
239+
let path = String::from_utf8_lossy(&sdk_path.stdout);
240+
let path = path.trim();
241+
bindgen = bindgen
242+
.clang_arg(format!("-isysroot{path}"))
243+
.clang_arg(format!("-I{path}/usr/include"));
247244
}
248245

249246
bindgen = bindgen

crates/macros/src/function.rs

Lines changed: 174 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ impl<'a> Args<'a> {
493493

494494
// If the variable is `&[&Zval]` treat it as the variadic argument.
495495
let default = defaults.remove(ident);
496-
let nullable = type_is_nullable(ty.as_ref(), default.is_some())?;
496+
let nullable = type_is_nullable(ty.as_ref())?;
497497
let (variadic, as_ref, ty) = Self::parse_typed(ty);
498498
result.typed.push(TypedArg {
499499
name: ident,
@@ -570,18 +570,20 @@ impl<'a> Args<'a> {
570570
/// # Parameters
571571
///
572572
/// * `optional` - The first optional argument. If [`None`], the optional
573-
/// arguments will be from the first nullable argument after the last
574-
/// non-nullable argument to the end of the arguments.
573+
/// arguments will be from the first optional argument (nullable or has
574+
/// default) after the last required argument to the end of the arguments.
575575
pub fn split_args(&self, optional: Option<&Ident>) -> (&[TypedArg<'a>], &[TypedArg<'a>]) {
576576
let mut mid = None;
577577
for (i, arg) in self.typed.iter().enumerate() {
578+
// An argument is optional if it's nullable (Option<T>) or has a default value.
579+
let is_optional = arg.nullable || arg.default.is_some();
578580
if let Some(optional) = optional {
579581
if optional == arg.name {
580582
mid.replace(i);
581583
}
582-
} else if mid.is_none() && arg.nullable {
584+
} else if mid.is_none() && is_optional {
583585
mid.replace(i);
584-
} else if !arg.nullable {
586+
} else if !is_optional {
585587
mid.take();
586588
}
587589
}
@@ -635,7 +637,7 @@ impl TypedArg<'_> {
635637
None
636638
};
637639
let default = self.default.as_ref().map(|val| {
638-
let val = val.to_token_stream().to_string();
640+
let val = expr_to_php_stub(val);
639641
quote! {
640642
.default(#val)
641643
}
@@ -659,8 +661,49 @@ impl TypedArg<'_> {
659661
fn accessor(&self, bail_fn: impl Fn(TokenStream) -> TokenStream) -> TokenStream {
660662
let name = self.name;
661663
if let Some(default) = &self.default {
662-
quote! {
663-
#name.val().unwrap_or(#default.into())
664+
if self.nullable {
665+
// For nullable types with defaults, null is acceptable
666+
quote! {
667+
#name.val().unwrap_or(#default.into())
668+
}
669+
} else {
670+
// For non-nullable types with defaults:
671+
// - If argument was omitted: use default
672+
// - If null was explicitly passed: throw TypeError
673+
// - If a value was passed: try to convert it
674+
let bail_null = bail_fn(quote! {
675+
::ext_php_rs::exception::PhpException::new(
676+
concat!("Argument `$", stringify!(#name), "` must not be null").into(),
677+
0,
678+
::ext_php_rs::zend::ce::type_error(),
679+
)
680+
});
681+
let bail_invalid = bail_fn(quote! {
682+
::ext_php_rs::exception::PhpException::default(
683+
concat!("Invalid value given for argument `", stringify!(#name), "`.").into()
684+
)
685+
});
686+
quote! {
687+
match #name.zval() {
688+
Some(zval) if zval.is_null() => {
689+
// Null was explicitly passed to a non-nullable parameter
690+
#bail_null
691+
}
692+
Some(_) => {
693+
// A value was passed, try to convert it
694+
match #name.val() {
695+
Some(val) => val,
696+
None => {
697+
#bail_invalid
698+
}
699+
}
700+
}
701+
None => {
702+
// Argument was omitted, use default
703+
#default.into()
704+
}
705+
}
706+
}
664707
}
665708
} else if self.variadic {
666709
let variadic_name = format_ident!("__variadic_{}", name);
@@ -692,21 +735,132 @@ impl TypedArg<'_> {
692735
}
693736
}
694737

695-
/// Returns true of the given type is nullable in PHP.
738+
/// Converts a Rust expression to a PHP stub-compatible default value string.
739+
///
740+
/// This function handles common Rust patterns and converts them to valid PHP
741+
/// syntax for use in generated stub files:
742+
///
743+
/// - `None` → `"null"`
744+
/// - `Some(expr)` → converts the inner expression
745+
/// - `42`, `3.14` → numeric literals as-is
746+
/// - `true`/`false` → as-is
747+
/// - `"string"` → `"string"`
748+
/// - `"string".to_string()` or `String::from("string")` → `"string"`
749+
fn expr_to_php_stub(expr: &Expr) -> String {
750+
match expr {
751+
// Handle None -> null
752+
Expr::Path(path) => {
753+
let path_str = path.path.to_token_stream().to_string();
754+
if path_str == "None" {
755+
"null".to_string()
756+
} else if path_str == "true" || path_str == "false" {
757+
path_str
758+
} else {
759+
// For other paths (constants, etc.), use the raw representation
760+
path_str
761+
}
762+
}
763+
764+
// Handle Some(expr) -> convert inner expression
765+
Expr::Call(call) => {
766+
if let Expr::Path(func_path) = &*call.func {
767+
let func_name = func_path.path.to_token_stream().to_string();
768+
769+
// Some(value) -> convert inner value
770+
if func_name == "Some"
771+
&& let Some(arg) = call.args.first()
772+
{
773+
return expr_to_php_stub(arg);
774+
}
775+
776+
// String::from("...") -> "..."
777+
if (func_name == "String :: from" || func_name == "String::from")
778+
&& let Some(arg) = call.args.first()
779+
{
780+
return expr_to_php_stub(arg);
781+
}
782+
}
783+
784+
// Default: use raw representation
785+
expr.to_token_stream().to_string()
786+
}
787+
788+
// Handle method calls like "string".to_string()
789+
Expr::MethodCall(method_call) => {
790+
let method_name = method_call.method.to_string();
791+
792+
// "...".to_string() or "...".to_owned() or "...".into() -> "..."
793+
if method_name == "to_string" || method_name == "to_owned" || method_name == "into" {
794+
return expr_to_php_stub(&method_call.receiver);
795+
}
796+
797+
// Default: use raw representation
798+
expr.to_token_stream().to_string()
799+
}
800+
801+
// String literals -> keep as-is (already valid PHP)
802+
Expr::Lit(lit) => match &lit.lit {
803+
syn::Lit::Str(s) => format!(
804+
"\"{}\"",
805+
s.value().replace('\\', "\\\\").replace('"', "\\\"")
806+
),
807+
syn::Lit::Int(i) => i.to_string(),
808+
syn::Lit::Float(f) => f.to_string(),
809+
syn::Lit::Bool(b) => if b.value { "true" } else { "false" }.to_string(),
810+
syn::Lit::Char(c) => format!("\"{}\"", c.value()),
811+
_ => expr.to_token_stream().to_string(),
812+
},
813+
814+
// Handle arrays: [] or vec![]
815+
Expr::Array(arr) => {
816+
if arr.elems.is_empty() {
817+
"[]".to_string()
818+
} else {
819+
let elems: Vec<String> = arr.elems.iter().map(expr_to_php_stub).collect();
820+
format!("[{}]", elems.join(", "))
821+
}
822+
}
823+
824+
// Handle vec![] macro
825+
Expr::Macro(m) => {
826+
let macro_name = m.mac.path.to_token_stream().to_string();
827+
if macro_name == "vec" {
828+
let tokens = m.mac.tokens.to_string();
829+
if tokens.trim().is_empty() {
830+
return "[]".to_string();
831+
}
832+
}
833+
// Default: use raw representation
834+
expr.to_token_stream().to_string()
835+
}
836+
837+
// Handle unary expressions like -42
838+
Expr::Unary(unary) => {
839+
let inner = expr_to_php_stub(&unary.expr);
840+
format!("{}{}", unary.op.to_token_stream(), inner)
841+
}
842+
843+
// Default: use raw representation
844+
_ => expr.to_token_stream().to_string(),
845+
}
846+
}
847+
848+
/// Returns true if the given type is nullable in PHP (i.e., it's an `Option<T>`).
849+
///
850+
/// Note: Having a default value does NOT make a type nullable. A parameter with
851+
/// a default value is optional (can be omitted), but passing `null` explicitly
852+
/// should still be rejected unless the type is `Option<T>`.
696853
// TODO(david): Eventually move to compile-time constants for this (similar to
697854
// FromZval::NULLABLE).
698-
pub fn type_is_nullable(ty: &Type, has_default: bool) -> Result<bool> {
855+
pub fn type_is_nullable(ty: &Type) -> Result<bool> {
699856
Ok(match ty {
700-
syn::Type::Path(path) => {
701-
has_default
702-
|| path
703-
.path
704-
.segments
705-
.iter()
706-
.next_back()
707-
.is_some_and(|seg| seg.ident == "Option")
708-
}
709-
syn::Type::Reference(_) => false, /* Reference cannot be nullable unless */
857+
Type::Path(path) => path
858+
.path
859+
.segments
860+
.iter()
861+
.next_back()
862+
.is_some_and(|seg| seg.ident == "Option"),
863+
Type::Reference(_) => false, /* Reference cannot be nullable unless */
710864
// wrapped in `Option` (in that case it'd be a Path).
711865
_ => bail!(ty => "Unsupported argument type."),
712866
})

src/builders/class.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ use crate::{
1717
zend_fastcall,
1818
};
1919

20-
type ConstantEntry = (String, Box<dyn FnOnce() -> Result<Zval>>, DocComments);
20+
/// A constant entry: (name, `value_closure`, docs, `stub_value`)
21+
type ConstantEntry = (
22+
String,
23+
Box<dyn FnOnce() -> Result<Zval>>,
24+
DocComments,
25+
String,
26+
);
2127
type PropertyDefault = Option<Box<dyn FnOnce() -> Result<Zval>>>;
2228

2329
/// Builder for registering a class in PHP.
@@ -140,8 +146,11 @@ impl ClassBuilder {
140146
value: impl IntoZval + 'static,
141147
docs: DocComments,
142148
) -> Result<Self> {
149+
// Convert to Zval first to get stub value
150+
let zval = value.into_zval(true)?;
151+
let stub = crate::convert::zval_to_stub(&zval);
143152
self.constants
144-
.push((name.into(), Box::new(|| value.into_zval(true)), docs));
153+
.push((name.into(), Box::new(|| Ok(zval)), docs, stub));
145154
Ok(self)
146155
}
147156

@@ -166,9 +175,14 @@ impl ClassBuilder {
166175
value: &'static dyn IntoZvalDyn,
167176
docs: DocComments,
168177
) -> Result<Self> {
178+
let stub = value.stub_value();
169179
let value = Rc::new(value);
170-
self.constants
171-
.push((name.into(), Box::new(move || value.as_zval(true)), docs));
180+
self.constants.push((
181+
name.into(),
182+
Box::new(move || value.as_zval(true)),
183+
docs,
184+
stub,
185+
));
172186
Ok(self)
173187
}
174188

@@ -375,7 +389,7 @@ impl ClassBuilder {
375389
}
376390
}
377391

378-
for (name, value, _) in self.constants {
392+
for (name, value, _, _) in self.constants {
379393
let value = Box::into_raw(Box::new(value()?));
380394
unsafe {
381395
zend_declare_class_constant(

src/constant.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ use crate::ffi::{
1313

1414
/// Implemented on types which can be registered as a constant in PHP.
1515
pub trait IntoConst: Debug {
16+
/// Returns the PHP stub representation of this constant value.
17+
///
18+
/// This is used when generating PHP stub files for IDE autocompletion.
19+
/// The returned string should be a valid PHP literal (e.g., `"hello"`,
20+
/// `42`, `true`).
21+
fn stub_value(&self) -> String;
22+
1623
/// Registers a global module constant in PHP, with the value as the content
1724
/// of self. This function _must_ be called in the module startup
1825
/// function, which is called after the module is initialized. The
@@ -89,6 +96,10 @@ pub trait IntoConst: Debug {
8996
}
9097

9198
impl IntoConst for String {
99+
fn stub_value(&self) -> String {
100+
self.as_str().stub_value()
101+
}
102+
92103
fn register_constant_flags(
93104
&self,
94105
name: &str,
@@ -101,6 +112,17 @@ impl IntoConst for String {
101112
}
102113

103114
impl IntoConst for &str {
115+
fn stub_value(&self) -> String {
116+
// Escape special characters for PHP string literal
117+
let escaped = self
118+
.replace('\\', "\\\\")
119+
.replace('\'', "\\'")
120+
.replace('\n', "\\n")
121+
.replace('\r', "\\r")
122+
.replace('\t', "\\t");
123+
format!("'{escaped}'")
124+
}
125+
104126
fn register_constant_flags(
105127
&self,
106128
name: &str,
@@ -133,6 +155,10 @@ impl IntoConst for &str {
133155
}
134156

135157
impl IntoConst for bool {
158+
fn stub_value(&self) -> String {
159+
if *self { "true" } else { "false" }.to_string()
160+
}
161+
136162
fn register_constant_flags(
137163
&self,
138164
name: &str,
@@ -169,6 +195,10 @@ impl IntoConst for bool {
169195
macro_rules! into_const_num {
170196
($type: ty, $fn: expr) => {
171197
impl IntoConst for $type {
198+
fn stub_value(&self) -> String {
199+
self.to_string()
200+
}
201+
172202
fn register_constant_flags(
173203
&self,
174204
name: &str,

0 commit comments

Comments
 (0)