Skip to main content

ext_php_rs_introspection/
stub.rs

1//! Traits and implementations to convert describe units into PHP stub code.
2
3use std::{
4    cmp::Ordering,
5    collections::HashMap,
6    fmt::{Error as FmtError, Result as FmtResult, Write},
7    option::Option as StdOption,
8    vec::Vec as StdVec,
9};
10
11use super::{
12    Class, Constant, DocBlock, Function, Method, MethodType, Module, Parameter, Property, Retval,
13    Visibility,
14    abi::{Option, RString, Str},
15};
16
17use crate::{DataType, Enum, EnumCase};
18
19/// Parsed rustdoc sections for conversion to `PHPDoc`.
20#[derive(Default)]
21struct ParsedRustDoc {
22    /// Summary/description lines (before any section header).
23    summary: StdVec<String>,
24    /// Parameter descriptions from `# Arguments` section.
25    /// Maps parameter name to description.
26    params: HashMap<String, String>,
27    /// Parameter type overrides from `# Parameters` section.
28    /// Maps parameter name to PHP type string (e.g., "?string &$stdout").
29    /// Used to override `mixed` type in stubs when Rust uses `Zval`.
30    param_types: HashMap<String, String>,
31    /// Return value description from `# Returns` section.
32    returns: StdOption<String>,
33    /// Error descriptions from `# Errors` section (for @throws).
34    errors: StdVec<String>,
35}
36
37/// Parse rustdoc-style documentation into structured sections.
38fn parse_rustdoc(docs: &[Str]) -> ParsedRustDoc {
39    let mut result = ParsedRustDoc::default();
40    let mut current_section: StdOption<&str> = None;
41    let mut section_content: StdVec<String> = StdVec::new();
42
43    for line in docs {
44        let line = line.as_ref();
45        let trimmed = line.trim();
46
47        // Check for section headers (# Arguments, # Returns, # Errors, etc.)
48        if trimmed.starts_with("# ") {
49            // Save previous section content
50            finalize_section(&mut result, current_section, &section_content);
51            section_content.clear();
52
53            // Start new section
54            let section_name = trimmed.strip_prefix("# ").unwrap_or(trimmed);
55            current_section = Some(section_name);
56        } else if current_section.is_some() {
57            // Inside a section, collect content
58            section_content.push(line.to_string());
59        } else {
60            // Before any section header - this is the summary
61            result.summary.push(line.to_string());
62        }
63    }
64
65    // Finalize last section
66    finalize_section(&mut result, current_section, &section_content);
67
68    result
69}
70
71/// Process section content and store in the appropriate field.
72fn finalize_section(result: &mut ParsedRustDoc, section: StdOption<&str>, content: &[String]) {
73    let Some(section_name) = section else {
74        return;
75    };
76
77    match section_name {
78        "Arguments" => {
79            // Parse argument list: `* `name` - description` or `* name - description`
80            for line in content {
81                let trimmed = line.trim();
82                let item = trimmed
83                    .strip_prefix("* ")
84                    .or_else(|| trimmed.strip_prefix("- "));
85                // Try to extract parameter name and description
86                // Format: `name` - description OR name - description
87                if let Some(item) = item
88                    && let Some((name, desc)) = parse_param_line(item.trim())
89                {
90                    result.params.insert(name, desc);
91                }
92            }
93        }
94        "Returns" => {
95            // Collect all non-empty lines as return description
96            let desc: String = content
97                .iter()
98                .map(|s| s.trim())
99                .filter(|s| !s.is_empty())
100                .collect::<StdVec<_>>()
101                .join(" ");
102            if !desc.is_empty() {
103                result.returns = Some(desc);
104            }
105        }
106        "Errors" => {
107            // Collect error descriptions for @throws
108            for line in content {
109                let trimmed = line.trim();
110                if !trimmed.is_empty() {
111                    result.errors.push(trimmed.to_string());
112                }
113            }
114        }
115        "Parameters" => {
116            // Parse parameter list with type overrides
117            // Format: - `name`: `type` description
118            for line in content {
119                let trimmed = line.trim();
120                let item = trimmed
121                    .strip_prefix("* ")
122                    .or_else(|| trimmed.strip_prefix("- "));
123                if let Some(item) = item
124                    && let Some((name, ty, desc)) = parse_typed_param_line(item.trim())
125                {
126                    result.param_types.insert(name.clone(), ty);
127                    if !desc.is_empty() {
128                        result.params.insert(name, desc);
129                    }
130                }
131            }
132        }
133        // Ignore other sections like Examples, Panics, Safety, etc.
134        _ => {}
135    }
136}
137
138/// Parse a parameter line from rustdoc `# Arguments` section.
139/// Handles formats like:
140/// - `name` - description
141/// - name - description
142/// - `$name` - description
143fn parse_param_line(line: &str) -> StdOption<(String, String)> {
144    // Try backtick format first: `name` - description
145    if let Some(rest) = line.strip_prefix('`')
146        && let Some(end_tick) = rest.find('`')
147    {
148        let name = &rest[..end_tick];
149        // Skip the closing backtick and find the separator
150        let after_tick = &rest[end_tick + 1..];
151        let desc = after_tick
152            .trim()
153            .strip_prefix('-')
154            .or_else(|| after_tick.trim().strip_prefix(':'))
155            .map_or_else(|| after_tick.trim(), str::trim);
156        // Remove leading $ if present
157        let name = name.strip_prefix('$').unwrap_or(name);
158        return Some((name.to_string(), desc.to_string()));
159    }
160
161    // Try simple format: name - description
162    if let Some(sep_pos) = line.find(" - ") {
163        let name = line[..sep_pos].trim();
164        let desc = line[sep_pos + 3..].trim();
165        // Remove leading $ if present
166        let name = name.strip_prefix('$').unwrap_or(name);
167        return Some((name.to_string(), desc.to_string()));
168    }
169
170    None
171}
172
173/// Parse a typed parameter line from rustdoc `# Parameters` section.
174/// Format: `name`: `type` description
175/// Returns (name, type, description) if successful.
176fn parse_typed_param_line(line: &str) -> StdOption<(String, String, String)> {
177    // Expected format: `name`: `type` description
178    // First, extract the name in backticks
179    let rest = line.strip_prefix('`')?;
180    let end_tick = rest.find('`')?;
181    let name = rest[..end_tick].to_string();
182
183    // Skip to after the colon
184    let after_name = rest[end_tick + 1..].trim();
185    let after_colon = after_name.strip_prefix(':')?.trim();
186
187    // Extract the type in backticks
188    let type_rest = after_colon.strip_prefix('`')?;
189    let type_end_tick = type_rest.find('`')?;
190    let ty = type_rest[..type_end_tick].to_string();
191
192    // The rest is the description
193    let desc = type_rest[type_end_tick + 1..].trim().to_string();
194
195    // Clean up the name (remove $ prefix if present)
196    let name = name.strip_prefix('$').unwrap_or(&name).to_string();
197
198    Some((name, ty, desc))
199}
200
201/// Format a `PHPDoc` comment block for a function or method.
202///
203/// Converts rustdoc-style documentation to `PHPDoc` format, including:
204/// - Summary/description
205/// - @param tags from `# Arguments` section
206/// - @return tag from `# Returns` section
207/// - @throws tags from `# Errors` section
208///
209/// Returns the parameter type overrides map for use in stub signature generation.
210fn format_phpdoc(
211    docs: &DocBlock,
212    params: &[Parameter],
213    ret: StdOption<&Retval>,
214    buf: &mut String,
215) -> Result<HashMap<String, String>, FmtError> {
216    if docs.0.is_empty() && params.is_empty() && ret.is_none() {
217        return Ok(HashMap::new());
218    }
219
220    let parsed = parse_rustdoc(&docs.0);
221
222    // Check if we have any content to output
223    let has_summary = parsed.summary.iter().any(|s| !s.trim().is_empty());
224    let has_params = !params.is_empty();
225    let has_return = ret.is_some();
226    let has_errors = !parsed.errors.is_empty();
227
228    if !has_summary && !has_params && !has_return && !has_errors {
229        return Ok(parsed.param_types);
230    }
231
232    writeln!(buf, "/**")?;
233
234    // Output summary (trim trailing empty lines)
235    let summary_lines: StdVec<_> = parsed
236        .summary
237        .iter()
238        .rev()
239        .skip_while(|s| s.trim().is_empty())
240        .collect::<StdVec<_>>()
241        .into_iter()
242        .rev()
243        .collect();
244
245    for line in &summary_lines {
246        writeln!(buf, " *{line}")?;
247    }
248
249    // Add blank line before tags if we have summary and tags
250    if !summary_lines.is_empty() && (has_params || has_return || has_errors) {
251        writeln!(buf, " *")?;
252    }
253
254    // Output @param tags
255    for param in params {
256        // Use type override from # Parameters section if available and type is mixed
257        let type_str = if let Some(type_override) = parsed.param_types.get(param.name.as_ref()) {
258            // Extract just the type part (strip reference like &$name)
259            extract_php_type(type_override)
260        } else {
261            match &param.ty {
262                Option::Some(ty) => datatype_to_phpdoc(ty, param.nullable),
263                Option::None => "mixed".to_string(),
264            }
265        };
266
267        let desc = parsed.params.get(param.name.as_ref()).cloned();
268        if let Some(desc) = desc {
269            writeln!(buf, " * @param {type_str} ${} {desc}", param.name)?;
270        } else {
271            writeln!(buf, " * @param {type_str} ${}", param.name)?;
272        }
273    }
274
275    // Output @return tag
276    if let Some(retval) = ret {
277        let type_str = datatype_to_phpdoc(&retval.ty, retval.nullable);
278        if let Some(desc) = &parsed.returns {
279            writeln!(buf, " * @return {type_str} {desc}")?;
280        } else {
281            writeln!(buf, " * @return {type_str}")?;
282        }
283    }
284
285    // Output @throws tags
286    for error in &parsed.errors {
287        writeln!(buf, " * @throws \\Exception {error}")?;
288    }
289
290    writeln!(buf, " */")?;
291    Ok(parsed.param_types)
292}
293
294/// Extract the PHP type from a type override string.
295/// Handles formats like "?string &$name" -> "?string"
296fn extract_php_type(type_str: &str) -> String {
297    // The type override might contain reference notation like "&$name"
298    // We want just the type part
299    type_str
300        .split_whitespace()
301        .next()
302        .unwrap_or("mixed")
303        .to_string()
304}
305
306/// Convert a `DataType` to `PHPDoc` type string.
307fn datatype_to_phpdoc(ty: &DataType, nullable: bool) -> String {
308    let base = match ty {
309        DataType::Bool | DataType::True | DataType::False => "bool",
310        DataType::Long => "int",
311        DataType::Double => "float",
312        DataType::String => "string",
313        DataType::Array => "array",
314        DataType::Object(_) => match ty.class_name() {
315            Some(name) => return format_class_type(name, nullable),
316            None => "object",
317        },
318        DataType::Resource => "resource",
319        DataType::Callable => "callable",
320        DataType::Void => "void",
321        DataType::Null => "null",
322        DataType::Iterable => "iterable",
323        // Mixed, Undef, Ptr, Indirect, Reference, ConstantExpression
324        _ => "mixed",
325    };
326
327    if nullable && !matches!(ty, DataType::Mixed | DataType::Null | DataType::Void) {
328        format!("{base}|null")
329    } else {
330        base.to_string()
331    }
332}
333
334/// Format a class type for `PHPDoc` (with backslash prefix).
335fn format_class_type(name: &str, nullable: bool) -> String {
336    let class_name = if name.starts_with('\\') {
337        name.to_string()
338    } else {
339        format!("\\{name}")
340    };
341
342    if nullable {
343        format!("{class_name}|null")
344    } else {
345        class_name
346    }
347}
348
349/// Implemented on types which can be converted into PHP stubs.
350pub trait ToStub {
351    /// Converts the implementor into PHP code, represented as a PHP stub.
352    /// Returned as a string.
353    ///
354    /// # Returns
355    ///
356    /// Returns a string on success.
357    ///
358    /// # Errors
359    ///
360    /// Returns an error if there was an error writing into the string.
361    fn to_stub(&self) -> Result<String, FmtError> {
362        let mut buf = String::new();
363        self.fmt_stub(&mut buf)?;
364        Ok(buf)
365    }
366
367    /// Converts the implementor into PHP code, represented as a PHP stub.
368    ///
369    /// # Parameters
370    ///
371    /// * `buf` - The buffer to write the PHP code into.
372    ///
373    /// # Returns
374    ///
375    /// Returns nothing on success.
376    ///
377    /// # Errors
378    ///
379    /// Returns an error if there was an error writing into the buffer.
380    fn fmt_stub(&self, buf: &mut String) -> FmtResult;
381}
382
383impl ToStub for Module {
384    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
385        writeln!(buf, "<?php")?;
386        writeln!(buf)?;
387        writeln!(buf, "// Stubs for {}", self.name.as_ref())?;
388        writeln!(buf)?;
389
390        // To account for namespaces we need to group by them. [`None`] as the key
391        // represents no namespace, while [`Some`] represents a namespace.
392        // Store (sort_key, stub) tuples to sort by name, not by rendered output.
393        let mut entries: HashMap<StdOption<&str>, StdVec<(String, String)>> = HashMap::new();
394
395        // Inserts a value into the entries hashmap. Takes a key, sort key, and entry,
396        // creating the internal vector if it doesn't already exist.
397        let mut insert = |ns, sort_key: String, entry| {
398            let bucket = entries.entry(ns).or_default();
399            bucket.push((sort_key, entry));
400        };
401
402        for c in &*self.constants {
403            let (ns, name) = split_namespace(c.name.as_ref());
404            insert(ns, name.to_string(), c.to_stub()?);
405        }
406
407        for func in &*self.functions {
408            let (ns, name) = split_namespace(func.name.as_ref());
409            insert(ns, name.to_string(), func.to_stub()?);
410        }
411
412        for class in &*self.classes {
413            let (ns, name) = split_namespace(class.name.as_ref());
414            insert(ns, name.to_string(), class.to_stub()?);
415        }
416
417        for r#enum in &*self.enums {
418            let (ns, name) = split_namespace(r#enum.name.as_ref());
419            insert(ns, name.to_string(), r#enum.to_stub()?);
420        }
421
422        // Sort by entity name, not by rendered output
423        for bucket in entries.values_mut() {
424            bucket.sort_by(|(a, _), (b, _)| a.cmp(b));
425        }
426
427        let mut entries: StdVec<_> = entries.iter().collect();
428        entries.sort_by(|(l, _), (r, _)| match (l, r) {
429            (None, _) => Ordering::Greater,
430            (_, None) => Ordering::Less,
431            (Some(l), Some(r)) => l.cmp(r),
432        });
433
434        buf.push_str(
435            &entries
436                .into_iter()
437                .map(|(ns, entries)| {
438                    let mut buf = String::new();
439                    if let Some(ns) = ns {
440                        writeln!(buf, "namespace {ns} {{")?;
441                    } else {
442                        writeln!(buf, "namespace {{")?;
443                    }
444
445                    buf.push_str(
446                        &entries
447                            .iter()
448                            .map(|(_, stub)| indent(stub, 4))
449                            .collect::<StdVec<_>>()
450                            .join(NEW_LINE_SEPARATOR),
451                    );
452
453                    writeln!(buf, "}}")?;
454                    Ok(buf)
455                })
456                .collect::<Result<StdVec<_>, FmtError>>()?
457                .join(NEW_LINE_SEPARATOR),
458        );
459
460        Ok(())
461    }
462}
463
464impl ToStub for Function {
465    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
466        // Convert rustdoc to PHPDoc format (Issue #369)
467        let ret_ref = match &self.ret {
468            Option::Some(r) => Some(r),
469            Option::None => None,
470        };
471        let type_overrides = format_phpdoc(&self.docs, &self.params, ret_ref, buf)?;
472
473        let (_, name) = split_namespace(self.name.as_ref());
474
475        // Render parameters with type overrides
476        let params_str = self
477            .params
478            .iter()
479            .map(|p| param_to_stub(p, &type_overrides))
480            .collect::<Result<StdVec<_>, FmtError>>()?
481            .join(", ");
482
483        write!(buf, "function {name}({params_str})")?;
484
485        if let Option::Some(retval) = &self.ret {
486            write!(buf, ": ")?;
487            // Don't add ? for mixed/null/void - they already include null or can't be nullable
488            if retval.nullable
489                && !matches!(retval.ty, DataType::Mixed | DataType::Null | DataType::Void)
490            {
491                write!(buf, "?")?;
492            }
493            retval.ty.fmt_stub(buf)?;
494        }
495
496        writeln!(buf, " {{}}")
497    }
498}
499
500/// Render a parameter to stub format, with optional type overrides from rustdoc.
501///
502/// When a parameter's Rust type is `Zval` (which maps to `mixed` in PHP), the
503/// `# Parameters` section in rustdoc can specify a more precise PHP type.
504fn param_to_stub(
505    param: &Parameter,
506    type_overrides: &HashMap<String, String>,
507) -> Result<String, FmtError> {
508    let mut buf = String::new();
509
510    // Check if we should use a type override from # Parameters section
511    // Only use override if the param type is Mixed (i.e., Zval in Rust)
512    let type_override = type_overrides
513        .get(param.name.as_ref())
514        .filter(|_| matches!(&param.ty, Option::Some(DataType::Mixed) | Option::None));
515
516    if let Some(override_str) = type_override {
517        // Use the documented type from # Parameters
518        let type_str = extract_php_type(override_str);
519        write!(buf, "{type_str} ")?;
520    } else if let Option::Some(ty) = &param.ty {
521        // Don't add ? for mixed/null/void - they already include null or can't be nullable
522        if param.nullable && !matches!(ty, DataType::Mixed | DataType::Null | DataType::Void) {
523            write!(buf, "?")?;
524        }
525        ty.fmt_stub(&mut buf)?;
526        write!(buf, " ")?;
527    }
528
529    if param.variadic {
530        write!(buf, "...")?;
531    }
532
533    write!(buf, "${}", param.name)?;
534
535    // Add default value to stub
536    if let Option::Some(default) = &param.default {
537        write!(buf, " = {default}")?;
538    } else if param.nullable {
539        // For nullable parameters without explicit default, add = null
540        // This makes Option<T> parameters truly optional in PHP
541        write!(buf, " = null")?;
542    }
543
544    Ok(buf)
545}
546
547impl ToStub for Parameter {
548    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
549        let empty_overrides = HashMap::new();
550        let result = param_to_stub(self, &empty_overrides)?;
551        buf.push_str(&result);
552        Ok(())
553    }
554}
555
556impl ToStub for DataType {
557    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
558        let mut fqdn = "\\".to_owned();
559        write!(
560            buf,
561            "{}",
562            match self {
563                DataType::Bool | DataType::True | DataType::False => "bool",
564                DataType::Long => "int",
565                DataType::Double => "float",
566                DataType::String => "string",
567                DataType::Array => "array",
568                DataType::Object(_) => match self.class_name() {
569                    Some(name) => {
570                        fqdn.push_str(name);
571                        fqdn.as_str()
572                    }
573                    None => "object",
574                },
575                DataType::Resource => "resource",
576                DataType::Reference => "reference",
577                DataType::Callable => "callable",
578                DataType::Iterable => "iterable",
579                DataType::Void => "void",
580                DataType::Null => "null",
581                DataType::Mixed
582                | DataType::Undef
583                | DataType::Ptr
584                | DataType::Indirect
585                | DataType::ConstantExpression => "mixed",
586            }
587        )
588    }
589}
590
591impl ToStub for DocBlock {
592    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
593        if !self.0.is_empty() {
594            writeln!(buf, "/**")?;
595            for comment in self.0.iter() {
596                writeln!(buf, " *{comment}")?;
597            }
598            writeln!(buf, " */")?;
599        }
600        Ok(())
601    }
602}
603
604impl ToStub for Class {
605    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
606        self.docs.fmt_stub(buf)?;
607
608        let (_, name) = split_namespace(self.name.as_ref());
609        if self.is_interface {
610            write!(buf, "interface {name} ")?;
611        } else {
612            write!(buf, "class {name} ")?;
613        }
614
615        if let Option::Some(extends) = &self.extends {
616            write!(buf, "extends {extends} ")?;
617        }
618
619        if !self.implements.is_empty() && !self.is_interface {
620            write!(
621                buf,
622                "implements {} ",
623                self.implements
624                    .iter()
625                    .map(RString::as_str)
626                    .collect::<StdVec<_>>()
627                    .join(", ")
628            )?;
629        }
630
631        if !self.implements.is_empty() && self.is_interface {
632            write!(
633                buf,
634                "extends {} ",
635                self.implements
636                    .iter()
637                    .map(RString::as_str)
638                    .collect::<StdVec<_>>()
639                    .join(", ")
640            )?;
641        }
642
643        writeln!(buf, "{{")?;
644
645        // Collect (sort_key, stub) tuples to sort by name, not by rendered output
646        let mut constants: StdVec<_> = self
647            .constants
648            .iter()
649            .map(|c| {
650                c.class_stub()
651                    .map(|s| (c.name.as_ref().to_string(), indent(&s, 4)))
652            })
653            .collect::<Result<_, FmtError>>()?;
654        let mut properties: StdVec<_> = self
655            .properties
656            .iter()
657            .map(|p| {
658                p.to_stub()
659                    .map(|s| (p.name.as_ref().to_string(), indent(&s, 4)))
660            })
661            .collect::<Result<_, FmtError>>()?;
662        let mut methods: StdVec<_> = self
663            .methods
664            .iter()
665            .map(|m| {
666                m.to_stub()
667                    .map(|s| (m.name.as_ref().to_string(), indent(&s, 4)))
668            })
669            .collect::<Result<_, FmtError>>()?;
670
671        // Sort by entity name
672        constants.sort_by(|(a, _), (b, _)| a.cmp(b));
673        properties.sort_by(|(a, _), (b, _)| a.cmp(b));
674        methods.sort_by(|(a, _), (b, _)| a.cmp(b));
675
676        buf.push_str(
677            &constants
678                .into_iter()
679                .chain(properties)
680                .chain(methods)
681                .map(|(_, stub)| stub)
682                .collect::<StdVec<_>>()
683                .join(NEW_LINE_SEPARATOR),
684        );
685
686        writeln!(buf, "}}")
687    }
688}
689
690impl ToStub for Enum {
691    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
692        self.docs.fmt_stub(buf)?;
693
694        let (_, name) = split_namespace(self.name.as_ref());
695        write!(buf, "enum {name}")?;
696
697        if let Option::Some(backing_type) = &self.backing_type {
698            write!(buf, ": {backing_type}")?;
699        }
700
701        writeln!(buf, " {{")?;
702
703        for case in self.cases.iter() {
704            case.fmt_stub(buf)?;
705        }
706
707        writeln!(buf, "}}")
708    }
709}
710
711impl ToStub for EnumCase {
712    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
713        self.docs.fmt_stub(buf)?;
714
715        write!(buf, "  case {}", self.name)?;
716        if let Option::Some(value) = &self.value {
717            write!(buf, " = {value}")?;
718        }
719        writeln!(buf, ";")
720    }
721}
722
723impl ToStub for Property {
724    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
725        if !self.docs.0.is_empty() {
726            writeln!(buf, "/**")?;
727            for comment in self.docs.0.iter() {
728                writeln!(buf, " *{comment}")?;
729            }
730            if let Option::Some(ty) = &self.ty {
731                writeln!(buf, " *")?;
732                writeln!(buf, " * @var {}", datatype_to_phpdoc(ty, self.nullable))?;
733            }
734            writeln!(buf, " */")?;
735        }
736
737        self.vis.fmt_stub(buf)?;
738        write!(buf, " ")?;
739        if self.static_ {
740            write!(buf, "static ")?;
741        }
742        if self.readonly {
743            write!(buf, "readonly ")?;
744        }
745        if let Option::Some(ty) = &self.ty {
746            let nullable = self.nullable && !matches!(ty, DataType::Mixed | DataType::Null);
747            if nullable {
748                write!(buf, "?")?;
749            }
750            ty.fmt_stub(buf)?;
751            write!(buf, " ")?;
752        }
753        write!(buf, "${}", self.name)?;
754        if let Option::Some(default) = &self.default {
755            write!(buf, " = {default}")?;
756        }
757        writeln!(buf, ";")
758    }
759}
760
761impl ToStub for Visibility {
762    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
763        write!(
764            buf,
765            "{}",
766            match self {
767                Visibility::Private => "private",
768                Visibility::Protected => "protected",
769                Visibility::Public => "public",
770            }
771        )
772    }
773}
774
775impl ToStub for Method {
776    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
777        // Convert rustdoc to PHPDoc format (Issue #369)
778        // Don't include return type for constructors in PHPDoc
779        let ret_ref = if matches!(self.ty, MethodType::Constructor) {
780            None
781        } else {
782            match &self.retval {
783                Option::Some(r) => Some(r),
784                Option::None => None,
785            }
786        };
787        let type_overrides = format_phpdoc(&self.docs, &self.params, ret_ref, buf)?;
788
789        self.visibility.fmt_stub(buf)?;
790
791        write!(buf, " ")?;
792
793        if matches!(self.ty, MethodType::Static) {
794            write!(buf, "static ")?;
795        }
796
797        // Render parameters with type overrides
798        let params_str = self
799            .params
800            .iter()
801            .map(|p| param_to_stub(p, &type_overrides))
802            .collect::<Result<StdVec<_>, FmtError>>()?
803            .join(", ");
804
805        write!(buf, "function {}({params_str})", self.name)?;
806
807        if !matches!(self.ty, MethodType::Constructor)
808            && let Option::Some(retval) = &self.retval
809        {
810            write!(buf, ": ")?;
811            // Don't add ? for mixed/null/void - they already include null or can't be nullable
812            if retval.nullable
813                && !matches!(retval.ty, DataType::Mixed | DataType::Null | DataType::Void)
814            {
815                write!(buf, "?")?;
816            }
817            retval.ty.fmt_stub(buf)?;
818        }
819
820        if self.r#abstract {
821            writeln!(buf, ";")
822        } else {
823            writeln!(buf, " {{}}")
824        }
825    }
826}
827
828impl Constant {
829    fn class_stub(&self) -> Result<String, FmtError> {
830        let mut buf = String::new();
831        self.docs.fmt_stub(&mut buf)?;
832        self.visibility.fmt_stub(&mut buf)?;
833        write!(buf, " ")?;
834        self.fmt_declaration(&mut buf)?;
835        Ok(buf)
836    }
837
838    fn fmt_declaration(&self, buf: &mut String) -> FmtResult {
839        write!(buf, "const {} = ", self.name)?;
840        if let Option::Some(value) = &self.value {
841            write!(buf, "{value}")?;
842        } else {
843            write!(buf, "null")?;
844        }
845        writeln!(buf, ";")
846    }
847}
848
849impl ToStub for Constant {
850    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
851        self.docs.fmt_stub(buf)?;
852        self.fmt_declaration(buf)
853    }
854}
855
856#[cfg(windows)]
857const NEW_LINE_SEPARATOR: &str = "\r\n";
858#[cfg(not(windows))]
859const NEW_LINE_SEPARATOR: &str = "\n";
860
861/// Takes a class name and splits the namespace off from the actual class name.
862///
863/// # Returns
864///
865/// A tuple, where the first item is the namespace (or [`None`] if not
866/// namespaced), and the second item is the class name.
867fn split_namespace(class: &str) -> (StdOption<&str>, &str) {
868    let idx = class.rfind('\\');
869
870    if let Some(idx) = idx {
871        (Some(&class[0..idx]), &class[idx + 1..])
872    } else {
873        (None, class)
874    }
875}
876
877/// Indents a given string to a given depth. Depth is given in number of spaces
878/// to be appended. Returns a new string with the new indentation. Will not
879/// indent whitespace lines.
880///
881/// # Parameters
882///
883/// * `s` - The string to indent.
884/// * `depth` - The depth to indent the lines to, in spaces.
885///
886/// # Returns
887///
888/// The indented string.
889fn indent(s: &str, depth: usize) -> String {
890    let indent = format!("{:depth$}", "", depth = depth);
891
892    s.split('\n')
893        .map(|line| {
894            let mut result = String::new();
895            if line.chars().any(|c| !c.is_whitespace()) {
896                result.push_str(&indent);
897                result.push_str(line);
898            }
899            result
900        })
901        .collect::<StdVec<_>>()
902        .join(NEW_LINE_SEPARATOR)
903}
904
905#[cfg(test)]
906mod test {
907    use super::{ToStub, split_namespace};
908    use crate::DataType;
909
910    #[test]
911    pub fn test_split_ns() {
912        assert_eq!(split_namespace("ext\\php\\rs"), (Some("ext\\php"), "rs"));
913        assert_eq!(split_namespace("test_solo_ns"), (None, "test_solo_ns"));
914        assert_eq!(split_namespace("simple\\ns"), (Some("simple"), "ns"));
915    }
916
917    #[test]
918    #[cfg(not(windows))]
919    #[allow(clippy::uninlined_format_args)]
920    pub fn test_indent() {
921        use super::NEW_LINE_SEPARATOR;
922        use super::indent;
923
924        assert_eq!(indent("hello", 4), "    hello");
925        assert_eq!(
926            indent(&format!("hello{nl}world{nl}", nl = NEW_LINE_SEPARATOR), 4),
927            format!("    hello{nl}    world{nl}", nl = NEW_LINE_SEPARATOR)
928        );
929    }
930
931    #[test]
932    #[allow(clippy::unwrap_used)]
933    pub fn test_datatype_to_stub() {
934        // Test that all DataType variants produce correct PHP type strings
935        assert_eq!(DataType::Void.to_stub().unwrap(), "void");
936        assert_eq!(DataType::Null.to_stub().unwrap(), "null");
937        assert_eq!(DataType::Bool.to_stub().unwrap(), "bool");
938        assert_eq!(DataType::True.to_stub().unwrap(), "bool");
939        assert_eq!(DataType::False.to_stub().unwrap(), "bool");
940        assert_eq!(DataType::Long.to_stub().unwrap(), "int");
941        assert_eq!(DataType::Double.to_stub().unwrap(), "float");
942        assert_eq!(DataType::String.to_stub().unwrap(), "string");
943        assert_eq!(DataType::Array.to_stub().unwrap(), "array");
944        assert_eq!(DataType::ANY_OBJECT.to_stub().unwrap(), "object");
945        assert_eq!(
946            DataType::object("Foo\\Bar").to_stub().unwrap(),
947            "\\Foo\\Bar"
948        );
949        assert_eq!(DataType::Resource.to_stub().unwrap(), "resource");
950        assert_eq!(DataType::Callable.to_stub().unwrap(), "callable");
951        assert_eq!(DataType::Iterable.to_stub().unwrap(), "iterable");
952        assert_eq!(DataType::Mixed.to_stub().unwrap(), "mixed");
953        assert_eq!(DataType::Undef.to_stub().unwrap(), "mixed");
954        assert_eq!(DataType::Ptr.to_stub().unwrap(), "mixed");
955        assert_eq!(DataType::Indirect.to_stub().unwrap(), "mixed");
956        assert_eq!(DataType::ConstantExpression.to_stub().unwrap(), "mixed");
957        assert_eq!(DataType::Reference.to_stub().unwrap(), "reference");
958    }
959
960    #[test]
961    #[allow(clippy::unwrap_used)]
962    fn test_property_stub_typed_no_docs() {
963        use crate::{Property, Visibility, abi::Option};
964
965        let prop = Property {
966            name: "foo".into(),
967            docs: super::DocBlock(vec![].into()),
968            ty: Option::Some(DataType::String),
969            vis: Visibility::Public,
970            static_: false,
971            nullable: false,
972            readonly: false,
973            default: Option::None,
974        };
975        let stub = prop.to_stub().unwrap();
976        // No docs → no docblock at all (type declaration is sufficient in PHP 8.0+)
977        assert!(!stub.contains("@var"), "no @var without docs: {stub}");
978        assert_eq!(stub, "public string $foo;\n");
979    }
980
981    #[test]
982    #[allow(clippy::unwrap_used)]
983    fn test_property_stub_nullable_with_default() {
984        use crate::{Property, Visibility, abi::Option};
985
986        let prop = Property {
987            name: "bar".into(),
988            docs: super::DocBlock(vec![].into()),
989            ty: Option::Some(DataType::String),
990            vis: Visibility::Public,
991            static_: false,
992            nullable: true,
993            readonly: false,
994            default: Option::Some("null".into()),
995        };
996        let stub = prop.to_stub().unwrap();
997        assert!(
998            stub.contains("public ?string $bar = null;"),
999            "missing nullable default: {stub}"
1000        );
1001    }
1002
1003    #[test]
1004    #[allow(clippy::unwrap_used)]
1005    fn test_property_stub_static_with_default() {
1006        use crate::{Property, Visibility, abi::Option};
1007
1008        let prop = Property {
1009            name: "limit".into(),
1010            docs: super::DocBlock(vec![].into()),
1011            ty: Option::Some(DataType::Long),
1012            vis: Visibility::Public,
1013            static_: true,
1014            nullable: false,
1015            readonly: false,
1016            default: Option::Some("100".into()),
1017        };
1018        let stub = prop.to_stub().unwrap();
1019        assert!(
1020            stub.contains("public static int $limit = 100;"),
1021            "missing static default: {stub}"
1022        );
1023    }
1024
1025    #[test]
1026    #[allow(clippy::unwrap_used)]
1027    fn test_property_stub_static_string_default() {
1028        use crate::{Property, Visibility, abi::Option};
1029
1030        let prop = Property {
1031            name: "label".into(),
1032            docs: super::DocBlock(vec![].into()),
1033            ty: Option::Some(DataType::String),
1034            vis: Visibility::Public,
1035            static_: true,
1036            nullable: false,
1037            readonly: false,
1038            default: Option::Some("'hello'".into()),
1039        };
1040        let stub = prop.to_stub().unwrap();
1041        assert!(
1042            stub.contains("public static string $label = 'hello';"),
1043            "missing static string default: {stub}"
1044        );
1045    }
1046
1047    #[test]
1048    #[allow(clippy::unwrap_used)]
1049    fn test_property_stub_with_docs_includes_var() {
1050        use crate::{Property, Visibility, abi::Option};
1051
1052        let prop = Property {
1053            name: "bar".into(),
1054            docs: super::DocBlock(vec![" The user name.".into()].into()),
1055            ty: Option::Some(DataType::String),
1056            vis: Visibility::Public,
1057            static_: false,
1058            nullable: true,
1059            readonly: false,
1060            default: Option::None,
1061        };
1062        let stub = prop.to_stub().unwrap();
1063        assert!(stub.contains("The user name."), "missing doc: {stub}");
1064        assert!(
1065            stub.contains("@var string|null"),
1066            "missing @var with nullable in docblock: {stub}"
1067        );
1068        assert!(
1069            stub.contains("public ?string $bar;"),
1070            "missing decl: {stub}"
1071        );
1072    }
1073
1074    #[test]
1075    #[allow(clippy::unwrap_used)]
1076    fn test_property_stub_with_docs_no_type() {
1077        use crate::{Property, Visibility, abi::Option};
1078
1079        let prop = Property {
1080            name: "x".into(),
1081            docs: super::DocBlock(vec![" Some value.".into()].into()),
1082            ty: Option::None,
1083            vis: Visibility::Public,
1084            static_: false,
1085            nullable: false,
1086            readonly: false,
1087            default: Option::None,
1088        };
1089        let stub = prop.to_stub().unwrap();
1090        assert!(stub.contains("Some value."), "missing doc: {stub}");
1091        assert!(!stub.contains("@var"), "no @var without type: {stub}");
1092        assert!(stub.contains("public $x;"), "missing decl: {stub}");
1093    }
1094
1095    #[test]
1096    #[allow(clippy::unwrap_used)]
1097    fn test_property_stub_readonly() {
1098        use crate::{Property, Visibility, abi::Option};
1099
1100        let prop = Property {
1101            name: "baz".into(),
1102            docs: super::DocBlock(vec![].into()),
1103            ty: Option::Some(DataType::Array),
1104            vis: Visibility::Public,
1105            static_: false,
1106            nullable: false,
1107            readonly: true,
1108            default: Option::None,
1109        };
1110        let stub = prop.to_stub().unwrap();
1111        assert_eq!(stub, "public readonly array $baz;\n");
1112    }
1113
1114    #[test]
1115    #[allow(clippy::unwrap_used)]
1116    fn test_property_stub_untyped_no_docblock() {
1117        use crate::{Property, Visibility, abi::Option};
1118
1119        let prop = Property {
1120            name: "x".into(),
1121            docs: super::DocBlock(vec![].into()),
1122            ty: Option::None,
1123            vis: Visibility::Public,
1124            static_: false,
1125            nullable: false,
1126            readonly: false,
1127            default: Option::None,
1128        };
1129        let stub = prop.to_stub().unwrap();
1130        assert!(
1131            !stub.contains("/**"),
1132            "no docblock without docs or type: {stub}"
1133        );
1134        assert_eq!(stub, "public $x;\n");
1135    }
1136
1137    #[test]
1138    #[allow(clippy::unwrap_used)]
1139    fn class_constant_stub_carries_visibility() {
1140        use crate::{Constant, Visibility, abi::Option};
1141
1142        let constant = Constant {
1143            name: "SECRET".into(),
1144            docs: super::DocBlock(vec![].into()),
1145            value: Option::Some("1".into()),
1146            visibility: Visibility::Private,
1147        };
1148        assert_eq!(
1149            constant.class_stub().unwrap(),
1150            "private const SECRET = 1;\n"
1151        );
1152        assert_eq!(constant.to_stub().unwrap(), "const SECRET = 1;\n");
1153    }
1154
1155    #[test]
1156    #[allow(clippy::unwrap_used)]
1157    fn test_property_stub_static_typed() {
1158        use crate::{Property, Visibility, abi::Option};
1159
1160        let prop = Property {
1161            name: "count".into(),
1162            docs: super::DocBlock(vec![].into()),
1163            ty: Option::Some(DataType::Long),
1164            vis: Visibility::Protected,
1165            static_: true,
1166            nullable: false,
1167            readonly: false,
1168            default: Option::None,
1169        };
1170        let stub = prop.to_stub().unwrap();
1171        assert!(
1172            stub.contains("protected static int $count;"),
1173            "missing decl: {stub}"
1174        );
1175    }
1176
1177    #[test]
1178    #[allow(clippy::unwrap_used)]
1179    fn test_property_stub_nullable_mixed_stays_mixed() {
1180        use crate::{Property, Visibility, abi::Option};
1181
1182        let prop = Property {
1183            name: "val".into(),
1184            docs: super::DocBlock(vec![].into()),
1185            ty: Option::Some(DataType::Mixed),
1186            vis: Visibility::Public,
1187            static_: false,
1188            nullable: true,
1189            readonly: false,
1190            default: Option::None,
1191        };
1192        let stub = prop.to_stub().unwrap();
1193        // mixed already includes null, no ? prefix
1194        assert!(stub.contains("public mixed $val;"), "missing decl: {stub}");
1195    }
1196
1197    #[test]
1198    #[allow(clippy::unwrap_used)]
1199    fn test_property_stub_nullable_object_with_docs() {
1200        use crate::{Property, Visibility, abi::Option};
1201
1202        let prop = Property {
1203            name: "ref_".into(),
1204            docs: super::DocBlock(vec![" The related entity.".into()].into()),
1205            ty: Option::Some(DataType::object("App\\Entity")),
1206            vis: Visibility::Private,
1207            static_: false,
1208            nullable: true,
1209            readonly: false,
1210            default: Option::None,
1211        };
1212        let stub = prop.to_stub().unwrap();
1213        assert!(stub.contains("The related entity."), "missing doc: {stub}");
1214        assert!(
1215            stub.contains("@var \\App\\Entity|null"),
1216            "missing @var with FQCN: {stub}"
1217        );
1218        assert!(
1219            stub.contains("private ?\\App\\Entity $ref_;"),
1220            "missing decl: {stub}"
1221        );
1222    }
1223
1224    #[test]
1225    fn test_parse_rustdoc() {
1226        use super::{Str, parse_rustdoc};
1227
1228        // Test basic rustdoc parsing
1229        let docs: Vec<Str> = vec![
1230            " Gives you a nice greeting!".into(),
1231            "".into(),
1232            " # Arguments".into(),
1233            "".into(),
1234            " * `name` - Your name".into(),
1235            " * `age` - Your age".into(),
1236            "".into(),
1237            " # Returns".into(),
1238            "".into(),
1239            " Nice greeting!".into(),
1240        ];
1241
1242        let parsed = parse_rustdoc(&docs);
1243
1244        // Check summary
1245        assert_eq!(parsed.summary.len(), 2);
1246        assert!(parsed.summary[0].contains("Gives you a nice greeting"));
1247
1248        // Check params
1249        assert_eq!(parsed.params.len(), 2);
1250        assert_eq!(parsed.params.get("name"), Some(&"Your name".to_string()));
1251        assert_eq!(parsed.params.get("age"), Some(&"Your age".to_string()));
1252
1253        // Check returns
1254        assert!(parsed.returns.is_some());
1255        assert!(
1256            parsed
1257                .returns
1258                .as_ref()
1259                .is_some_and(|r| r.contains("Nice greeting"))
1260        );
1261    }
1262
1263    #[test]
1264    fn test_parse_param_line() {
1265        use super::parse_param_line;
1266
1267        // Test backtick format
1268        assert_eq!(
1269            parse_param_line("`name` - Your name"),
1270            Some(("name".to_string(), "Your name".to_string()))
1271        );
1272
1273        // Test with $ prefix
1274        assert_eq!(
1275            parse_param_line("`$name` - Your name"),
1276            Some(("name".to_string(), "Your name".to_string()))
1277        );
1278
1279        // Test simple format
1280        assert_eq!(
1281            parse_param_line("name - Your name"),
1282            Some(("name".to_string(), "Your name".to_string()))
1283        );
1284
1285        // Test invalid format
1286        assert_eq!(parse_param_line("no separator here"), None);
1287    }
1288
1289    #[test]
1290    fn test_format_phpdoc() {
1291        use super::{DocBlock, Parameter, Retval, Str, format_phpdoc};
1292        use crate::DataType;
1293        use crate::abi::Option;
1294
1295        // Create a DocBlock with rustdoc content
1296        let docs = DocBlock(
1297            vec![
1298                Str::from(" Greets the user."),
1299                Str::from(""),
1300                Str::from(" # Arguments"),
1301                Str::from(""),
1302                Str::from(" * `name` - The name to greet"),
1303                Str::from(""),
1304                Str::from(" # Returns"),
1305                Str::from(""),
1306                Str::from(" A greeting string."),
1307            ]
1308            .into(),
1309        );
1310
1311        let params = vec![Parameter {
1312            name: "name".into(),
1313            ty: Option::Some(DataType::String),
1314            nullable: false,
1315            variadic: false,
1316            default: Option::None,
1317        }];
1318
1319        let retval = Retval {
1320            ty: DataType::String,
1321            nullable: false,
1322        };
1323
1324        let mut buf = String::new();
1325        format_phpdoc(&docs, &params, Some(&retval), &mut buf).expect("format_phpdoc failed");
1326
1327        // Check that PHPDoc format is produced
1328        assert!(buf.contains("/**"));
1329        assert!(buf.contains("*/"));
1330        assert!(buf.contains("@param string $name The name to greet"));
1331        assert!(buf.contains("@return string A greeting string."));
1332        // Should NOT contain rustdoc section headers
1333        assert!(!buf.contains("# Arguments"));
1334        assert!(!buf.contains("# Returns"));
1335    }
1336}