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, 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/// Formats a fully qualified class reference, adding the leading backslash
335/// only when it is missing.
336fn format_class_type(name: &str, nullable: bool) -> String {
337    let class_name = if name.starts_with('\\') {
338        name.to_string()
339    } else {
340        format!("\\{name}")
341    };
342
343    if nullable {
344        format!("{class_name}|null")
345    } else {
346        class_name
347    }
348}
349
350/// Implemented on types which can be converted into PHP stubs.
351pub trait ToStub {
352    /// Converts the implementor into PHP code, represented as a PHP stub.
353    /// Returned as a string.
354    ///
355    /// # Returns
356    ///
357    /// Returns a string on success.
358    ///
359    /// # Errors
360    ///
361    /// Returns an error if there was an error writing into the string.
362    fn to_stub(&self) -> Result<String, FmtError> {
363        let mut buf = String::new();
364        self.fmt_stub(&mut buf)?;
365        Ok(buf)
366    }
367
368    /// Converts the implementor into PHP code, represented as a PHP stub.
369    ///
370    /// # Parameters
371    ///
372    /// * `buf` - The buffer to write the PHP code into.
373    ///
374    /// # Returns
375    ///
376    /// Returns nothing on success.
377    ///
378    /// # Errors
379    ///
380    /// Returns an error if there was an error writing into the buffer.
381    fn fmt_stub(&self, buf: &mut String) -> FmtResult;
382}
383
384impl ToStub for Module {
385    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
386        writeln!(buf, "<?php")?;
387        writeln!(buf)?;
388        writeln!(buf, "// Stubs for {}", self.name.as_ref())?;
389        writeln!(buf)?;
390
391        // To account for namespaces we need to group by them. [`None`] as the key
392        // represents no namespace, while [`Some`] represents a namespace.
393        // Store (sort_key, stub) tuples to sort by name, not by rendered output.
394        let mut entries: HashMap<StdOption<&str>, StdVec<(String, String)>> = HashMap::new();
395
396        // Inserts a value into the entries hashmap. Takes a key, sort key, and entry,
397        // creating the internal vector if it doesn't already exist.
398        let mut insert = |ns, sort_key: String, entry| {
399            let bucket = entries.entry(ns).or_default();
400            bucket.push((sort_key, entry));
401        };
402
403        for c in &*self.constants {
404            let (ns, name) = split_namespace(c.name.as_ref());
405            insert(ns, name.to_string(), c.to_stub()?);
406        }
407
408        for func in &*self.functions {
409            let (ns, name) = split_namespace(func.name.as_ref());
410            insert(ns, name.to_string(), func.to_stub()?);
411        }
412
413        for class in &*self.classes {
414            let (ns, name) = split_namespace(class.name.as_ref());
415            insert(ns, name.to_string(), class.to_stub()?);
416        }
417
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 qualified;
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(_) => match self.class_name() {
570                    Some(name) => {
571                        qualified = format_class_type(name, false);
572                        qualified.as_str()
573                    }
574                    None => "object",
575                },
576                DataType::Resource => "resource",
577                DataType::Reference => "reference",
578                DataType::Callable => "callable",
579                DataType::Iterable => "iterable",
580                DataType::Void => "void",
581                DataType::Null => "null",
582                DataType::Mixed
583                | DataType::Undef
584                | DataType::Ptr
585                | DataType::Indirect
586                | DataType::ConstantExpression => "mixed",
587            }
588        )
589    }
590}
591
592impl ToStub for DocBlock {
593    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
594        if !self.0.is_empty() {
595            writeln!(buf, "/**")?;
596            for comment in self.0.iter() {
597                writeln!(buf, " *{comment}")?;
598            }
599            writeln!(buf, " */")?;
600        }
601        Ok(())
602    }
603}
604
605impl ToStub for Class {
606    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
607        self.docs.fmt_stub(buf)?;
608
609        let (_, name) = split_namespace(self.name.as_ref());
610        if self.is_interface {
611            write!(buf, "interface {name} ")?;
612        } else {
613            write!(buf, "class {name} ")?;
614        }
615
616        if let Option::Some(extends) = &self.extends {
617            write!(
618                buf,
619                "extends {} ",
620                format_class_type(extends.as_str(), false)
621            )?;
622        }
623
624        if !self.implements.is_empty() {
625            let keyword = if self.is_interface {
626                "extends"
627            } else {
628                "implements"
629            };
630            let interfaces = self
631                .implements
632                .iter()
633                .map(|interface| format_class_type(interface.as_str(), false))
634                .collect::<StdVec<_>>()
635                .join(", ");
636            write!(buf, "{keyword} {interfaces} ")?;
637        }
638
639        writeln!(buf, "{{")?;
640
641        // Collect (sort_key, stub) tuples to sort by name, not by rendered output
642        let mut constants: StdVec<_> = self
643            .constants
644            .iter()
645            .map(|c| {
646                c.class_stub()
647                    .map(|s| (c.name.as_ref().to_string(), indent(&s, 4)))
648            })
649            .collect::<Result<_, FmtError>>()?;
650        let mut properties: StdVec<_> = self
651            .properties
652            .iter()
653            .map(|p| {
654                p.to_stub()
655                    .map(|s| (p.name.as_ref().to_string(), indent(&s, 4)))
656            })
657            .collect::<Result<_, FmtError>>()?;
658        let mut methods: StdVec<_> = self
659            .methods
660            .iter()
661            .map(|m| {
662                m.to_stub()
663                    .map(|s| (m.name.as_ref().to_string(), indent(&s, 4)))
664            })
665            .collect::<Result<_, FmtError>>()?;
666
667        // Sort by entity name
668        constants.sort_by(|(a, _), (b, _)| a.cmp(b));
669        properties.sort_by(|(a, _), (b, _)| a.cmp(b));
670        methods.sort_by(|(a, _), (b, _)| a.cmp(b));
671
672        buf.push_str(
673            &constants
674                .into_iter()
675                .chain(properties)
676                .chain(methods)
677                .map(|(_, stub)| stub)
678                .collect::<StdVec<_>>()
679                .join(NEW_LINE_SEPARATOR),
680        );
681
682        writeln!(buf, "}}")
683    }
684}
685
686impl ToStub for Enum {
687    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
688        self.docs.fmt_stub(buf)?;
689
690        let (_, name) = split_namespace(self.name.as_ref());
691        write!(buf, "enum {name}")?;
692
693        if let Option::Some(backing_type) = &self.backing_type {
694            write!(buf, ": {backing_type}")?;
695        }
696
697        writeln!(buf, " {{")?;
698
699        for case in self.cases.iter() {
700            case.fmt_stub(buf)?;
701        }
702
703        writeln!(buf, "}}")
704    }
705}
706
707impl ToStub for EnumCase {
708    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
709        self.docs.fmt_stub(buf)?;
710
711        write!(buf, "  case {}", self.name)?;
712        if let Option::Some(value) = &self.value {
713            write!(buf, " = {value}")?;
714        }
715        writeln!(buf, ";")
716    }
717}
718
719impl ToStub for Property {
720    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
721        if !self.docs.0.is_empty() {
722            writeln!(buf, "/**")?;
723            for comment in self.docs.0.iter() {
724                writeln!(buf, " *{comment}")?;
725            }
726            if let Option::Some(ty) = &self.ty {
727                writeln!(buf, " *")?;
728                writeln!(buf, " * @var {}", datatype_to_phpdoc(ty, self.nullable))?;
729            }
730            writeln!(buf, " */")?;
731        }
732
733        self.vis.fmt_stub(buf)?;
734        write!(buf, " ")?;
735        if self.static_ {
736            write!(buf, "static ")?;
737        }
738        if self.readonly {
739            write!(buf, "readonly ")?;
740        }
741        if let Option::Some(ty) = &self.ty {
742            let nullable = self.nullable && !matches!(ty, DataType::Mixed | DataType::Null);
743            if nullable {
744                write!(buf, "?")?;
745            }
746            ty.fmt_stub(buf)?;
747            write!(buf, " ")?;
748        }
749        write!(buf, "${}", self.name)?;
750        if let Option::Some(default) = &self.default {
751            write!(buf, " = {default}")?;
752        }
753        writeln!(buf, ";")
754    }
755}
756
757impl ToStub for Visibility {
758    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
759        write!(
760            buf,
761            "{}",
762            match self {
763                Visibility::Private => "private",
764                Visibility::Protected => "protected",
765                Visibility::Public => "public",
766            }
767        )
768    }
769}
770
771impl ToStub for Method {
772    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
773        // Convert rustdoc to PHPDoc format (Issue #369)
774        // Don't include return type for constructors in PHPDoc
775        let ret_ref = if matches!(self.ty, MethodType::Constructor) {
776            None
777        } else {
778            match &self.retval {
779                Option::Some(r) => Some(r),
780                Option::None => None,
781            }
782        };
783        let type_overrides = format_phpdoc(&self.docs, &self.params, ret_ref, buf)?;
784
785        self.visibility.fmt_stub(buf)?;
786
787        write!(buf, " ")?;
788
789        if matches!(self.ty, MethodType::Static) {
790            write!(buf, "static ")?;
791        }
792
793        // Render parameters with type overrides
794        let params_str = self
795            .params
796            .iter()
797            .map(|p| param_to_stub(p, &type_overrides))
798            .collect::<Result<StdVec<_>, FmtError>>()?
799            .join(", ");
800
801        write!(buf, "function {}({params_str})", self.name)?;
802
803        if !matches!(self.ty, MethodType::Constructor)
804            && let Option::Some(retval) = &self.retval
805        {
806            write!(buf, ": ")?;
807            // Don't add ? for mixed/null/void - they already include null or can't be nullable
808            if retval.nullable
809                && !matches!(retval.ty, DataType::Mixed | DataType::Null | DataType::Void)
810            {
811                write!(buf, "?")?;
812            }
813            retval.ty.fmt_stub(buf)?;
814        }
815
816        if self.r#abstract {
817            writeln!(buf, ";")
818        } else {
819            writeln!(buf, " {{}}")
820        }
821    }
822}
823
824impl Constant {
825    fn class_stub(&self) -> Result<String, FmtError> {
826        let mut buf = String::new();
827        self.docs.fmt_stub(&mut buf)?;
828        self.visibility.fmt_stub(&mut buf)?;
829        write!(buf, " ")?;
830        self.fmt_declaration(&mut buf)?;
831        Ok(buf)
832    }
833
834    fn fmt_declaration(&self, buf: &mut String) -> FmtResult {
835        write!(buf, "const {} = ", self.name)?;
836        if let Option::Some(value) = &self.value {
837            write!(buf, "{value}")?;
838        } else {
839            write!(buf, "null")?;
840        }
841        writeln!(buf, ";")
842    }
843}
844
845impl ToStub for Constant {
846    fn fmt_stub(&self, buf: &mut String) -> FmtResult {
847        self.docs.fmt_stub(buf)?;
848        self.fmt_declaration(buf)
849    }
850}
851
852#[cfg(windows)]
853const NEW_LINE_SEPARATOR: &str = "\r\n";
854#[cfg(not(windows))]
855const NEW_LINE_SEPARATOR: &str = "\n";
856
857/// Takes a class name and splits the namespace off from the actual class name.
858///
859/// # Returns
860///
861/// A tuple, where the first item is the namespace (or [`None`] if not
862/// namespaced), and the second item is the class name.
863fn split_namespace(class: &str) -> (StdOption<&str>, &str) {
864    let idx = class.rfind('\\');
865
866    if let Some(idx) = idx {
867        (Some(&class[0..idx]), &class[idx + 1..])
868    } else {
869        (None, class)
870    }
871}
872
873/// Indents a given string to a given depth. Depth is given in number of spaces
874/// to be appended. Returns a new string with the new indentation. Will not
875/// indent whitespace lines.
876///
877/// # Parameters
878///
879/// * `s` - The string to indent.
880/// * `depth` - The depth to indent the lines to, in spaces.
881///
882/// # Returns
883///
884/// The indented string.
885fn indent(s: &str, depth: usize) -> String {
886    let indent = format!("{:depth$}", "", depth = depth);
887
888    s.split('\n')
889        .map(|line| {
890            let mut result = String::new();
891            if line.chars().any(|c| !c.is_whitespace()) {
892                result.push_str(&indent);
893                result.push_str(line);
894            }
895            result
896        })
897        .collect::<StdVec<_>>()
898        .join(NEW_LINE_SEPARATOR)
899}
900
901#[cfg(test)]
902mod test {
903    use super::{StdOption, StdVec, ToStub, split_namespace};
904    use crate::{Class, DataType, DocBlock, Module};
905
906    #[test]
907    pub fn test_split_ns() {
908        assert_eq!(split_namespace("ext\\php\\rs"), (Some("ext\\php"), "rs"));
909        assert_eq!(split_namespace("test_solo_ns"), (None, "test_solo_ns"));
910        assert_eq!(split_namespace("simple\\ns"), (Some("simple"), "ns"));
911    }
912
913    #[test]
914    #[cfg(not(windows))]
915    #[allow(clippy::uninlined_format_args)]
916    pub fn test_indent() {
917        use super::NEW_LINE_SEPARATOR;
918        use super::indent;
919
920        assert_eq!(indent("hello", 4), "    hello");
921        assert_eq!(
922            indent(&format!("hello{nl}world{nl}", nl = NEW_LINE_SEPARATOR), 4),
923            format!("    hello{nl}    world{nl}", nl = NEW_LINE_SEPARATOR)
924        );
925    }
926
927    #[test]
928    #[allow(clippy::unwrap_used)]
929    pub fn test_datatype_to_stub() {
930        // Test that all DataType variants produce correct PHP type strings
931        assert_eq!(DataType::Void.to_stub().unwrap(), "void");
932        assert_eq!(DataType::Null.to_stub().unwrap(), "null");
933        assert_eq!(DataType::Bool.to_stub().unwrap(), "bool");
934        assert_eq!(DataType::True.to_stub().unwrap(), "bool");
935        assert_eq!(DataType::False.to_stub().unwrap(), "bool");
936        assert_eq!(DataType::Long.to_stub().unwrap(), "int");
937        assert_eq!(DataType::Double.to_stub().unwrap(), "float");
938        assert_eq!(DataType::String.to_stub().unwrap(), "string");
939        assert_eq!(DataType::Array.to_stub().unwrap(), "array");
940        assert_eq!(DataType::ANY_OBJECT.to_stub().unwrap(), "object");
941        assert_eq!(
942            DataType::object("Foo\\Bar").to_stub().unwrap(),
943            "\\Foo\\Bar"
944        );
945        assert_eq!(DataType::Resource.to_stub().unwrap(), "resource");
946        assert_eq!(DataType::Callable.to_stub().unwrap(), "callable");
947        assert_eq!(DataType::Iterable.to_stub().unwrap(), "iterable");
948        assert_eq!(DataType::Mixed.to_stub().unwrap(), "mixed");
949        assert_eq!(DataType::Undef.to_stub().unwrap(), "mixed");
950        assert_eq!(DataType::Ptr.to_stub().unwrap(), "mixed");
951        assert_eq!(DataType::Indirect.to_stub().unwrap(), "mixed");
952        assert_eq!(DataType::ConstantExpression.to_stub().unwrap(), "mixed");
953        assert_eq!(DataType::Reference.to_stub().unwrap(), "reference");
954    }
955
956    #[test]
957    #[allow(clippy::unwrap_used)]
958    fn test_property_stub_typed_no_docs() {
959        use crate::{Property, Visibility, abi::Option};
960
961        let prop = Property {
962            name: "foo".into(),
963            docs: super::DocBlock(vec![].into()),
964            ty: Option::Some(DataType::String),
965            vis: Visibility::Public,
966            static_: false,
967            nullable: false,
968            readonly: false,
969            default: Option::None,
970        };
971        let stub = prop.to_stub().unwrap();
972        // No docs → no docblock at all (type declaration is sufficient in PHP 8.0+)
973        assert!(!stub.contains("@var"), "no @var without docs: {stub}");
974        assert_eq!(stub, "public string $foo;\n");
975    }
976
977    #[test]
978    #[allow(clippy::unwrap_used)]
979    fn test_property_stub_nullable_with_default() {
980        use crate::{Property, Visibility, abi::Option};
981
982        let prop = Property {
983            name: "bar".into(),
984            docs: super::DocBlock(vec![].into()),
985            ty: Option::Some(DataType::String),
986            vis: Visibility::Public,
987            static_: false,
988            nullable: true,
989            readonly: false,
990            default: Option::Some("null".into()),
991        };
992        let stub = prop.to_stub().unwrap();
993        assert!(
994            stub.contains("public ?string $bar = null;"),
995            "missing nullable default: {stub}"
996        );
997    }
998
999    #[test]
1000    #[allow(clippy::unwrap_used)]
1001    fn test_property_stub_static_with_default() {
1002        use crate::{Property, Visibility, abi::Option};
1003
1004        let prop = Property {
1005            name: "limit".into(),
1006            docs: super::DocBlock(vec![].into()),
1007            ty: Option::Some(DataType::Long),
1008            vis: Visibility::Public,
1009            static_: true,
1010            nullable: false,
1011            readonly: false,
1012            default: Option::Some("100".into()),
1013        };
1014        let stub = prop.to_stub().unwrap();
1015        assert!(
1016            stub.contains("public static int $limit = 100;"),
1017            "missing static default: {stub}"
1018        );
1019    }
1020
1021    #[test]
1022    #[allow(clippy::unwrap_used)]
1023    fn test_property_stub_static_string_default() {
1024        use crate::{Property, Visibility, abi::Option};
1025
1026        let prop = Property {
1027            name: "label".into(),
1028            docs: super::DocBlock(vec![].into()),
1029            ty: Option::Some(DataType::String),
1030            vis: Visibility::Public,
1031            static_: true,
1032            nullable: false,
1033            readonly: false,
1034            default: Option::Some("'hello'".into()),
1035        };
1036        let stub = prop.to_stub().unwrap();
1037        assert!(
1038            stub.contains("public static string $label = 'hello';"),
1039            "missing static string default: {stub}"
1040        );
1041    }
1042
1043    #[test]
1044    #[allow(clippy::unwrap_used)]
1045    fn test_property_stub_with_docs_includes_var() {
1046        use crate::{Property, Visibility, abi::Option};
1047
1048        let prop = Property {
1049            name: "bar".into(),
1050            docs: super::DocBlock(vec![" The user name.".into()].into()),
1051            ty: Option::Some(DataType::String),
1052            vis: Visibility::Public,
1053            static_: false,
1054            nullable: true,
1055            readonly: false,
1056            default: Option::None,
1057        };
1058        let stub = prop.to_stub().unwrap();
1059        assert!(stub.contains("The user name."), "missing doc: {stub}");
1060        assert!(
1061            stub.contains("@var string|null"),
1062            "missing @var with nullable in docblock: {stub}"
1063        );
1064        assert!(
1065            stub.contains("public ?string $bar;"),
1066            "missing decl: {stub}"
1067        );
1068    }
1069
1070    #[test]
1071    #[allow(clippy::unwrap_used)]
1072    fn test_property_stub_with_docs_no_type() {
1073        use crate::{Property, Visibility, abi::Option};
1074
1075        let prop = Property {
1076            name: "x".into(),
1077            docs: super::DocBlock(vec![" Some value.".into()].into()),
1078            ty: Option::None,
1079            vis: Visibility::Public,
1080            static_: false,
1081            nullable: false,
1082            readonly: false,
1083            default: Option::None,
1084        };
1085        let stub = prop.to_stub().unwrap();
1086        assert!(stub.contains("Some value."), "missing doc: {stub}");
1087        assert!(!stub.contains("@var"), "no @var without type: {stub}");
1088        assert!(stub.contains("public $x;"), "missing decl: {stub}");
1089    }
1090
1091    #[test]
1092    #[allow(clippy::unwrap_used)]
1093    fn test_property_stub_readonly() {
1094        use crate::{Property, Visibility, abi::Option};
1095
1096        let prop = Property {
1097            name: "baz".into(),
1098            docs: super::DocBlock(vec![].into()),
1099            ty: Option::Some(DataType::Array),
1100            vis: Visibility::Public,
1101            static_: false,
1102            nullable: false,
1103            readonly: true,
1104            default: Option::None,
1105        };
1106        let stub = prop.to_stub().unwrap();
1107        assert_eq!(stub, "public readonly array $baz;\n");
1108    }
1109
1110    #[test]
1111    #[allow(clippy::unwrap_used)]
1112    fn test_property_stub_untyped_no_docblock() {
1113        use crate::{Property, Visibility, abi::Option};
1114
1115        let prop = Property {
1116            name: "x".into(),
1117            docs: super::DocBlock(vec![].into()),
1118            ty: Option::None,
1119            vis: Visibility::Public,
1120            static_: false,
1121            nullable: false,
1122            readonly: false,
1123            default: Option::None,
1124        };
1125        let stub = prop.to_stub().unwrap();
1126        assert!(
1127            !stub.contains("/**"),
1128            "no docblock without docs or type: {stub}"
1129        );
1130        assert_eq!(stub, "public $x;\n");
1131    }
1132
1133    #[test]
1134    #[allow(clippy::unwrap_used)]
1135    fn class_constant_stub_carries_visibility() {
1136        use crate::{Constant, Visibility, abi::Option};
1137
1138        let constant = Constant {
1139            name: "SECRET".into(),
1140            docs: super::DocBlock(vec![].into()),
1141            value: Option::Some("1".into()),
1142            visibility: Visibility::Private,
1143        };
1144        assert_eq!(
1145            constant.class_stub().unwrap(),
1146            "private const SECRET = 1;\n"
1147        );
1148        assert_eq!(constant.to_stub().unwrap(), "const SECRET = 1;\n");
1149    }
1150
1151    #[test]
1152    #[allow(clippy::unwrap_used)]
1153    fn test_property_stub_static_typed() {
1154        use crate::{Property, Visibility, abi::Option};
1155
1156        let prop = Property {
1157            name: "count".into(),
1158            docs: super::DocBlock(vec![].into()),
1159            ty: Option::Some(DataType::Long),
1160            vis: Visibility::Protected,
1161            static_: true,
1162            nullable: false,
1163            readonly: false,
1164            default: Option::None,
1165        };
1166        let stub = prop.to_stub().unwrap();
1167        assert!(
1168            stub.contains("protected static int $count;"),
1169            "missing decl: {stub}"
1170        );
1171    }
1172
1173    #[test]
1174    #[allow(clippy::unwrap_used)]
1175    fn test_property_stub_nullable_mixed_stays_mixed() {
1176        use crate::{Property, Visibility, abi::Option};
1177
1178        let prop = Property {
1179            name: "val".into(),
1180            docs: super::DocBlock(vec![].into()),
1181            ty: Option::Some(DataType::Mixed),
1182            vis: Visibility::Public,
1183            static_: false,
1184            nullable: true,
1185            readonly: false,
1186            default: Option::None,
1187        };
1188        let stub = prop.to_stub().unwrap();
1189        // mixed already includes null, no ? prefix
1190        assert!(stub.contains("public mixed $val;"), "missing decl: {stub}");
1191    }
1192
1193    #[test]
1194    #[allow(clippy::unwrap_used)]
1195    fn test_property_stub_nullable_object_with_docs() {
1196        use crate::{Property, Visibility, abi::Option};
1197
1198        let prop = Property {
1199            name: "ref_".into(),
1200            docs: super::DocBlock(vec![" The related entity.".into()].into()),
1201            ty: Option::Some(DataType::object("App\\Entity")),
1202            vis: Visibility::Private,
1203            static_: false,
1204            nullable: true,
1205            readonly: false,
1206            default: Option::None,
1207        };
1208        let stub = prop.to_stub().unwrap();
1209        assert!(stub.contains("The related entity."), "missing doc: {stub}");
1210        assert!(
1211            stub.contains("@var \\App\\Entity|null"),
1212            "missing @var with FQCN: {stub}"
1213        );
1214        assert!(
1215            stub.contains("private ?\\App\\Entity $ref_;"),
1216            "missing decl: {stub}"
1217        );
1218    }
1219
1220    #[test]
1221    fn test_parse_rustdoc() {
1222        use super::{Str, parse_rustdoc};
1223
1224        // Test basic rustdoc parsing
1225        let docs: Vec<Str> = vec![
1226            " Gives you a nice greeting!".into(),
1227            "".into(),
1228            " # Arguments".into(),
1229            "".into(),
1230            " * `name` - Your name".into(),
1231            " * `age` - Your age".into(),
1232            "".into(),
1233            " # Returns".into(),
1234            "".into(),
1235            " Nice greeting!".into(),
1236        ];
1237
1238        let parsed = parse_rustdoc(&docs);
1239
1240        // Check summary
1241        assert_eq!(parsed.summary.len(), 2);
1242        assert!(parsed.summary[0].contains("Gives you a nice greeting"));
1243
1244        // Check params
1245        assert_eq!(parsed.params.len(), 2);
1246        assert_eq!(parsed.params.get("name"), Some(&"Your name".to_string()));
1247        assert_eq!(parsed.params.get("age"), Some(&"Your age".to_string()));
1248
1249        // Check returns
1250        assert!(parsed.returns.is_some());
1251        assert!(
1252            parsed
1253                .returns
1254                .as_ref()
1255                .is_some_and(|r| r.contains("Nice greeting"))
1256        );
1257    }
1258
1259    #[test]
1260    fn test_parse_param_line() {
1261        use super::parse_param_line;
1262
1263        // Test backtick format
1264        assert_eq!(
1265            parse_param_line("`name` - Your name"),
1266            Some(("name".to_string(), "Your name".to_string()))
1267        );
1268
1269        // Test with $ prefix
1270        assert_eq!(
1271            parse_param_line("`$name` - Your name"),
1272            Some(("name".to_string(), "Your name".to_string()))
1273        );
1274
1275        // Test simple format
1276        assert_eq!(
1277            parse_param_line("name - Your name"),
1278            Some(("name".to_string(), "Your name".to_string()))
1279        );
1280
1281        // Test invalid format
1282        assert_eq!(parse_param_line("no separator here"), None);
1283    }
1284
1285    #[test]
1286    fn test_format_phpdoc() {
1287        use super::{DocBlock, Parameter, Retval, Str, format_phpdoc};
1288        use crate::DataType;
1289        use crate::abi::Option;
1290
1291        // Create a DocBlock with rustdoc content
1292        let docs = DocBlock(
1293            vec![
1294                Str::from(" Greets the user."),
1295                Str::from(""),
1296                Str::from(" # Arguments"),
1297                Str::from(""),
1298                Str::from(" * `name` - The name to greet"),
1299                Str::from(""),
1300                Str::from(" # Returns"),
1301                Str::from(""),
1302                Str::from(" A greeting string."),
1303            ]
1304            .into(),
1305        );
1306
1307        let params = vec![Parameter {
1308            name: "name".into(),
1309            ty: Option::Some(DataType::String),
1310            nullable: false,
1311            variadic: false,
1312            default: Option::None,
1313        }];
1314
1315        let retval = Retval {
1316            ty: DataType::String,
1317            nullable: false,
1318        };
1319
1320        let mut buf = String::new();
1321        format_phpdoc(&docs, &params, Some(&retval), &mut buf).expect("format_phpdoc failed");
1322
1323        // Check that PHPDoc format is produced
1324        assert!(buf.contains("/**"));
1325        assert!(buf.contains("*/"));
1326        assert!(buf.contains("@param string $name The name to greet"));
1327        assert!(buf.contains("@return string A greeting string."));
1328        // Should NOT contain rustdoc section headers
1329        assert!(!buf.contains("# Arguments"));
1330        assert!(!buf.contains("# Returns"));
1331    }
1332
1333    fn class(name: &str, extends: StdOption<&str>, implements: &[&str]) -> Class {
1334        Class {
1335            name: name.into(),
1336            docs: DocBlock(vec![].into()),
1337            extends: extends.map(Into::into).into(),
1338            implements: implements
1339                .iter()
1340                .map(|&i| i.into())
1341                .collect::<StdVec<_>>()
1342                .into(),
1343            properties: vec![].into(),
1344            methods: vec![].into(),
1345            constants: vec![].into(),
1346            is_interface: false,
1347        }
1348    }
1349
1350    #[test]
1351    #[allow(clippy::unwrap_used)]
1352    fn namespaced_parent_resolves_from_global_namespace() {
1353        let module = Module {
1354            name: "ext".into(),
1355            functions: vec![].into(),
1356            classes: vec![
1357                class("Ns\\Error", Some("\\Exception"), &[]),
1358                class("Ns\\NotFoundError", Some("Ns\\Error"), &[]),
1359            ]
1360            .into(),
1361            enums: vec![].into(),
1362            constants: vec![].into(),
1363        };
1364
1365        let stub = module.to_stub().unwrap();
1366
1367        assert!(
1368            stub.contains("class NotFoundError extends \\Ns\\Error {"),
1369            "{stub}"
1370        );
1371    }
1372
1373    #[test]
1374    #[allow(clippy::unwrap_used)]
1375    fn qualified_parent_keeps_single_leading_backslash() {
1376        let stub = class("Ns\\Error", Some("\\Exception"), &[])
1377            .to_stub()
1378            .unwrap();
1379
1380        assert!(
1381            stub.starts_with("class Error extends \\Exception {"),
1382            "{stub}"
1383        );
1384    }
1385
1386    #[test]
1387    #[allow(clippy::unwrap_used)]
1388    fn unqualified_global_parent_is_qualified() {
1389        let stub = class("Ns\\Error", Some("Exception"), &[])
1390            .to_stub()
1391            .unwrap();
1392
1393        assert!(
1394            stub.starts_with("class Error extends \\Exception {"),
1395            "{stub}"
1396        );
1397    }
1398
1399    #[test]
1400    #[allow(clippy::unwrap_used)]
1401    fn class_interfaces_are_qualified() {
1402        let stub = class("Ns\\Item", None, &["Ns\\Countable", "\\Stringable"])
1403            .to_stub()
1404            .unwrap();
1405
1406        assert!(
1407            stub.starts_with("class Item implements \\Ns\\Countable, \\Stringable {"),
1408            "{stub}"
1409        );
1410    }
1411
1412    #[test]
1413    #[allow(clippy::unwrap_used)]
1414    fn interface_parents_are_qualified() {
1415        let stub = Class {
1416            is_interface: true,
1417            ..class("Ns\\Child", None, &["Ns\\Parent", "\\Countable"])
1418        }
1419        .to_stub()
1420        .unwrap();
1421
1422        assert!(
1423            stub.starts_with("interface Child extends \\Ns\\Parent, \\Countable {"),
1424            "{stub}"
1425        );
1426    }
1427
1428    #[test]
1429    #[allow(clippy::unwrap_used)]
1430    fn qualified_object_type_keeps_single_leading_backslash() {
1431        assert_eq!(
1432            DataType::object("\\Foo\\Bar").to_stub().unwrap(),
1433            "\\Foo\\Bar"
1434        );
1435    }
1436}