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        if !self.docs.0.is_empty() {
730            writeln!(buf, "/**")?;
731            for comment in self.docs.0.iter() {
732                writeln!(buf, " *{comment}")?;
733            }
734            if let Option::Some(ty) = &self.ty {
735                writeln!(buf, " *")?;
736                writeln!(buf, " * @var {}", datatype_to_phpdoc(ty, self.nullable))?;
737            }
738            writeln!(buf, " */")?;
739        }
740
741        self.vis.fmt_stub(buf)?;
742        write!(buf, " ")?;
743        if self.static_ {
744            write!(buf, "static ")?;
745        }
746        if self.readonly {
747            write!(buf, "readonly ")?;
748        }
749        if let Option::Some(ty) = &self.ty {
750            let nullable = self.nullable && !matches!(ty, DataType::Mixed | DataType::Null);
751            if nullable {
752                write!(buf, "?")?;
753            }
754            ty.fmt_stub(buf)?;
755            write!(buf, " ")?;
756        }
757        write!(buf, "${}", self.name)?;
758        if let Option::Some(default) = &self.default {
759            write!(buf, " = {default}")?;
760        }
761        writeln!(buf, ";")
762    }
763}
764
765impl ToStub for Visibility {
766    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
767        write!(
768            buf,
769            "{}",
770            match self {
771                Visibility::Private => "private",
772                Visibility::Protected => "protected",
773                Visibility::Public => "public",
774            }
775        )
776    }
777}
778
779impl ToStub for Method {
780    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
781        // Convert rustdoc to PHPDoc format (Issue #369)
782        // Don't include return type for constructors in PHPDoc
783        let ret_ref = if matches!(self.ty, MethodType::Constructor) {
784            None
785        } else {
786            match &self.retval {
787                Option::Some(r) => Some(r),
788                Option::None => None,
789            }
790        };
791        let type_overrides = format_phpdoc(&self.docs, &self.params, ret_ref, buf)?;
792
793        self.visibility.fmt_stub(buf)?;
794
795        write!(buf, " ")?;
796
797        if matches!(self.ty, MethodType::Static) {
798            write!(buf, "static ")?;
799        }
800
801        // Render parameters with type overrides
802        let params_str = self
803            .params
804            .iter()
805            .map(|p| param_to_stub(p, &type_overrides))
806            .collect::<Result<StdVec<_>, FmtError>>()?
807            .join(", ");
808
809        write!(buf, "function {}({params_str})", self.name)?;
810
811        if !matches!(self.ty, MethodType::Constructor)
812            && let Option::Some(retval) = &self.retval
813        {
814            write!(buf, ": ")?;
815            // Don't add ? for mixed/null/void - they already include null or can't be nullable
816            if retval.nullable
817                && !matches!(retval.ty, DataType::Mixed | DataType::Null | DataType::Void)
818            {
819                write!(buf, "?")?;
820            }
821            retval.ty.fmt_stub(buf)?;
822        }
823
824        if self.r#abstract {
825            writeln!(buf, ";")
826        } else {
827            writeln!(buf, " {{}}")
828        }
829    }
830}
831
832impl ToStub for Constant {
833    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
834        self.docs.fmt_stub(buf)?;
835
836        write!(buf, "const {} = ", self.name)?;
837        if let Option::Some(value) = &self.value {
838            write!(buf, "{value}")?;
839        } else {
840            write!(buf, "null")?;
841        }
842        writeln!(buf, ";")
843    }
844}
845
846#[cfg(windows)]
847const NEW_LINE_SEPARATOR: &str = "\r\n";
848#[cfg(not(windows))]
849const NEW_LINE_SEPARATOR: &str = "\n";
850
851/// Takes a class name and splits the namespace off from the actual class name.
852///
853/// # Returns
854///
855/// A tuple, where the first item is the namespace (or [`None`] if not
856/// namespaced), and the second item is the class name.
857fn split_namespace(class: &str) -> (StdOption<&str>, &str) {
858    let idx = class.rfind('\\');
859
860    if let Some(idx) = idx {
861        (Some(&class[0..idx]), &class[idx + 1..])
862    } else {
863        (None, class)
864    }
865}
866
867/// Indents a given string to a given depth. Depth is given in number of spaces
868/// to be appended. Returns a new string with the new indentation. Will not
869/// indent whitespace lines.
870///
871/// # Parameters
872///
873/// * `s` - The string to indent.
874/// * `depth` - The depth to indent the lines to, in spaces.
875///
876/// # Returns
877///
878/// The indented string.
879fn indent(s: &str, depth: usize) -> String {
880    let indent = format!("{:depth$}", "", depth = depth);
881
882    s.split('\n')
883        .map(|line| {
884            let mut result = String::new();
885            if line.chars().any(|c| !c.is_whitespace()) {
886                result.push_str(&indent);
887                result.push_str(line);
888            }
889            result
890        })
891        .collect::<StdVec<_>>()
892        .join(NEW_LINE_SEPARATOR)
893}
894
895#[cfg(test)]
896mod test {
897    use super::{ToStub, split_namespace};
898    use crate::flags::DataType;
899
900    #[test]
901    pub fn test_split_ns() {
902        assert_eq!(split_namespace("ext\\php\\rs"), (Some("ext\\php"), "rs"));
903        assert_eq!(split_namespace("test_solo_ns"), (None, "test_solo_ns"));
904        assert_eq!(split_namespace("simple\\ns"), (Some("simple"), "ns"));
905    }
906
907    #[test]
908    #[cfg(not(windows))]
909    #[allow(clippy::uninlined_format_args)]
910    pub fn test_indent() {
911        use super::indent;
912        use crate::describe::stub::NEW_LINE_SEPARATOR;
913
914        assert_eq!(indent("hello", 4), "    hello");
915        assert_eq!(
916            indent(&format!("hello{nl}world{nl}", nl = NEW_LINE_SEPARATOR), 4),
917            format!("    hello{nl}    world{nl}", nl = NEW_LINE_SEPARATOR)
918        );
919    }
920
921    #[test]
922    #[allow(clippy::unwrap_used)]
923    pub fn test_datatype_to_stub() {
924        // Test that all DataType variants produce correct PHP type strings
925        assert_eq!(DataType::Void.to_stub().unwrap(), "void");
926        assert_eq!(DataType::Null.to_stub().unwrap(), "null");
927        assert_eq!(DataType::Bool.to_stub().unwrap(), "bool");
928        assert_eq!(DataType::True.to_stub().unwrap(), "bool");
929        assert_eq!(DataType::False.to_stub().unwrap(), "bool");
930        assert_eq!(DataType::Long.to_stub().unwrap(), "int");
931        assert_eq!(DataType::Double.to_stub().unwrap(), "float");
932        assert_eq!(DataType::String.to_stub().unwrap(), "string");
933        assert_eq!(DataType::Array.to_stub().unwrap(), "array");
934        assert_eq!(DataType::Object(None).to_stub().unwrap(), "object");
935        assert_eq!(
936            DataType::Object(Some("Foo\\Bar")).to_stub().unwrap(),
937            "\\Foo\\Bar"
938        );
939        assert_eq!(DataType::Resource.to_stub().unwrap(), "resource");
940        assert_eq!(DataType::Callable.to_stub().unwrap(), "callable");
941        assert_eq!(DataType::Iterable.to_stub().unwrap(), "iterable");
942        assert_eq!(DataType::Mixed.to_stub().unwrap(), "mixed");
943        assert_eq!(DataType::Undef.to_stub().unwrap(), "mixed");
944        assert_eq!(DataType::Ptr.to_stub().unwrap(), "mixed");
945        assert_eq!(DataType::Indirect.to_stub().unwrap(), "mixed");
946        assert_eq!(DataType::ConstantExpression.to_stub().unwrap(), "mixed");
947        assert_eq!(DataType::Reference.to_stub().unwrap(), "reference");
948    }
949
950    #[test]
951    #[allow(clippy::unwrap_used)]
952    fn test_property_stub_typed_no_docs() {
953        use crate::describe::{Property, Visibility, abi::Option};
954
955        let prop = Property {
956            name: "foo".into(),
957            docs: super::DocBlock(vec![].into()),
958            ty: Option::Some(DataType::String),
959            vis: Visibility::Public,
960            static_: false,
961            nullable: false,
962            readonly: false,
963            default: Option::None,
964        };
965        let stub = prop.to_stub().unwrap();
966        // No docs → no docblock at all (type declaration is sufficient in PHP 8.0+)
967        assert!(!stub.contains("@var"), "no @var without docs: {stub}");
968        assert_eq!(stub, "public string $foo;\n");
969    }
970
971    #[test]
972    #[allow(clippy::unwrap_used)]
973    fn test_property_stub_nullable_with_default() {
974        use crate::describe::{Property, Visibility, abi::Option};
975
976        let prop = Property {
977            name: "bar".into(),
978            docs: super::DocBlock(vec![].into()),
979            ty: Option::Some(DataType::String),
980            vis: Visibility::Public,
981            static_: false,
982            nullable: true,
983            readonly: false,
984            default: Option::Some("null".into()),
985        };
986        let stub = prop.to_stub().unwrap();
987        assert!(
988            stub.contains("public ?string $bar = null;"),
989            "missing nullable default: {stub}"
990        );
991    }
992
993    #[test]
994    #[allow(clippy::unwrap_used)]
995    fn test_property_stub_static_with_default() {
996        use crate::describe::{Property, Visibility, abi::Option};
997
998        let prop = Property {
999            name: "limit".into(),
1000            docs: super::DocBlock(vec![].into()),
1001            ty: Option::Some(DataType::Long),
1002            vis: Visibility::Public,
1003            static_: true,
1004            nullable: false,
1005            readonly: false,
1006            default: Option::Some("100".into()),
1007        };
1008        let stub = prop.to_stub().unwrap();
1009        assert!(
1010            stub.contains("public static int $limit = 100;"),
1011            "missing static default: {stub}"
1012        );
1013    }
1014
1015    #[test]
1016    #[allow(clippy::unwrap_used)]
1017    fn test_property_stub_static_string_default() {
1018        use crate::describe::{Property, Visibility, abi::Option};
1019
1020        let prop = Property {
1021            name: "label".into(),
1022            docs: super::DocBlock(vec![].into()),
1023            ty: Option::Some(DataType::String),
1024            vis: Visibility::Public,
1025            static_: true,
1026            nullable: false,
1027            readonly: false,
1028            default: Option::Some("'hello'".into()),
1029        };
1030        let stub = prop.to_stub().unwrap();
1031        assert!(
1032            stub.contains("public static string $label = 'hello';"),
1033            "missing static string default: {stub}"
1034        );
1035    }
1036
1037    #[test]
1038    #[allow(clippy::unwrap_used)]
1039    fn test_property_stub_with_docs_includes_var() {
1040        use crate::describe::{Property, Visibility, abi::Option};
1041
1042        let prop = Property {
1043            name: "bar".into(),
1044            docs: super::DocBlock(vec![" The user name.".into()].into()),
1045            ty: Option::Some(DataType::String),
1046            vis: Visibility::Public,
1047            static_: false,
1048            nullable: true,
1049            readonly: false,
1050            default: Option::None,
1051        };
1052        let stub = prop.to_stub().unwrap();
1053        assert!(stub.contains("The user name."), "missing doc: {stub}");
1054        assert!(
1055            stub.contains("@var string|null"),
1056            "missing @var with nullable in docblock: {stub}"
1057        );
1058        assert!(
1059            stub.contains("public ?string $bar;"),
1060            "missing decl: {stub}"
1061        );
1062    }
1063
1064    #[test]
1065    #[allow(clippy::unwrap_used)]
1066    fn test_property_stub_with_docs_no_type() {
1067        use crate::describe::{Property, Visibility, abi::Option};
1068
1069        let prop = Property {
1070            name: "x".into(),
1071            docs: super::DocBlock(vec![" Some value.".into()].into()),
1072            ty: Option::None,
1073            vis: Visibility::Public,
1074            static_: false,
1075            nullable: false,
1076            readonly: false,
1077            default: Option::None,
1078        };
1079        let stub = prop.to_stub().unwrap();
1080        assert!(stub.contains("Some value."), "missing doc: {stub}");
1081        assert!(!stub.contains("@var"), "no @var without type: {stub}");
1082        assert!(stub.contains("public $x;"), "missing decl: {stub}");
1083    }
1084
1085    #[test]
1086    #[allow(clippy::unwrap_used)]
1087    fn test_property_stub_readonly() {
1088        use crate::describe::{Property, Visibility, abi::Option};
1089
1090        let prop = Property {
1091            name: "baz".into(),
1092            docs: super::DocBlock(vec![].into()),
1093            ty: Option::Some(DataType::Array),
1094            vis: Visibility::Public,
1095            static_: false,
1096            nullable: false,
1097            readonly: true,
1098            default: Option::None,
1099        };
1100        let stub = prop.to_stub().unwrap();
1101        assert_eq!(stub, "public readonly array $baz;\n");
1102    }
1103
1104    #[test]
1105    #[allow(clippy::unwrap_used)]
1106    fn test_property_stub_untyped_no_docblock() {
1107        use crate::describe::{Property, Visibility, abi::Option};
1108
1109        let prop = Property {
1110            name: "x".into(),
1111            docs: super::DocBlock(vec![].into()),
1112            ty: Option::None,
1113            vis: Visibility::Public,
1114            static_: false,
1115            nullable: false,
1116            readonly: false,
1117            default: Option::None,
1118        };
1119        let stub = prop.to_stub().unwrap();
1120        assert!(
1121            !stub.contains("/**"),
1122            "no docblock without docs or type: {stub}"
1123        );
1124        assert_eq!(stub, "public $x;\n");
1125    }
1126
1127    #[test]
1128    #[allow(clippy::unwrap_used)]
1129    fn test_property_stub_static_typed() {
1130        use crate::describe::{Property, Visibility, abi::Option};
1131
1132        let prop = Property {
1133            name: "count".into(),
1134            docs: super::DocBlock(vec![].into()),
1135            ty: Option::Some(DataType::Long),
1136            vis: Visibility::Protected,
1137            static_: true,
1138            nullable: false,
1139            readonly: false,
1140            default: Option::None,
1141        };
1142        let stub = prop.to_stub().unwrap();
1143        assert!(
1144            stub.contains("protected static int $count;"),
1145            "missing decl: {stub}"
1146        );
1147    }
1148
1149    #[test]
1150    #[allow(clippy::unwrap_used)]
1151    fn test_property_stub_nullable_mixed_stays_mixed() {
1152        use crate::describe::{Property, Visibility, abi::Option};
1153
1154        let prop = Property {
1155            name: "val".into(),
1156            docs: super::DocBlock(vec![].into()),
1157            ty: Option::Some(DataType::Mixed),
1158            vis: Visibility::Public,
1159            static_: false,
1160            nullable: true,
1161            readonly: false,
1162            default: Option::None,
1163        };
1164        let stub = prop.to_stub().unwrap();
1165        // mixed already includes null, no ? prefix
1166        assert!(stub.contains("public mixed $val;"), "missing decl: {stub}");
1167    }
1168
1169    #[test]
1170    #[allow(clippy::unwrap_used)]
1171    fn test_property_stub_nullable_object_with_docs() {
1172        use crate::describe::{Property, Visibility, abi::Option};
1173
1174        let prop = Property {
1175            name: "ref_".into(),
1176            docs: super::DocBlock(vec![" The related entity.".into()].into()),
1177            ty: Option::Some(DataType::Object(Some("App\\Entity"))),
1178            vis: Visibility::Private,
1179            static_: false,
1180            nullable: true,
1181            readonly: false,
1182            default: Option::None,
1183        };
1184        let stub = prop.to_stub().unwrap();
1185        assert!(stub.contains("The related entity."), "missing doc: {stub}");
1186        assert!(
1187            stub.contains("@var \\App\\Entity|null"),
1188            "missing @var with FQCN: {stub}"
1189        );
1190        assert!(
1191            stub.contains("private ?\\App\\Entity $ref_;"),
1192            "missing decl: {stub}"
1193        );
1194    }
1195
1196    #[test]
1197    fn test_parse_rustdoc() {
1198        use super::{Str, parse_rustdoc};
1199
1200        // Test basic rustdoc parsing
1201        let docs: Vec<Str> = vec![
1202            " Gives you a nice greeting!".into(),
1203            "".into(),
1204            " # Arguments".into(),
1205            "".into(),
1206            " * `name` - Your name".into(),
1207            " * `age` - Your age".into(),
1208            "".into(),
1209            " # Returns".into(),
1210            "".into(),
1211            " Nice greeting!".into(),
1212        ];
1213
1214        let parsed = parse_rustdoc(&docs);
1215
1216        // Check summary
1217        assert_eq!(parsed.summary.len(), 2);
1218        assert!(parsed.summary[0].contains("Gives you a nice greeting"));
1219
1220        // Check params
1221        assert_eq!(parsed.params.len(), 2);
1222        assert_eq!(parsed.params.get("name"), Some(&"Your name".to_string()));
1223        assert_eq!(parsed.params.get("age"), Some(&"Your age".to_string()));
1224
1225        // Check returns
1226        assert!(parsed.returns.is_some());
1227        assert!(
1228            parsed
1229                .returns
1230                .as_ref()
1231                .is_some_and(|r| r.contains("Nice greeting"))
1232        );
1233    }
1234
1235    #[test]
1236    fn test_parse_param_line() {
1237        use super::parse_param_line;
1238
1239        // Test backtick format
1240        assert_eq!(
1241            parse_param_line("`name` - Your name"),
1242            Some(("name".to_string(), "Your name".to_string()))
1243        );
1244
1245        // Test with $ prefix
1246        assert_eq!(
1247            parse_param_line("`$name` - Your name"),
1248            Some(("name".to_string(), "Your name".to_string()))
1249        );
1250
1251        // Test simple format
1252        assert_eq!(
1253            parse_param_line("name - Your name"),
1254            Some(("name".to_string(), "Your name".to_string()))
1255        );
1256
1257        // Test invalid format
1258        assert_eq!(parse_param_line("no separator here"), None);
1259    }
1260
1261    #[test]
1262    fn test_format_phpdoc() {
1263        use super::{DocBlock, Parameter, Retval, Str, format_phpdoc};
1264        use crate::describe::abi::Option;
1265        use crate::flags::DataType;
1266
1267        // Create a DocBlock with rustdoc content
1268        let docs = DocBlock(
1269            vec![
1270                Str::from(" Greets the user."),
1271                Str::from(""),
1272                Str::from(" # Arguments"),
1273                Str::from(""),
1274                Str::from(" * `name` - The name to greet"),
1275                Str::from(""),
1276                Str::from(" # Returns"),
1277                Str::from(""),
1278                Str::from(" A greeting string."),
1279            ]
1280            .into(),
1281        );
1282
1283        let params = vec![Parameter {
1284            name: "name".into(),
1285            ty: Option::Some(DataType::String),
1286            nullable: false,
1287            variadic: false,
1288            default: Option::None,
1289        }];
1290
1291        let retval = Retval {
1292            ty: DataType::String,
1293            nullable: false,
1294        };
1295
1296        let mut buf = String::new();
1297        format_phpdoc(&docs, &params, Some(&retval), &mut buf).expect("format_phpdoc failed");
1298
1299        // Check that PHPDoc format is produced
1300        assert!(buf.contains("/**"));
1301        assert!(buf.contains("*/"));
1302        assert!(buf.contains("@param string $name The name to greet"));
1303        assert!(buf.contains("@return string A greeting string."));
1304        // Should NOT contain rustdoc section headers
1305        assert!(!buf.contains("# Arguments"));
1306        assert!(!buf.contains("# Returns"));
1307    }
1308}