Skip to main content

ext_php_rs/describe/
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
17#[cfg(feature = "enum")]
18use crate::describe::{Enum, EnumCase};
19use crate::flags::{ClassFlags, DataType};
20
21/// Parsed rustdoc sections for conversion to `PHPDoc`.
22#[derive(Default)]
23struct ParsedRustDoc {
24    /// Summary/description lines (before any section header).
25    summary: StdVec<String>,
26    /// Parameter descriptions from `# Arguments` section.
27    /// Maps parameter name to description.
28    params: HashMap<String, String>,
29    /// Parameter type overrides from `# Parameters` section.
30    /// Maps parameter name to PHP type string (e.g., "?string &$stdout").
31    /// Used to override `mixed` type in stubs when Rust uses `Zval`.
32    param_types: HashMap<String, String>,
33    /// Return value description from `# Returns` section.
34    returns: StdOption<String>,
35    /// Error descriptions from `# Errors` section (for @throws).
36    errors: StdVec<String>,
37}
38
39/// Parse rustdoc-style documentation into structured sections.
40fn parse_rustdoc(docs: &[Str]) -> ParsedRustDoc {
41    let mut result = ParsedRustDoc::default();
42    let mut current_section: StdOption<&str> = None;
43    let mut section_content: StdVec<String> = StdVec::new();
44
45    for line in docs {
46        let line = line.as_ref();
47        let trimmed = line.trim();
48
49        // Check for section headers (# Arguments, # Returns, # Errors, etc.)
50        if trimmed.starts_with("# ") {
51            // Save previous section content
52            finalize_section(&mut result, current_section, &section_content);
53            section_content.clear();
54
55            // Start new section
56            let section_name = trimmed.strip_prefix("# ").unwrap_or(trimmed);
57            current_section = Some(section_name);
58        } else if current_section.is_some() {
59            // Inside a section, collect content
60            section_content.push(line.to_string());
61        } else {
62            // Before any section header - this is the summary
63            result.summary.push(line.to_string());
64        }
65    }
66
67    // Finalize last section
68    finalize_section(&mut result, current_section, &section_content);
69
70    result
71}
72
73/// Process section content and store in the appropriate field.
74fn finalize_section(result: &mut ParsedRustDoc, section: StdOption<&str>, content: &[String]) {
75    let Some(section_name) = section else {
76        return;
77    };
78
79    match section_name {
80        "Arguments" => {
81            // Parse argument list: `* `name` - description` or `* name - description`
82            for line in content {
83                let trimmed = line.trim();
84                let item = trimmed
85                    .strip_prefix("* ")
86                    .or_else(|| trimmed.strip_prefix("- "));
87                // Try to extract parameter name and description
88                // Format: `name` - description OR name - description
89                if let Some(item) = item
90                    && let Some((name, desc)) = parse_param_line(item.trim())
91                {
92                    result.params.insert(name, desc);
93                }
94            }
95        }
96        "Returns" => {
97            // Collect all non-empty lines as return description
98            let desc: String = content
99                .iter()
100                .map(|s| s.trim())
101                .filter(|s| !s.is_empty())
102                .collect::<StdVec<_>>()
103                .join(" ");
104            if !desc.is_empty() {
105                result.returns = Some(desc);
106            }
107        }
108        "Errors" => {
109            // Collect error descriptions for @throws
110            for line in content {
111                let trimmed = line.trim();
112                if !trimmed.is_empty() {
113                    result.errors.push(trimmed.to_string());
114                }
115            }
116        }
117        "Parameters" => {
118            // Parse parameter list with type overrides
119            // Format: - `name`: `type` description
120            for line in content {
121                let trimmed = line.trim();
122                let item = trimmed
123                    .strip_prefix("* ")
124                    .or_else(|| trimmed.strip_prefix("- "));
125                if let Some(item) = item
126                    && let Some((name, ty, desc)) = parse_typed_param_line(item.trim())
127                {
128                    result.param_types.insert(name.clone(), ty);
129                    if !desc.is_empty() {
130                        result.params.insert(name, desc);
131                    }
132                }
133            }
134        }
135        // Ignore other sections like Examples, Panics, Safety, etc.
136        _ => {}
137    }
138}
139
140/// Parse a parameter line from rustdoc `# Arguments` section.
141/// Handles formats like:
142/// - `name` - description
143/// - name - description
144/// - `$name` - description
145fn parse_param_line(line: &str) -> StdOption<(String, String)> {
146    // Try backtick format first: `name` - description
147    if let Some(rest) = line.strip_prefix('`')
148        && let Some(end_tick) = rest.find('`')
149    {
150        let name = &rest[..end_tick];
151        // Skip the closing backtick and find the separator
152        let after_tick = &rest[end_tick + 1..];
153        let desc = after_tick
154            .trim()
155            .strip_prefix('-')
156            .or_else(|| after_tick.trim().strip_prefix(':'))
157            .map_or_else(|| after_tick.trim(), str::trim);
158        // Remove leading $ if present
159        let name = name.strip_prefix('$').unwrap_or(name);
160        return Some((name.to_string(), desc.to_string()));
161    }
162
163    // Try simple format: name - description
164    if let Some(sep_pos) = line.find(" - ") {
165        let name = line[..sep_pos].trim();
166        let desc = line[sep_pos + 3..].trim();
167        // Remove leading $ if present
168        let name = name.strip_prefix('$').unwrap_or(name);
169        return Some((name.to_string(), desc.to_string()));
170    }
171
172    None
173}
174
175/// Parse a typed parameter line from rustdoc `# Parameters` section.
176/// Format: `name`: `type` description
177/// Returns (name, type, description) if successful.
178fn parse_typed_param_line(line: &str) -> StdOption<(String, String, String)> {
179    // Expected format: `name`: `type` description
180    // First, extract the name in backticks
181    let rest = line.strip_prefix('`')?;
182    let end_tick = rest.find('`')?;
183    let name = rest[..end_tick].to_string();
184
185    // Skip to after the colon
186    let after_name = rest[end_tick + 1..].trim();
187    let after_colon = after_name.strip_prefix(':')?.trim();
188
189    // Extract the type in backticks
190    let type_rest = after_colon.strip_prefix('`')?;
191    let type_end_tick = type_rest.find('`')?;
192    let ty = type_rest[..type_end_tick].to_string();
193
194    // The rest is the description
195    let desc = type_rest[type_end_tick + 1..].trim().to_string();
196
197    // Clean up the name (remove $ prefix if present)
198    let name = name.strip_prefix('$').unwrap_or(&name).to_string();
199
200    Some((name, ty, desc))
201}
202
203/// Format a `PHPDoc` comment block for a function or method.
204///
205/// Converts rustdoc-style documentation to `PHPDoc` format, including:
206/// - Summary/description
207/// - @param tags from `# Arguments` section
208/// - @return tag from `# Returns` section
209/// - @throws tags from `# Errors` section
210///
211/// Returns the parameter type overrides map for use in stub signature generation.
212fn format_phpdoc(
213    docs: &DocBlock,
214    params: &[Parameter],
215    ret: StdOption<&Retval>,
216    buf: &mut String,
217) -> Result<HashMap<String, String>, FmtError> {
218    if docs.0.is_empty() && params.is_empty() && ret.is_none() {
219        return Ok(HashMap::new());
220    }
221
222    let parsed = parse_rustdoc(&docs.0);
223
224    // Check if we have any content to output
225    let has_summary = parsed.summary.iter().any(|s| !s.trim().is_empty());
226    let has_params = !params.is_empty();
227    let has_return = ret.is_some();
228    let has_errors = !parsed.errors.is_empty();
229
230    if !has_summary && !has_params && !has_return && !has_errors {
231        return Ok(parsed.param_types);
232    }
233
234    writeln!(buf, "/**")?;
235
236    // Output summary (trim trailing empty lines)
237    let summary_lines: StdVec<_> = parsed
238        .summary
239        .iter()
240        .rev()
241        .skip_while(|s| s.trim().is_empty())
242        .collect::<StdVec<_>>()
243        .into_iter()
244        .rev()
245        .collect();
246
247    for line in &summary_lines {
248        writeln!(buf, " *{line}")?;
249    }
250
251    // Add blank line before tags if we have summary and tags
252    if !summary_lines.is_empty() && (has_params || has_return || has_errors) {
253        writeln!(buf, " *")?;
254    }
255
256    // Output @param tags
257    for param in params {
258        // Use type override from # Parameters section if available and type is mixed
259        let type_str = if let Some(type_override) = parsed.param_types.get(param.name.as_ref()) {
260            // Extract just the type part (strip reference like &$name)
261            extract_php_type(type_override)
262        } else {
263            match &param.ty {
264                Option::Some(ty) => datatype_to_phpdoc(ty, param.nullable),
265                Option::None => "mixed".to_string(),
266            }
267        };
268
269        let desc = parsed.params.get(param.name.as_ref()).cloned();
270        if let Some(desc) = desc {
271            writeln!(buf, " * @param {type_str} ${} {desc}", param.name)?;
272        } else {
273            writeln!(buf, " * @param {type_str} ${}", param.name)?;
274        }
275    }
276
277    // Output @return tag
278    if let Some(retval) = ret {
279        let type_str = datatype_to_phpdoc(&retval.ty, retval.nullable);
280        if let Some(desc) = &parsed.returns {
281            writeln!(buf, " * @return {type_str} {desc}")?;
282        } else {
283            writeln!(buf, " * @return {type_str}")?;
284        }
285    }
286
287    // Output @throws tags
288    for error in &parsed.errors {
289        writeln!(buf, " * @throws \\Exception {error}")?;
290    }
291
292    writeln!(buf, " */")?;
293    Ok(parsed.param_types)
294}
295
296/// Extract the PHP type from a type override string.
297/// Handles formats like "?string &$name" -> "?string"
298fn extract_php_type(type_str: &str) -> String {
299    // The type override might contain reference notation like "&$name"
300    // We want just the type part
301    type_str
302        .split_whitespace()
303        .next()
304        .unwrap_or("mixed")
305        .to_string()
306}
307
308/// Convert a `DataType` to `PHPDoc` type string.
309fn datatype_to_phpdoc(ty: &DataType, nullable: bool) -> String {
310    let base = match ty {
311        DataType::Bool | DataType::True | DataType::False => "bool",
312        DataType::Long => "int",
313        DataType::Double => "float",
314        DataType::String => "string",
315        DataType::Array => "array",
316        DataType::Object(Some(name)) => return format_class_type(name, nullable),
317        DataType::Object(None) => "object",
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        #[cfg(feature = "enum")]
418        for r#enum in &*self.enums {
419            let (ns, name) = split_namespace(r#enum.name.as_ref());
420            insert(ns, name.to_string(), r#enum.to_stub()?);
421        }
422
423        // Sort by entity name, not by rendered output
424        for bucket in entries.values_mut() {
425            bucket.sort_by(|(a, _), (b, _)| a.cmp(b));
426        }
427
428        let mut entries: StdVec<_> = entries.iter().collect();
429        entries.sort_by(|(l, _), (r, _)| match (l, r) {
430            (None, _) => Ordering::Greater,
431            (_, None) => Ordering::Less,
432            (Some(l), Some(r)) => l.cmp(r),
433        });
434
435        buf.push_str(
436            &entries
437                .into_iter()
438                .map(|(ns, entries)| {
439                    let mut buf = String::new();
440                    if let Some(ns) = ns {
441                        writeln!(buf, "namespace {ns} {{")?;
442                    } else {
443                        writeln!(buf, "namespace {{")?;
444                    }
445
446                    buf.push_str(
447                        &entries
448                            .iter()
449                            .map(|(_, stub)| indent(stub, 4))
450                            .collect::<StdVec<_>>()
451                            .join(NEW_LINE_SEPARATOR),
452                    );
453
454                    writeln!(buf, "}}")?;
455                    Ok(buf)
456                })
457                .collect::<Result<StdVec<_>, FmtError>>()?
458                .join(NEW_LINE_SEPARATOR),
459        );
460
461        Ok(())
462    }
463}
464
465impl ToStub for Function {
466    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
467        // Convert rustdoc to PHPDoc format (Issue #369)
468        let ret_ref = match &self.ret {
469            Option::Some(r) => Some(r),
470            Option::None => None,
471        };
472        let type_overrides = format_phpdoc(&self.docs, &self.params, ret_ref, buf)?;
473
474        let (_, name) = split_namespace(self.name.as_ref());
475
476        // Render parameters with type overrides
477        let params_str = self
478            .params
479            .iter()
480            .map(|p| param_to_stub(p, &type_overrides))
481            .collect::<Result<StdVec<_>, FmtError>>()?
482            .join(", ");
483
484        write!(buf, "function {name}({params_str})")?;
485
486        if let Option::Some(retval) = &self.ret {
487            write!(buf, ": ")?;
488            // Don't add ? for mixed/null/void - they already include null or can't be nullable
489            if retval.nullable
490                && !matches!(retval.ty, DataType::Mixed | DataType::Null | DataType::Void)
491            {
492                write!(buf, "?")?;
493            }
494            retval.ty.fmt_stub(buf)?;
495        }
496
497        writeln!(buf, " {{}}")
498    }
499}
500
501/// Render a parameter to stub format, with optional type overrides from rustdoc.
502///
503/// When a parameter's Rust type is `Zval` (which maps to `mixed` in PHP), the
504/// `# Parameters` section in rustdoc can specify a more precise PHP type.
505fn param_to_stub(
506    param: &Parameter,
507    type_overrides: &HashMap<String, String>,
508) -> Result<String, FmtError> {
509    let mut buf = String::new();
510
511    // Check if we should use a type override from # Parameters section
512    // Only use override if the param type is Mixed (i.e., Zval in Rust)
513    let type_override = type_overrides
514        .get(param.name.as_ref())
515        .filter(|_| matches!(&param.ty, Option::Some(DataType::Mixed) | Option::None));
516
517    if let Some(override_str) = type_override {
518        // Use the documented type from # Parameters
519        let type_str = extract_php_type(override_str);
520        write!(buf, "{type_str} ")?;
521    } else if let Option::Some(ty) = &param.ty {
522        // Don't add ? for mixed/null/void - they already include null or can't be nullable
523        if param.nullable && !matches!(ty, DataType::Mixed | DataType::Null | DataType::Void) {
524            write!(buf, "?")?;
525        }
526        ty.fmt_stub(&mut buf)?;
527        write!(buf, " ")?;
528    }
529
530    if param.variadic {
531        write!(buf, "...")?;
532    }
533
534    write!(buf, "${}", param.name)?;
535
536    // Add default value to stub
537    if let Option::Some(default) = &param.default {
538        write!(buf, " = {default}")?;
539    } else if param.nullable {
540        // For nullable parameters without explicit default, add = null
541        // This makes Option<T> parameters truly optional in PHP
542        write!(buf, " = null")?;
543    }
544
545    Ok(buf)
546}
547
548impl ToStub for Parameter {
549    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
550        let empty_overrides = HashMap::new();
551        let result = param_to_stub(self, &empty_overrides)?;
552        buf.push_str(&result);
553        Ok(())
554    }
555}
556
557impl ToStub for DataType {
558    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
559        let mut fqdn = "\\".to_owned();
560        write!(
561            buf,
562            "{}",
563            match self {
564                DataType::Bool | DataType::True | DataType::False => "bool",
565                DataType::Long => "int",
566                DataType::Double => "float",
567                DataType::String => "string",
568                DataType::Array => "array",
569                DataType::Object(Some(ty)) => {
570                    fqdn.push_str(ty);
571                    fqdn.as_str()
572                }
573                DataType::Object(None) => "object",
574                DataType::Resource => "resource",
575                DataType::Reference => "reference",
576                DataType::Callable => "callable",
577                DataType::Iterable => "iterable",
578                DataType::Void => "void",
579                DataType::Null => "null",
580                DataType::Mixed
581                | DataType::Undef
582                | DataType::Ptr
583                | DataType::Indirect
584                | DataType::ConstantExpression => "mixed",
585            }
586        )
587    }
588}
589
590impl ToStub for DocBlock {
591    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
592        if !self.0.is_empty() {
593            writeln!(buf, "/**")?;
594            for comment in self.0.iter() {
595                writeln!(buf, " *{comment}")?;
596            }
597            writeln!(buf, " */")?;
598        }
599        Ok(())
600    }
601}
602
603impl ToStub for Class {
604    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
605        self.docs.fmt_stub(buf)?;
606
607        let (_, name) = split_namespace(self.name.as_ref());
608        let flags = ClassFlags::from_bits(self.flags).unwrap_or(ClassFlags::empty());
609        let is_interface = flags.contains(ClassFlags::Interface);
610
611        if is_interface {
612            write!(buf, "interface {name} ")?;
613        } else {
614            write!(buf, "class {name} ")?;
615        }
616
617        if let Option::Some(extends) = &self.extends {
618            write!(buf, "extends {extends} ")?;
619        }
620
621        if !self.implements.is_empty() && !is_interface {
622            write!(
623                buf,
624                "implements {} ",
625                self.implements
626                    .iter()
627                    .map(RString::as_str)
628                    .collect::<StdVec<_>>()
629                    .join(", ")
630            )?;
631        }
632
633        if !self.implements.is_empty() && is_interface {
634            write!(
635                buf,
636                "extends {} ",
637                self.implements
638                    .iter()
639                    .map(RString::as_str)
640                    .collect::<StdVec<_>>()
641                    .join(", ")
642            )?;
643        }
644
645        writeln!(buf, "{{")?;
646
647        // Collect (sort_key, stub) tuples to sort by name, not by rendered output
648        let mut constants: StdVec<_> = self
649            .constants
650            .iter()
651            .map(|c| {
652                c.to_stub()
653                    .map(|s| (c.name.as_ref().to_string(), indent(&s, 4)))
654            })
655            .collect::<Result<_, FmtError>>()?;
656        let mut properties: StdVec<_> = self
657            .properties
658            .iter()
659            .map(|p| {
660                p.to_stub()
661                    .map(|s| (p.name.as_ref().to_string(), indent(&s, 4)))
662            })
663            .collect::<Result<_, FmtError>>()?;
664        let mut methods: StdVec<_> = self
665            .methods
666            .iter()
667            .map(|m| {
668                m.to_stub()
669                    .map(|s| (m.name.as_ref().to_string(), indent(&s, 4)))
670            })
671            .collect::<Result<_, FmtError>>()?;
672
673        // Sort by entity name
674        constants.sort_by(|(a, _), (b, _)| a.cmp(b));
675        properties.sort_by(|(a, _), (b, _)| a.cmp(b));
676        methods.sort_by(|(a, _), (b, _)| a.cmp(b));
677
678        buf.push_str(
679            &constants
680                .into_iter()
681                .chain(properties)
682                .chain(methods)
683                .map(|(_, stub)| stub)
684                .collect::<StdVec<_>>()
685                .join(NEW_LINE_SEPARATOR),
686        );
687
688        writeln!(buf, "}}")
689    }
690}
691
692#[cfg(feature = "enum")]
693impl ToStub for Enum {
694    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
695        self.docs.fmt_stub(buf)?;
696
697        let (_, name) = split_namespace(self.name.as_ref());
698        write!(buf, "enum {name}")?;
699
700        if let Option::Some(backing_type) = &self.backing_type {
701            write!(buf, ": {backing_type}")?;
702        }
703
704        writeln!(buf, " {{")?;
705
706        for case in self.cases.iter() {
707            case.fmt_stub(buf)?;
708        }
709
710        writeln!(buf, "}}")
711    }
712}
713
714#[cfg(feature = "enum")]
715impl ToStub for EnumCase {
716    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
717        self.docs.fmt_stub(buf)?;
718
719        write!(buf, "  case {}", self.name)?;
720        if let Option::Some(value) = &self.value {
721            write!(buf, " = {value}")?;
722        }
723        writeln!(buf, ";")
724    }
725}
726
727impl ToStub for Property {
728    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
729        self.docs.fmt_stub(buf)?;
730        self.vis.fmt_stub(buf)?;
731
732        write!(buf, " ")?;
733
734        if self.static_ {
735            write!(buf, "static ")?;
736        }
737        if let Option::Some(ty) = &self.ty {
738            ty.fmt_stub(buf)?;
739        }
740        write!(buf, "${}", self.name)?;
741        if let Option::Some(default) = &self.default {
742            write!(buf, " = {default}")?;
743        }
744        writeln!(buf, ";")
745    }
746}
747
748impl ToStub for Visibility {
749    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
750        write!(
751            buf,
752            "{}",
753            match self {
754                Visibility::Private => "private",
755                Visibility::Protected => "protected",
756                Visibility::Public => "public",
757            }
758        )
759    }
760}
761
762impl ToStub for Method {
763    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
764        // Convert rustdoc to PHPDoc format (Issue #369)
765        // Don't include return type for constructors in PHPDoc
766        let ret_ref = if matches!(self.ty, MethodType::Constructor) {
767            None
768        } else {
769            match &self.retval {
770                Option::Some(r) => Some(r),
771                Option::None => None,
772            }
773        };
774        let type_overrides = format_phpdoc(&self.docs, &self.params, ret_ref, buf)?;
775
776        self.visibility.fmt_stub(buf)?;
777
778        write!(buf, " ")?;
779
780        if matches!(self.ty, MethodType::Static) {
781            write!(buf, "static ")?;
782        }
783
784        // Render parameters with type overrides
785        let params_str = self
786            .params
787            .iter()
788            .map(|p| param_to_stub(p, &type_overrides))
789            .collect::<Result<StdVec<_>, FmtError>>()?
790            .join(", ");
791
792        write!(buf, "function {}({params_str})", self.name)?;
793
794        if !matches!(self.ty, MethodType::Constructor)
795            && let Option::Some(retval) = &self.retval
796        {
797            write!(buf, ": ")?;
798            // Don't add ? for mixed/null/void - they already include null or can't be nullable
799            if retval.nullable
800                && !matches!(retval.ty, DataType::Mixed | DataType::Null | DataType::Void)
801            {
802                write!(buf, "?")?;
803            }
804            retval.ty.fmt_stub(buf)?;
805        }
806
807        if self.r#abstract {
808            writeln!(buf, ";")
809        } else {
810            writeln!(buf, " {{}}")
811        }
812    }
813}
814
815impl ToStub for Constant {
816    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
817        self.docs.fmt_stub(buf)?;
818
819        write!(buf, "const {} = ", self.name)?;
820        if let Option::Some(value) = &self.value {
821            write!(buf, "{value}")?;
822        } else {
823            write!(buf, "null")?;
824        }
825        writeln!(buf, ";")
826    }
827}
828
829#[cfg(windows)]
830const NEW_LINE_SEPARATOR: &str = "\r\n";
831#[cfg(not(windows))]
832const NEW_LINE_SEPARATOR: &str = "\n";
833
834/// Takes a class name and splits the namespace off from the actual class name.
835///
836/// # Returns
837///
838/// A tuple, where the first item is the namespace (or [`None`] if not
839/// namespaced), and the second item is the class name.
840fn split_namespace(class: &str) -> (StdOption<&str>, &str) {
841    let idx = class.rfind('\\');
842
843    if let Some(idx) = idx {
844        (Some(&class[0..idx]), &class[idx + 1..])
845    } else {
846        (None, class)
847    }
848}
849
850/// Indents a given string to a given depth. Depth is given in number of spaces
851/// to be appended. Returns a new string with the new indentation. Will not
852/// indent whitespace lines.
853///
854/// # Parameters
855///
856/// * `s` - The string to indent.
857/// * `depth` - The depth to indent the lines to, in spaces.
858///
859/// # Returns
860///
861/// The indented string.
862fn indent(s: &str, depth: usize) -> String {
863    let indent = format!("{:depth$}", "", depth = depth);
864
865    s.split('\n')
866        .map(|line| {
867            let mut result = String::new();
868            if line.chars().any(|c| !c.is_whitespace()) {
869                result.push_str(&indent);
870                result.push_str(line);
871            }
872            result
873        })
874        .collect::<StdVec<_>>()
875        .join(NEW_LINE_SEPARATOR)
876}
877
878#[cfg(test)]
879mod test {
880    use super::{ToStub, split_namespace};
881    use crate::flags::DataType;
882
883    #[test]
884    pub fn test_split_ns() {
885        assert_eq!(split_namespace("ext\\php\\rs"), (Some("ext\\php"), "rs"));
886        assert_eq!(split_namespace("test_solo_ns"), (None, "test_solo_ns"));
887        assert_eq!(split_namespace("simple\\ns"), (Some("simple"), "ns"));
888    }
889
890    #[test]
891    #[cfg(not(windows))]
892    #[allow(clippy::uninlined_format_args)]
893    pub fn test_indent() {
894        use super::indent;
895        use crate::describe::stub::NEW_LINE_SEPARATOR;
896
897        assert_eq!(indent("hello", 4), "    hello");
898        assert_eq!(
899            indent(&format!("hello{nl}world{nl}", nl = NEW_LINE_SEPARATOR), 4),
900            format!("    hello{nl}    world{nl}", nl = NEW_LINE_SEPARATOR)
901        );
902    }
903
904    #[test]
905    #[allow(clippy::unwrap_used)]
906    pub fn test_datatype_to_stub() {
907        // Test that all DataType variants produce correct PHP type strings
908        assert_eq!(DataType::Void.to_stub().unwrap(), "void");
909        assert_eq!(DataType::Null.to_stub().unwrap(), "null");
910        assert_eq!(DataType::Bool.to_stub().unwrap(), "bool");
911        assert_eq!(DataType::True.to_stub().unwrap(), "bool");
912        assert_eq!(DataType::False.to_stub().unwrap(), "bool");
913        assert_eq!(DataType::Long.to_stub().unwrap(), "int");
914        assert_eq!(DataType::Double.to_stub().unwrap(), "float");
915        assert_eq!(DataType::String.to_stub().unwrap(), "string");
916        assert_eq!(DataType::Array.to_stub().unwrap(), "array");
917        assert_eq!(DataType::Object(None).to_stub().unwrap(), "object");
918        assert_eq!(
919            DataType::Object(Some("Foo\\Bar")).to_stub().unwrap(),
920            "\\Foo\\Bar"
921        );
922        assert_eq!(DataType::Resource.to_stub().unwrap(), "resource");
923        assert_eq!(DataType::Callable.to_stub().unwrap(), "callable");
924        assert_eq!(DataType::Iterable.to_stub().unwrap(), "iterable");
925        assert_eq!(DataType::Mixed.to_stub().unwrap(), "mixed");
926        assert_eq!(DataType::Undef.to_stub().unwrap(), "mixed");
927        assert_eq!(DataType::Ptr.to_stub().unwrap(), "mixed");
928        assert_eq!(DataType::Indirect.to_stub().unwrap(), "mixed");
929        assert_eq!(DataType::ConstantExpression.to_stub().unwrap(), "mixed");
930        assert_eq!(DataType::Reference.to_stub().unwrap(), "reference");
931    }
932
933    #[test]
934    fn test_parse_rustdoc() {
935        use super::{Str, parse_rustdoc};
936
937        // Test basic rustdoc parsing
938        let docs: Vec<Str> = vec![
939            " Gives you a nice greeting!".into(),
940            "".into(),
941            " # Arguments".into(),
942            "".into(),
943            " * `name` - Your name".into(),
944            " * `age` - Your age".into(),
945            "".into(),
946            " # Returns".into(),
947            "".into(),
948            " Nice greeting!".into(),
949        ];
950
951        let parsed = parse_rustdoc(&docs);
952
953        // Check summary
954        assert_eq!(parsed.summary.len(), 2);
955        assert!(parsed.summary[0].contains("Gives you a nice greeting"));
956
957        // Check params
958        assert_eq!(parsed.params.len(), 2);
959        assert_eq!(parsed.params.get("name"), Some(&"Your name".to_string()));
960        assert_eq!(parsed.params.get("age"), Some(&"Your age".to_string()));
961
962        // Check returns
963        assert!(parsed.returns.is_some());
964        assert!(
965            parsed
966                .returns
967                .as_ref()
968                .is_some_and(|r| r.contains("Nice greeting"))
969        );
970    }
971
972    #[test]
973    fn test_parse_param_line() {
974        use super::parse_param_line;
975
976        // Test backtick format
977        assert_eq!(
978            parse_param_line("`name` - Your name"),
979            Some(("name".to_string(), "Your name".to_string()))
980        );
981
982        // Test with $ prefix
983        assert_eq!(
984            parse_param_line("`$name` - Your name"),
985            Some(("name".to_string(), "Your name".to_string()))
986        );
987
988        // Test simple format
989        assert_eq!(
990            parse_param_line("name - Your name"),
991            Some(("name".to_string(), "Your name".to_string()))
992        );
993
994        // Test invalid format
995        assert_eq!(parse_param_line("no separator here"), None);
996    }
997
998    #[test]
999    fn test_format_phpdoc() {
1000        use super::{DocBlock, Parameter, Retval, Str, format_phpdoc};
1001        use crate::describe::abi::Option;
1002        use crate::flags::DataType;
1003
1004        // Create a DocBlock with rustdoc content
1005        let docs = DocBlock(
1006            vec![
1007                Str::from(" Greets the user."),
1008                Str::from(""),
1009                Str::from(" # Arguments"),
1010                Str::from(""),
1011                Str::from(" * `name` - The name to greet"),
1012                Str::from(""),
1013                Str::from(" # Returns"),
1014                Str::from(""),
1015                Str::from(" A greeting string."),
1016            ]
1017            .into(),
1018        );
1019
1020        let params = vec![Parameter {
1021            name: "name".into(),
1022            ty: Option::Some(DataType::String),
1023            nullable: false,
1024            variadic: false,
1025            default: Option::None,
1026        }];
1027
1028        let retval = Retval {
1029            ty: DataType::String,
1030            nullable: false,
1031        };
1032
1033        let mut buf = String::new();
1034        format_phpdoc(&docs, &params, Some(&retval), &mut buf).expect("format_phpdoc failed");
1035
1036        // Check that PHPDoc format is produced
1037        assert!(buf.contains("/**"));
1038        assert!(buf.contains("*/"));
1039        assert!(buf.contains("@param string $name The name to greet"));
1040        assert!(buf.contains("@return string A greeting string."));
1041        // Should NOT contain rustdoc section headers
1042        assert!(!buf.contains("# Arguments"));
1043        assert!(!buf.contains("# Returns"));
1044    }
1045}