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, RString, 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 {
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
349pub trait ToStub {
351 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 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 let mut entries: HashMap<StdOption<&str>, StdVec<(String, String)>> = HashMap::new();
394
395 let mut insert = |ns, sort_key: String, entry| {
398 let bucket = entries.entry(ns).or_default();
399 bucket.push((sort_key, entry));
400 };
401
402 for c in &*self.constants {
403 let (ns, name) = split_namespace(c.name.as_ref());
404 insert(ns, name.to_string(), c.to_stub()?);
405 }
406
407 for func in &*self.functions {
408 let (ns, name) = split_namespace(func.name.as_ref());
409 insert(ns, name.to_string(), func.to_stub()?);
410 }
411
412 for class in &*self.classes {
413 let (ns, name) = split_namespace(class.name.as_ref());
414 insert(ns, name.to_string(), class.to_stub()?);
415 }
416
417 for r#enum in &*self.enums {
418 let (ns, name) = split_namespace(r#enum.name.as_ref());
419 insert(ns, name.to_string(), r#enum.to_stub()?);
420 }
421
422 for bucket in entries.values_mut() {
424 bucket.sort_by(|(a, _), (b, _)| a.cmp(b));
425 }
426
427 let mut entries: StdVec<_> = entries.iter().collect();
428 entries.sort_by(|(l, _), (r, _)| match (l, r) {
429 (None, _) => Ordering::Greater,
430 (_, None) => Ordering::Less,
431 (Some(l), Some(r)) => l.cmp(r),
432 });
433
434 buf.push_str(
435 &entries
436 .into_iter()
437 .map(|(ns, entries)| {
438 let mut buf = String::new();
439 if let Some(ns) = ns {
440 writeln!(buf, "namespace {ns} {{")?;
441 } else {
442 writeln!(buf, "namespace {{")?;
443 }
444
445 buf.push_str(
446 &entries
447 .iter()
448 .map(|(_, stub)| indent(stub, 4))
449 .collect::<StdVec<_>>()
450 .join(NEW_LINE_SEPARATOR),
451 );
452
453 writeln!(buf, "}}")?;
454 Ok(buf)
455 })
456 .collect::<Result<StdVec<_>, FmtError>>()?
457 .join(NEW_LINE_SEPARATOR),
458 );
459
460 Ok(())
461 }
462}
463
464impl ToStub for Function {
465 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
466 let ret_ref = match &self.ret {
468 Option::Some(r) => Some(r),
469 Option::None => None,
470 };
471 let type_overrides = format_phpdoc(&self.docs, &self.params, ret_ref, buf)?;
472
473 let (_, name) = split_namespace(self.name.as_ref());
474
475 let params_str = self
477 .params
478 .iter()
479 .map(|p| param_to_stub(p, &type_overrides))
480 .collect::<Result<StdVec<_>, FmtError>>()?
481 .join(", ");
482
483 write!(buf, "function {name}({params_str})")?;
484
485 if let Option::Some(retval) = &self.ret {
486 write!(buf, ": ")?;
487 if retval.nullable
489 && !matches!(retval.ty, DataType::Mixed | DataType::Null | DataType::Void)
490 {
491 write!(buf, "?")?;
492 }
493 retval.ty.fmt_stub(buf)?;
494 }
495
496 writeln!(buf, " {{}}")
497 }
498}
499
500fn param_to_stub(
505 param: &Parameter,
506 type_overrides: &HashMap<String, String>,
507) -> Result<String, FmtError> {
508 let mut buf = String::new();
509
510 let type_override = type_overrides
513 .get(param.name.as_ref())
514 .filter(|_| matches!(¶m.ty, Option::Some(DataType::Mixed) | Option::None));
515
516 if let Some(override_str) = type_override {
517 let type_str = extract_php_type(override_str);
519 write!(buf, "{type_str} ")?;
520 } else if let Option::Some(ty) = ¶m.ty {
521 if param.nullable && !matches!(ty, DataType::Mixed | DataType::Null | DataType::Void) {
523 write!(buf, "?")?;
524 }
525 ty.fmt_stub(&mut buf)?;
526 write!(buf, " ")?;
527 }
528
529 if param.variadic {
530 write!(buf, "...")?;
531 }
532
533 write!(buf, "${}", param.name)?;
534
535 if let Option::Some(default) = ¶m.default {
537 write!(buf, " = {default}")?;
538 } else if param.nullable {
539 write!(buf, " = null")?;
542 }
543
544 Ok(buf)
545}
546
547impl ToStub for Parameter {
548 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
549 let empty_overrides = HashMap::new();
550 let result = param_to_stub(self, &empty_overrides)?;
551 buf.push_str(&result);
552 Ok(())
553 }
554}
555
556impl ToStub for DataType {
557 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
558 let mut fqdn = "\\".to_owned();
559 write!(
560 buf,
561 "{}",
562 match self {
563 DataType::Bool | DataType::True | DataType::False => "bool",
564 DataType::Long => "int",
565 DataType::Double => "float",
566 DataType::String => "string",
567 DataType::Array => "array",
568 DataType::Object(_) => match self.class_name() {
569 Some(name) => {
570 fqdn.push_str(name);
571 fqdn.as_str()
572 }
573 None => "object",
574 },
575 DataType::Resource => "resource",
576 DataType::Reference => "reference",
577 DataType::Callable => "callable",
578 DataType::Iterable => "iterable",
579 DataType::Void => "void",
580 DataType::Null => "null",
581 DataType::Mixed
582 | DataType::Undef
583 | DataType::Ptr
584 | DataType::Indirect
585 | DataType::ConstantExpression => "mixed",
586 }
587 )
588 }
589}
590
591impl ToStub for DocBlock {
592 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
593 if !self.0.is_empty() {
594 writeln!(buf, "/**")?;
595 for comment in self.0.iter() {
596 writeln!(buf, " *{comment}")?;
597 }
598 writeln!(buf, " */")?;
599 }
600 Ok(())
601 }
602}
603
604impl ToStub for Class {
605 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
606 self.docs.fmt_stub(buf)?;
607
608 let (_, name) = split_namespace(self.name.as_ref());
609 if self.is_interface {
610 write!(buf, "interface {name} ")?;
611 } else {
612 write!(buf, "class {name} ")?;
613 }
614
615 if let Option::Some(extends) = &self.extends {
616 write!(buf, "extends {extends} ")?;
617 }
618
619 if !self.implements.is_empty() && !self.is_interface {
620 write!(
621 buf,
622 "implements {} ",
623 self.implements
624 .iter()
625 .map(RString::as_str)
626 .collect::<StdVec<_>>()
627 .join(", ")
628 )?;
629 }
630
631 if !self.implements.is_empty() && self.is_interface {
632 write!(
633 buf,
634 "extends {} ",
635 self.implements
636 .iter()
637 .map(RString::as_str)
638 .collect::<StdVec<_>>()
639 .join(", ")
640 )?;
641 }
642
643 writeln!(buf, "{{")?;
644
645 let mut constants: StdVec<_> = self
647 .constants
648 .iter()
649 .map(|c| {
650 c.class_stub()
651 .map(|s| (c.name.as_ref().to_string(), indent(&s, 4)))
652 })
653 .collect::<Result<_, FmtError>>()?;
654 let mut properties: StdVec<_> = self
655 .properties
656 .iter()
657 .map(|p| {
658 p.to_stub()
659 .map(|s| (p.name.as_ref().to_string(), indent(&s, 4)))
660 })
661 .collect::<Result<_, FmtError>>()?;
662 let mut methods: StdVec<_> = self
663 .methods
664 .iter()
665 .map(|m| {
666 m.to_stub()
667 .map(|s| (m.name.as_ref().to_string(), indent(&s, 4)))
668 })
669 .collect::<Result<_, FmtError>>()?;
670
671 constants.sort_by(|(a, _), (b, _)| a.cmp(b));
673 properties.sort_by(|(a, _), (b, _)| a.cmp(b));
674 methods.sort_by(|(a, _), (b, _)| a.cmp(b));
675
676 buf.push_str(
677 &constants
678 .into_iter()
679 .chain(properties)
680 .chain(methods)
681 .map(|(_, stub)| stub)
682 .collect::<StdVec<_>>()
683 .join(NEW_LINE_SEPARATOR),
684 );
685
686 writeln!(buf, "}}")
687 }
688}
689
690impl ToStub for Enum {
691 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
692 self.docs.fmt_stub(buf)?;
693
694 let (_, name) = split_namespace(self.name.as_ref());
695 write!(buf, "enum {name}")?;
696
697 if let Option::Some(backing_type) = &self.backing_type {
698 write!(buf, ": {backing_type}")?;
699 }
700
701 writeln!(buf, " {{")?;
702
703 for case in self.cases.iter() {
704 case.fmt_stub(buf)?;
705 }
706
707 writeln!(buf, "}}")
708 }
709}
710
711impl ToStub for EnumCase {
712 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
713 self.docs.fmt_stub(buf)?;
714
715 write!(buf, " case {}", self.name)?;
716 if let Option::Some(value) = &self.value {
717 write!(buf, " = {value}")?;
718 }
719 writeln!(buf, ";")
720 }
721}
722
723impl ToStub for Property {
724 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
725 if !self.docs.0.is_empty() {
726 writeln!(buf, "/**")?;
727 for comment in self.docs.0.iter() {
728 writeln!(buf, " *{comment}")?;
729 }
730 if let Option::Some(ty) = &self.ty {
731 writeln!(buf, " *")?;
732 writeln!(buf, " * @var {}", datatype_to_phpdoc(ty, self.nullable))?;
733 }
734 writeln!(buf, " */")?;
735 }
736
737 self.vis.fmt_stub(buf)?;
738 write!(buf, " ")?;
739 if self.static_ {
740 write!(buf, "static ")?;
741 }
742 if self.readonly {
743 write!(buf, "readonly ")?;
744 }
745 if let Option::Some(ty) = &self.ty {
746 let nullable = self.nullable && !matches!(ty, DataType::Mixed | DataType::Null);
747 if nullable {
748 write!(buf, "?")?;
749 }
750 ty.fmt_stub(buf)?;
751 write!(buf, " ")?;
752 }
753 write!(buf, "${}", self.name)?;
754 if let Option::Some(default) = &self.default {
755 write!(buf, " = {default}")?;
756 }
757 writeln!(buf, ";")
758 }
759}
760
761impl ToStub for Visibility {
762 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
763 write!(
764 buf,
765 "{}",
766 match self {
767 Visibility::Private => "private",
768 Visibility::Protected => "protected",
769 Visibility::Public => "public",
770 }
771 )
772 }
773}
774
775impl ToStub for Method {
776 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
777 let ret_ref = if matches!(self.ty, MethodType::Constructor) {
780 None
781 } else {
782 match &self.retval {
783 Option::Some(r) => Some(r),
784 Option::None => None,
785 }
786 };
787 let type_overrides = format_phpdoc(&self.docs, &self.params, ret_ref, buf)?;
788
789 self.visibility.fmt_stub(buf)?;
790
791 write!(buf, " ")?;
792
793 if matches!(self.ty, MethodType::Static) {
794 write!(buf, "static ")?;
795 }
796
797 let params_str = self
799 .params
800 .iter()
801 .map(|p| param_to_stub(p, &type_overrides))
802 .collect::<Result<StdVec<_>, FmtError>>()?
803 .join(", ");
804
805 write!(buf, "function {}({params_str})", self.name)?;
806
807 if !matches!(self.ty, MethodType::Constructor)
808 && let Option::Some(retval) = &self.retval
809 {
810 write!(buf, ": ")?;
811 if retval.nullable
813 && !matches!(retval.ty, DataType::Mixed | DataType::Null | DataType::Void)
814 {
815 write!(buf, "?")?;
816 }
817 retval.ty.fmt_stub(buf)?;
818 }
819
820 if self.r#abstract {
821 writeln!(buf, ";")
822 } else {
823 writeln!(buf, " {{}}")
824 }
825 }
826}
827
828impl Constant {
829 fn class_stub(&self) -> Result<String, FmtError> {
830 let mut buf = String::new();
831 self.docs.fmt_stub(&mut buf)?;
832 self.visibility.fmt_stub(&mut buf)?;
833 write!(buf, " ")?;
834 self.fmt_declaration(&mut buf)?;
835 Ok(buf)
836 }
837
838 fn fmt_declaration(&self, buf: &mut String) -> FmtResult {
839 write!(buf, "const {} = ", self.name)?;
840 if let Option::Some(value) = &self.value {
841 write!(buf, "{value}")?;
842 } else {
843 write!(buf, "null")?;
844 }
845 writeln!(buf, ";")
846 }
847}
848
849impl ToStub for Constant {
850 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
851 self.docs.fmt_stub(buf)?;
852 self.fmt_declaration(buf)
853 }
854}
855
856#[cfg(windows)]
857const NEW_LINE_SEPARATOR: &str = "\r\n";
858#[cfg(not(windows))]
859const NEW_LINE_SEPARATOR: &str = "\n";
860
861fn split_namespace(class: &str) -> (StdOption<&str>, &str) {
868 let idx = class.rfind('\\');
869
870 if let Some(idx) = idx {
871 (Some(&class[0..idx]), &class[idx + 1..])
872 } else {
873 (None, class)
874 }
875}
876
877fn indent(s: &str, depth: usize) -> String {
890 let indent = format!("{:depth$}", "", depth = depth);
891
892 s.split('\n')
893 .map(|line| {
894 let mut result = String::new();
895 if line.chars().any(|c| !c.is_whitespace()) {
896 result.push_str(&indent);
897 result.push_str(line);
898 }
899 result
900 })
901 .collect::<StdVec<_>>()
902 .join(NEW_LINE_SEPARATOR)
903}
904
905#[cfg(test)]
906mod test {
907 use super::{ToStub, split_namespace};
908 use crate::DataType;
909
910 #[test]
911 pub fn test_split_ns() {
912 assert_eq!(split_namespace("ext\\php\\rs"), (Some("ext\\php"), "rs"));
913 assert_eq!(split_namespace("test_solo_ns"), (None, "test_solo_ns"));
914 assert_eq!(split_namespace("simple\\ns"), (Some("simple"), "ns"));
915 }
916
917 #[test]
918 #[cfg(not(windows))]
919 #[allow(clippy::uninlined_format_args)]
920 pub fn test_indent() {
921 use super::NEW_LINE_SEPARATOR;
922 use super::indent;
923
924 assert_eq!(indent("hello", 4), " hello");
925 assert_eq!(
926 indent(&format!("hello{nl}world{nl}", nl = NEW_LINE_SEPARATOR), 4),
927 format!(" hello{nl} world{nl}", nl = NEW_LINE_SEPARATOR)
928 );
929 }
930
931 #[test]
932 #[allow(clippy::unwrap_used)]
933 pub fn test_datatype_to_stub() {
934 assert_eq!(DataType::Void.to_stub().unwrap(), "void");
936 assert_eq!(DataType::Null.to_stub().unwrap(), "null");
937 assert_eq!(DataType::Bool.to_stub().unwrap(), "bool");
938 assert_eq!(DataType::True.to_stub().unwrap(), "bool");
939 assert_eq!(DataType::False.to_stub().unwrap(), "bool");
940 assert_eq!(DataType::Long.to_stub().unwrap(), "int");
941 assert_eq!(DataType::Double.to_stub().unwrap(), "float");
942 assert_eq!(DataType::String.to_stub().unwrap(), "string");
943 assert_eq!(DataType::Array.to_stub().unwrap(), "array");
944 assert_eq!(DataType::ANY_OBJECT.to_stub().unwrap(), "object");
945 assert_eq!(
946 DataType::object("Foo\\Bar").to_stub().unwrap(),
947 "\\Foo\\Bar"
948 );
949 assert_eq!(DataType::Resource.to_stub().unwrap(), "resource");
950 assert_eq!(DataType::Callable.to_stub().unwrap(), "callable");
951 assert_eq!(DataType::Iterable.to_stub().unwrap(), "iterable");
952 assert_eq!(DataType::Mixed.to_stub().unwrap(), "mixed");
953 assert_eq!(DataType::Undef.to_stub().unwrap(), "mixed");
954 assert_eq!(DataType::Ptr.to_stub().unwrap(), "mixed");
955 assert_eq!(DataType::Indirect.to_stub().unwrap(), "mixed");
956 assert_eq!(DataType::ConstantExpression.to_stub().unwrap(), "mixed");
957 assert_eq!(DataType::Reference.to_stub().unwrap(), "reference");
958 }
959
960 #[test]
961 #[allow(clippy::unwrap_used)]
962 fn test_property_stub_typed_no_docs() {
963 use crate::{Property, Visibility, abi::Option};
964
965 let prop = Property {
966 name: "foo".into(),
967 docs: super::DocBlock(vec![].into()),
968 ty: Option::Some(DataType::String),
969 vis: Visibility::Public,
970 static_: false,
971 nullable: false,
972 readonly: false,
973 default: Option::None,
974 };
975 let stub = prop.to_stub().unwrap();
976 assert!(!stub.contains("@var"), "no @var without docs: {stub}");
978 assert_eq!(stub, "public string $foo;\n");
979 }
980
981 #[test]
982 #[allow(clippy::unwrap_used)]
983 fn test_property_stub_nullable_with_default() {
984 use crate::{Property, Visibility, abi::Option};
985
986 let prop = Property {
987 name: "bar".into(),
988 docs: super::DocBlock(vec![].into()),
989 ty: Option::Some(DataType::String),
990 vis: Visibility::Public,
991 static_: false,
992 nullable: true,
993 readonly: false,
994 default: Option::Some("null".into()),
995 };
996 let stub = prop.to_stub().unwrap();
997 assert!(
998 stub.contains("public ?string $bar = null;"),
999 "missing nullable default: {stub}"
1000 );
1001 }
1002
1003 #[test]
1004 #[allow(clippy::unwrap_used)]
1005 fn test_property_stub_static_with_default() {
1006 use crate::{Property, Visibility, abi::Option};
1007
1008 let prop = Property {
1009 name: "limit".into(),
1010 docs: super::DocBlock(vec![].into()),
1011 ty: Option::Some(DataType::Long),
1012 vis: Visibility::Public,
1013 static_: true,
1014 nullable: false,
1015 readonly: false,
1016 default: Option::Some("100".into()),
1017 };
1018 let stub = prop.to_stub().unwrap();
1019 assert!(
1020 stub.contains("public static int $limit = 100;"),
1021 "missing static default: {stub}"
1022 );
1023 }
1024
1025 #[test]
1026 #[allow(clippy::unwrap_used)]
1027 fn test_property_stub_static_string_default() {
1028 use crate::{Property, Visibility, abi::Option};
1029
1030 let prop = Property {
1031 name: "label".into(),
1032 docs: super::DocBlock(vec![].into()),
1033 ty: Option::Some(DataType::String),
1034 vis: Visibility::Public,
1035 static_: true,
1036 nullable: false,
1037 readonly: false,
1038 default: Option::Some("'hello'".into()),
1039 };
1040 let stub = prop.to_stub().unwrap();
1041 assert!(
1042 stub.contains("public static string $label = 'hello';"),
1043 "missing static string default: {stub}"
1044 );
1045 }
1046
1047 #[test]
1048 #[allow(clippy::unwrap_used)]
1049 fn test_property_stub_with_docs_includes_var() {
1050 use crate::{Property, Visibility, abi::Option};
1051
1052 let prop = Property {
1053 name: "bar".into(),
1054 docs: super::DocBlock(vec![" The user name.".into()].into()),
1055 ty: Option::Some(DataType::String),
1056 vis: Visibility::Public,
1057 static_: false,
1058 nullable: true,
1059 readonly: false,
1060 default: Option::None,
1061 };
1062 let stub = prop.to_stub().unwrap();
1063 assert!(stub.contains("The user name."), "missing doc: {stub}");
1064 assert!(
1065 stub.contains("@var string|null"),
1066 "missing @var with nullable in docblock: {stub}"
1067 );
1068 assert!(
1069 stub.contains("public ?string $bar;"),
1070 "missing decl: {stub}"
1071 );
1072 }
1073
1074 #[test]
1075 #[allow(clippy::unwrap_used)]
1076 fn test_property_stub_with_docs_no_type() {
1077 use crate::{Property, Visibility, abi::Option};
1078
1079 let prop = Property {
1080 name: "x".into(),
1081 docs: super::DocBlock(vec![" Some value.".into()].into()),
1082 ty: Option::None,
1083 vis: Visibility::Public,
1084 static_: false,
1085 nullable: false,
1086 readonly: false,
1087 default: Option::None,
1088 };
1089 let stub = prop.to_stub().unwrap();
1090 assert!(stub.contains("Some value."), "missing doc: {stub}");
1091 assert!(!stub.contains("@var"), "no @var without type: {stub}");
1092 assert!(stub.contains("public $x;"), "missing decl: {stub}");
1093 }
1094
1095 #[test]
1096 #[allow(clippy::unwrap_used)]
1097 fn test_property_stub_readonly() {
1098 use crate::{Property, Visibility, abi::Option};
1099
1100 let prop = Property {
1101 name: "baz".into(),
1102 docs: super::DocBlock(vec![].into()),
1103 ty: Option::Some(DataType::Array),
1104 vis: Visibility::Public,
1105 static_: false,
1106 nullable: false,
1107 readonly: true,
1108 default: Option::None,
1109 };
1110 let stub = prop.to_stub().unwrap();
1111 assert_eq!(stub, "public readonly array $baz;\n");
1112 }
1113
1114 #[test]
1115 #[allow(clippy::unwrap_used)]
1116 fn test_property_stub_untyped_no_docblock() {
1117 use crate::{Property, Visibility, abi::Option};
1118
1119 let prop = Property {
1120 name: "x".into(),
1121 docs: super::DocBlock(vec![].into()),
1122 ty: Option::None,
1123 vis: Visibility::Public,
1124 static_: false,
1125 nullable: false,
1126 readonly: false,
1127 default: Option::None,
1128 };
1129 let stub = prop.to_stub().unwrap();
1130 assert!(
1131 !stub.contains("/**"),
1132 "no docblock without docs or type: {stub}"
1133 );
1134 assert_eq!(stub, "public $x;\n");
1135 }
1136
1137 #[test]
1138 #[allow(clippy::unwrap_used)]
1139 fn class_constant_stub_carries_visibility() {
1140 use crate::{Constant, Visibility, abi::Option};
1141
1142 let constant = Constant {
1143 name: "SECRET".into(),
1144 docs: super::DocBlock(vec![].into()),
1145 value: Option::Some("1".into()),
1146 visibility: Visibility::Private,
1147 };
1148 assert_eq!(
1149 constant.class_stub().unwrap(),
1150 "private const SECRET = 1;\n"
1151 );
1152 assert_eq!(constant.to_stub().unwrap(), "const SECRET = 1;\n");
1153 }
1154
1155 #[test]
1156 #[allow(clippy::unwrap_used)]
1157 fn test_property_stub_static_typed() {
1158 use crate::{Property, Visibility, abi::Option};
1159
1160 let prop = Property {
1161 name: "count".into(),
1162 docs: super::DocBlock(vec![].into()),
1163 ty: Option::Some(DataType::Long),
1164 vis: Visibility::Protected,
1165 static_: true,
1166 nullable: false,
1167 readonly: false,
1168 default: Option::None,
1169 };
1170 let stub = prop.to_stub().unwrap();
1171 assert!(
1172 stub.contains("protected static int $count;"),
1173 "missing decl: {stub}"
1174 );
1175 }
1176
1177 #[test]
1178 #[allow(clippy::unwrap_used)]
1179 fn test_property_stub_nullable_mixed_stays_mixed() {
1180 use crate::{Property, Visibility, abi::Option};
1181
1182 let prop = Property {
1183 name: "val".into(),
1184 docs: super::DocBlock(vec![].into()),
1185 ty: Option::Some(DataType::Mixed),
1186 vis: Visibility::Public,
1187 static_: false,
1188 nullable: true,
1189 readonly: false,
1190 default: Option::None,
1191 };
1192 let stub = prop.to_stub().unwrap();
1193 assert!(stub.contains("public mixed $val;"), "missing decl: {stub}");
1195 }
1196
1197 #[test]
1198 #[allow(clippy::unwrap_used)]
1199 fn test_property_stub_nullable_object_with_docs() {
1200 use crate::{Property, Visibility, abi::Option};
1201
1202 let prop = Property {
1203 name: "ref_".into(),
1204 docs: super::DocBlock(vec![" The related entity.".into()].into()),
1205 ty: Option::Some(DataType::object("App\\Entity")),
1206 vis: Visibility::Private,
1207 static_: false,
1208 nullable: true,
1209 readonly: false,
1210 default: Option::None,
1211 };
1212 let stub = prop.to_stub().unwrap();
1213 assert!(stub.contains("The related entity."), "missing doc: {stub}");
1214 assert!(
1215 stub.contains("@var \\App\\Entity|null"),
1216 "missing @var with FQCN: {stub}"
1217 );
1218 assert!(
1219 stub.contains("private ?\\App\\Entity $ref_;"),
1220 "missing decl: {stub}"
1221 );
1222 }
1223
1224 #[test]
1225 fn test_parse_rustdoc() {
1226 use super::{Str, parse_rustdoc};
1227
1228 let docs: Vec<Str> = vec![
1230 " Gives you a nice greeting!".into(),
1231 "".into(),
1232 " # Arguments".into(),
1233 "".into(),
1234 " * `name` - Your name".into(),
1235 " * `age` - Your age".into(),
1236 "".into(),
1237 " # Returns".into(),
1238 "".into(),
1239 " Nice greeting!".into(),
1240 ];
1241
1242 let parsed = parse_rustdoc(&docs);
1243
1244 assert_eq!(parsed.summary.len(), 2);
1246 assert!(parsed.summary[0].contains("Gives you a nice greeting"));
1247
1248 assert_eq!(parsed.params.len(), 2);
1250 assert_eq!(parsed.params.get("name"), Some(&"Your name".to_string()));
1251 assert_eq!(parsed.params.get("age"), Some(&"Your age".to_string()));
1252
1253 assert!(parsed.returns.is_some());
1255 assert!(
1256 parsed
1257 .returns
1258 .as_ref()
1259 .is_some_and(|r| r.contains("Nice greeting"))
1260 );
1261 }
1262
1263 #[test]
1264 fn test_parse_param_line() {
1265 use super::parse_param_line;
1266
1267 assert_eq!(
1269 parse_param_line("`name` - Your name"),
1270 Some(("name".to_string(), "Your name".to_string()))
1271 );
1272
1273 assert_eq!(
1275 parse_param_line("`$name` - Your name"),
1276 Some(("name".to_string(), "Your name".to_string()))
1277 );
1278
1279 assert_eq!(
1281 parse_param_line("name - Your name"),
1282 Some(("name".to_string(), "Your name".to_string()))
1283 );
1284
1285 assert_eq!(parse_param_line("no separator here"), None);
1287 }
1288
1289 #[test]
1290 fn test_format_phpdoc() {
1291 use super::{DocBlock, Parameter, Retval, Str, format_phpdoc};
1292 use crate::DataType;
1293 use crate::abi::Option;
1294
1295 let docs = DocBlock(
1297 vec![
1298 Str::from(" Greets the user."),
1299 Str::from(""),
1300 Str::from(" # Arguments"),
1301 Str::from(""),
1302 Str::from(" * `name` - The name to greet"),
1303 Str::from(""),
1304 Str::from(" # Returns"),
1305 Str::from(""),
1306 Str::from(" A greeting string."),
1307 ]
1308 .into(),
1309 );
1310
1311 let params = vec![Parameter {
1312 name: "name".into(),
1313 ty: Option::Some(DataType::String),
1314 nullable: false,
1315 variadic: false,
1316 default: Option::None,
1317 }];
1318
1319 let retval = Retval {
1320 ty: DataType::String,
1321 nullable: false,
1322 };
1323
1324 let mut buf = String::new();
1325 format_phpdoc(&docs, ¶ms, Some(&retval), &mut buf).expect("format_phpdoc failed");
1326
1327 assert!(buf.contains("/**"));
1329 assert!(buf.contains("*/"));
1330 assert!(buf.contains("@param string $name The name to greet"));
1331 assert!(buf.contains("@return string A greeting string."));
1332 assert!(!buf.contains("# Arguments"));
1334 assert!(!buf.contains("# Returns"));
1335 }
1336}