1use 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#[derive(Default)]
21struct ParsedRustDoc {
22 summary: StdVec<String>,
24 params: HashMap<String, String>,
27 param_types: HashMap<String, String>,
31 returns: StdOption<String>,
33 errors: StdVec<String>,
35}
36
37fn 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 if trimmed.starts_with("# ") {
49 finalize_section(&mut result, current_section, §ion_content);
51 section_content.clear();
52
53 let section_name = trimmed.strip_prefix("# ").unwrap_or(trimmed);
55 current_section = Some(section_name);
56 } else if current_section.is_some() {
57 section_content.push(line.to_string());
59 } else {
60 result.summary.push(line.to_string());
62 }
63 }
64
65 finalize_section(&mut result, current_section, §ion_content);
67
68 result
69}
70
71fn 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 for line in content {
81 let trimmed = line.trim();
82 let item = trimmed
83 .strip_prefix("* ")
84 .or_else(|| trimmed.strip_prefix("- "));
85 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 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 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 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 _ => {}
135 }
136}
137
138fn parse_param_line(line: &str) -> StdOption<(String, String)> {
144 if let Some(rest) = line.strip_prefix('`')
146 && let Some(end_tick) = rest.find('`')
147 {
148 let name = &rest[..end_tick];
149 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 let name = name.strip_prefix('$').unwrap_or(name);
158 return Some((name.to_string(), desc.to_string()));
159 }
160
161 if let Some(sep_pos) = line.find(" - ") {
163 let name = line[..sep_pos].trim();
164 let desc = line[sep_pos + 3..].trim();
165 let name = name.strip_prefix('$').unwrap_or(name);
167 return Some((name.to_string(), desc.to_string()));
168 }
169
170 None
171}
172
173fn parse_typed_param_line(line: &str) -> StdOption<(String, String, String)> {
177 let rest = line.strip_prefix('`')?;
180 let end_tick = rest.find('`')?;
181 let name = rest[..end_tick].to_string();
182
183 let after_name = rest[end_tick + 1..].trim();
185 let after_colon = after_name.strip_prefix(':')?.trim();
186
187 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 let desc = type_rest[type_end_tick + 1..].trim().to_string();
194
195 let name = name.strip_prefix('$').unwrap_or(&name).to_string();
197
198 Some((name, ty, desc))
199}
200
201fn 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 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 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 if !summary_lines.is_empty() && (has_params || has_return || has_errors) {
251 writeln!(buf, " *")?;
252 }
253
254 for param in params {
256 let type_str = if let Some(type_override) = parsed.param_types.get(param.name.as_ref()) {
258 extract_php_type(type_override)
260 } else {
261 match ¶m.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 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 for error in &parsed.errors {
287 writeln!(buf, " * @throws \\Exception {error}")?;
288 }
289
290 writeln!(buf, " */")?;
291 Ok(parsed.param_types)
292}
293
294fn extract_php_type(type_str: &str) -> String {
297 type_str
300 .split_whitespace()
301 .next()
302 .unwrap_or("mixed")
303 .to_string()
304}
305
306fn 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",
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
334fn 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
350pub trait ToStub {
352 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 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 let mut entries: HashMap<StdOption<&str>, StdVec<(String, String)>> = HashMap::new();
395
396 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 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 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 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 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
501fn param_to_stub(
506 param: &Parameter,
507 type_overrides: &HashMap<String, String>,
508) -> Result<String, FmtError> {
509 let mut buf = String::new();
510
511 let type_override = type_overrides
514 .get(param.name.as_ref())
515 .filter(|_| matches!(¶m.ty, Option::Some(DataType::Mixed) | Option::None));
516
517 if let Some(override_str) = type_override {
518 let type_str = extract_php_type(override_str);
520 write!(buf, "{type_str} ")?;
521 } else if let Option::Some(ty) = ¶m.ty {
522 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 if let Option::Some(default) = ¶m.default {
538 write!(buf, " = {default}")?;
539 } else if param.nullable {
540 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 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 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 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 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 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
857fn 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
873fn 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 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 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 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 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 assert_eq!(parsed.summary.len(), 2);
1242 assert!(parsed.summary[0].contains("Gives you a nice greeting"));
1243
1244 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 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 assert_eq!(
1265 parse_param_line("`name` - Your name"),
1266 Some(("name".to_string(), "Your name".to_string()))
1267 );
1268
1269 assert_eq!(
1271 parse_param_line("`$name` - Your name"),
1272 Some(("name".to_string(), "Your name".to_string()))
1273 );
1274
1275 assert_eq!(
1277 parse_param_line("name - Your name"),
1278 Some(("name".to_string(), "Your name".to_string()))
1279 );
1280
1281 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 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, ¶ms, Some(&retval), &mut buf).expect("format_phpdoc failed");
1322
1323 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 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}