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
17#[cfg(feature = "enum")]
18use crate::describe::{Enum, EnumCase};
19use crate::flags::{ClassFlags, DataType};
20
21#[derive(Default)]
23struct ParsedRustDoc {
24 summary: StdVec<String>,
26 params: HashMap<String, String>,
29 param_types: HashMap<String, String>,
33 returns: StdOption<String>,
35 errors: StdVec<String>,
37}
38
39fn parse_rustdoc(docs: &[Str]) -> ParsedRustDoc {
41 let mut result = ParsedRustDoc::default();
42 let mut current_section: StdOption<&str> = None;
43 let mut section_content: StdVec<String> = StdVec::new();
44
45 for line in docs {
46 let line = line.as_ref();
47 let trimmed = line.trim();
48
49 if trimmed.starts_with("# ") {
51 finalize_section(&mut result, current_section, §ion_content);
53 section_content.clear();
54
55 let section_name = trimmed.strip_prefix("# ").unwrap_or(trimmed);
57 current_section = Some(section_name);
58 } else if current_section.is_some() {
59 section_content.push(line.to_string());
61 } else {
62 result.summary.push(line.to_string());
64 }
65 }
66
67 finalize_section(&mut result, current_section, §ion_content);
69
70 result
71}
72
73fn finalize_section(result: &mut ParsedRustDoc, section: StdOption<&str>, content: &[String]) {
75 let Some(section_name) = section else {
76 return;
77 };
78
79 match section_name {
80 "Arguments" => {
81 for line in content {
83 let trimmed = line.trim();
84 let item = trimmed
85 .strip_prefix("* ")
86 .or_else(|| trimmed.strip_prefix("- "));
87 if let Some(item) = item
90 && let Some((name, desc)) = parse_param_line(item.trim())
91 {
92 result.params.insert(name, desc);
93 }
94 }
95 }
96 "Returns" => {
97 let desc: String = content
99 .iter()
100 .map(|s| s.trim())
101 .filter(|s| !s.is_empty())
102 .collect::<StdVec<_>>()
103 .join(" ");
104 if !desc.is_empty() {
105 result.returns = Some(desc);
106 }
107 }
108 "Errors" => {
109 for line in content {
111 let trimmed = line.trim();
112 if !trimmed.is_empty() {
113 result.errors.push(trimmed.to_string());
114 }
115 }
116 }
117 "Parameters" => {
118 for line in content {
121 let trimmed = line.trim();
122 let item = trimmed
123 .strip_prefix("* ")
124 .or_else(|| trimmed.strip_prefix("- "));
125 if let Some(item) = item
126 && let Some((name, ty, desc)) = parse_typed_param_line(item.trim())
127 {
128 result.param_types.insert(name.clone(), ty);
129 if !desc.is_empty() {
130 result.params.insert(name, desc);
131 }
132 }
133 }
134 }
135 _ => {}
137 }
138}
139
140fn parse_param_line(line: &str) -> StdOption<(String, String)> {
146 if let Some(rest) = line.strip_prefix('`')
148 && let Some(end_tick) = rest.find('`')
149 {
150 let name = &rest[..end_tick];
151 let after_tick = &rest[end_tick + 1..];
153 let desc = after_tick
154 .trim()
155 .strip_prefix('-')
156 .or_else(|| after_tick.trim().strip_prefix(':'))
157 .map_or_else(|| after_tick.trim(), str::trim);
158 let name = name.strip_prefix('$').unwrap_or(name);
160 return Some((name.to_string(), desc.to_string()));
161 }
162
163 if let Some(sep_pos) = line.find(" - ") {
165 let name = line[..sep_pos].trim();
166 let desc = line[sep_pos + 3..].trim();
167 let name = name.strip_prefix('$').unwrap_or(name);
169 return Some((name.to_string(), desc.to_string()));
170 }
171
172 None
173}
174
175fn parse_typed_param_line(line: &str) -> StdOption<(String, String, String)> {
179 let rest = line.strip_prefix('`')?;
182 let end_tick = rest.find('`')?;
183 let name = rest[..end_tick].to_string();
184
185 let after_name = rest[end_tick + 1..].trim();
187 let after_colon = after_name.strip_prefix(':')?.trim();
188
189 let type_rest = after_colon.strip_prefix('`')?;
191 let type_end_tick = type_rest.find('`')?;
192 let ty = type_rest[..type_end_tick].to_string();
193
194 let desc = type_rest[type_end_tick + 1..].trim().to_string();
196
197 let name = name.strip_prefix('$').unwrap_or(&name).to_string();
199
200 Some((name, ty, desc))
201}
202
203fn format_phpdoc(
213 docs: &DocBlock,
214 params: &[Parameter],
215 ret: StdOption<&Retval>,
216 buf: &mut String,
217) -> Result<HashMap<String, String>, FmtError> {
218 if docs.0.is_empty() && params.is_empty() && ret.is_none() {
219 return Ok(HashMap::new());
220 }
221
222 let parsed = parse_rustdoc(&docs.0);
223
224 let has_summary = parsed.summary.iter().any(|s| !s.trim().is_empty());
226 let has_params = !params.is_empty();
227 let has_return = ret.is_some();
228 let has_errors = !parsed.errors.is_empty();
229
230 if !has_summary && !has_params && !has_return && !has_errors {
231 return Ok(parsed.param_types);
232 }
233
234 writeln!(buf, "/**")?;
235
236 let summary_lines: StdVec<_> = parsed
238 .summary
239 .iter()
240 .rev()
241 .skip_while(|s| s.trim().is_empty())
242 .collect::<StdVec<_>>()
243 .into_iter()
244 .rev()
245 .collect();
246
247 for line in &summary_lines {
248 writeln!(buf, " *{line}")?;
249 }
250
251 if !summary_lines.is_empty() && (has_params || has_return || has_errors) {
253 writeln!(buf, " *")?;
254 }
255
256 for param in params {
258 let type_str = if let Some(type_override) = parsed.param_types.get(param.name.as_ref()) {
260 extract_php_type(type_override)
262 } else {
263 match ¶m.ty {
264 Option::Some(ty) => datatype_to_phpdoc(ty, param.nullable),
265 Option::None => "mixed".to_string(),
266 }
267 };
268
269 let desc = parsed.params.get(param.name.as_ref()).cloned();
270 if let Some(desc) = desc {
271 writeln!(buf, " * @param {type_str} ${} {desc}", param.name)?;
272 } else {
273 writeln!(buf, " * @param {type_str} ${}", param.name)?;
274 }
275 }
276
277 if let Some(retval) = ret {
279 let type_str = datatype_to_phpdoc(&retval.ty, retval.nullable);
280 if let Some(desc) = &parsed.returns {
281 writeln!(buf, " * @return {type_str} {desc}")?;
282 } else {
283 writeln!(buf, " * @return {type_str}")?;
284 }
285 }
286
287 for error in &parsed.errors {
289 writeln!(buf, " * @throws \\Exception {error}")?;
290 }
291
292 writeln!(buf, " */")?;
293 Ok(parsed.param_types)
294}
295
296fn extract_php_type(type_str: &str) -> String {
299 type_str
302 .split_whitespace()
303 .next()
304 .unwrap_or("mixed")
305 .to_string()
306}
307
308fn datatype_to_phpdoc(ty: &DataType, nullable: bool) -> String {
310 let base = match ty {
311 DataType::Bool | DataType::True | DataType::False => "bool",
312 DataType::Long => "int",
313 DataType::Double => "float",
314 DataType::String => "string",
315 DataType::Array => "array",
316 DataType::Object(Some(name)) => return format_class_type(name, nullable),
317 DataType::Object(None) => "object",
318 DataType::Resource => "resource",
319 DataType::Callable => "callable",
320 DataType::Void => "void",
321 DataType::Null => "null",
322 DataType::Iterable => "iterable",
323 _ => "mixed",
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 #[cfg(feature = "enum")]
418 for r#enum in &*self.enums {
419 let (ns, name) = split_namespace(r#enum.name.as_ref());
420 insert(ns, name.to_string(), r#enum.to_stub()?);
421 }
422
423 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 mut fqdn = "\\".to_owned();
560 write!(
561 buf,
562 "{}",
563 match self {
564 DataType::Bool | DataType::True | DataType::False => "bool",
565 DataType::Long => "int",
566 DataType::Double => "float",
567 DataType::String => "string",
568 DataType::Array => "array",
569 DataType::Object(Some(ty)) => {
570 fqdn.push_str(ty);
571 fqdn.as_str()
572 }
573 DataType::Object(None) => "object",
574 DataType::Resource => "resource",
575 DataType::Reference => "reference",
576 DataType::Callable => "callable",
577 DataType::Iterable => "iterable",
578 DataType::Void => "void",
579 DataType::Null => "null",
580 DataType::Mixed
581 | DataType::Undef
582 | DataType::Ptr
583 | DataType::Indirect
584 | DataType::ConstantExpression => "mixed",
585 }
586 )
587 }
588}
589
590impl ToStub for DocBlock {
591 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
592 if !self.0.is_empty() {
593 writeln!(buf, "/**")?;
594 for comment in self.0.iter() {
595 writeln!(buf, " *{comment}")?;
596 }
597 writeln!(buf, " */")?;
598 }
599 Ok(())
600 }
601}
602
603impl ToStub for Class {
604 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
605 self.docs.fmt_stub(buf)?;
606
607 let (_, name) = split_namespace(self.name.as_ref());
608 let flags = ClassFlags::from_bits(self.flags).unwrap_or(ClassFlags::empty());
609 let is_interface = flags.contains(ClassFlags::Interface);
610
611 if is_interface {
612 write!(buf, "interface {name} ")?;
613 } else {
614 write!(buf, "class {name} ")?;
615 }
616
617 if let Option::Some(extends) = &self.extends {
618 write!(buf, "extends {extends} ")?;
619 }
620
621 if !self.implements.is_empty() && !is_interface {
622 write!(
623 buf,
624 "implements {} ",
625 self.implements
626 .iter()
627 .map(RString::as_str)
628 .collect::<StdVec<_>>()
629 .join(", ")
630 )?;
631 }
632
633 if !self.implements.is_empty() && is_interface {
634 write!(
635 buf,
636 "extends {} ",
637 self.implements
638 .iter()
639 .map(RString::as_str)
640 .collect::<StdVec<_>>()
641 .join(", ")
642 )?;
643 }
644
645 writeln!(buf, "{{")?;
646
647 let mut constants: StdVec<_> = self
649 .constants
650 .iter()
651 .map(|c| {
652 c.to_stub()
653 .map(|s| (c.name.as_ref().to_string(), indent(&s, 4)))
654 })
655 .collect::<Result<_, FmtError>>()?;
656 let mut properties: StdVec<_> = self
657 .properties
658 .iter()
659 .map(|p| {
660 p.to_stub()
661 .map(|s| (p.name.as_ref().to_string(), indent(&s, 4)))
662 })
663 .collect::<Result<_, FmtError>>()?;
664 let mut methods: StdVec<_> = self
665 .methods
666 .iter()
667 .map(|m| {
668 m.to_stub()
669 .map(|s| (m.name.as_ref().to_string(), indent(&s, 4)))
670 })
671 .collect::<Result<_, FmtError>>()?;
672
673 constants.sort_by(|(a, _), (b, _)| a.cmp(b));
675 properties.sort_by(|(a, _), (b, _)| a.cmp(b));
676 methods.sort_by(|(a, _), (b, _)| a.cmp(b));
677
678 buf.push_str(
679 &constants
680 .into_iter()
681 .chain(properties)
682 .chain(methods)
683 .map(|(_, stub)| stub)
684 .collect::<StdVec<_>>()
685 .join(NEW_LINE_SEPARATOR),
686 );
687
688 writeln!(buf, "}}")
689 }
690}
691
692#[cfg(feature = "enum")]
693impl ToStub for Enum {
694 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
695 self.docs.fmt_stub(buf)?;
696
697 let (_, name) = split_namespace(self.name.as_ref());
698 write!(buf, "enum {name}")?;
699
700 if let Option::Some(backing_type) = &self.backing_type {
701 write!(buf, ": {backing_type}")?;
702 }
703
704 writeln!(buf, " {{")?;
705
706 for case in self.cases.iter() {
707 case.fmt_stub(buf)?;
708 }
709
710 writeln!(buf, "}}")
711 }
712}
713
714#[cfg(feature = "enum")]
715impl ToStub for EnumCase {
716 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
717 self.docs.fmt_stub(buf)?;
718
719 write!(buf, " case {}", self.name)?;
720 if let Option::Some(value) = &self.value {
721 write!(buf, " = {value}")?;
722 }
723 writeln!(buf, ";")
724 }
725}
726
727impl ToStub for Property {
728 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
729 if !self.docs.0.is_empty() {
730 writeln!(buf, "/**")?;
731 for comment in self.docs.0.iter() {
732 writeln!(buf, " *{comment}")?;
733 }
734 if let Option::Some(ty) = &self.ty {
735 writeln!(buf, " *")?;
736 writeln!(buf, " * @var {}", datatype_to_phpdoc(ty, self.nullable))?;
737 }
738 writeln!(buf, " */")?;
739 }
740
741 self.vis.fmt_stub(buf)?;
742 write!(buf, " ")?;
743 if self.static_ {
744 write!(buf, "static ")?;
745 }
746 if self.readonly {
747 write!(buf, "readonly ")?;
748 }
749 if let Option::Some(ty) = &self.ty {
750 let nullable = self.nullable && !matches!(ty, DataType::Mixed | DataType::Null);
751 if nullable {
752 write!(buf, "?")?;
753 }
754 ty.fmt_stub(buf)?;
755 write!(buf, " ")?;
756 }
757 write!(buf, "${}", self.name)?;
758 if let Option::Some(default) = &self.default {
759 write!(buf, " = {default}")?;
760 }
761 writeln!(buf, ";")
762 }
763}
764
765impl ToStub for Visibility {
766 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
767 write!(
768 buf,
769 "{}",
770 match self {
771 Visibility::Private => "private",
772 Visibility::Protected => "protected",
773 Visibility::Public => "public",
774 }
775 )
776 }
777}
778
779impl ToStub for Method {
780 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
781 let ret_ref = if matches!(self.ty, MethodType::Constructor) {
784 None
785 } else {
786 match &self.retval {
787 Option::Some(r) => Some(r),
788 Option::None => None,
789 }
790 };
791 let type_overrides = format_phpdoc(&self.docs, &self.params, ret_ref, buf)?;
792
793 self.visibility.fmt_stub(buf)?;
794
795 write!(buf, " ")?;
796
797 if matches!(self.ty, MethodType::Static) {
798 write!(buf, "static ")?;
799 }
800
801 let params_str = self
803 .params
804 .iter()
805 .map(|p| param_to_stub(p, &type_overrides))
806 .collect::<Result<StdVec<_>, FmtError>>()?
807 .join(", ");
808
809 write!(buf, "function {}({params_str})", self.name)?;
810
811 if !matches!(self.ty, MethodType::Constructor)
812 && let Option::Some(retval) = &self.retval
813 {
814 write!(buf, ": ")?;
815 if retval.nullable
817 && !matches!(retval.ty, DataType::Mixed | DataType::Null | DataType::Void)
818 {
819 write!(buf, "?")?;
820 }
821 retval.ty.fmt_stub(buf)?;
822 }
823
824 if self.r#abstract {
825 writeln!(buf, ";")
826 } else {
827 writeln!(buf, " {{}}")
828 }
829 }
830}
831
832impl ToStub for Constant {
833 fn fmt_stub(&self, buf: &mut String) -> FmtResult {
834 self.docs.fmt_stub(buf)?;
835
836 write!(buf, "const {} = ", self.name)?;
837 if let Option::Some(value) = &self.value {
838 write!(buf, "{value}")?;
839 } else {
840 write!(buf, "null")?;
841 }
842 writeln!(buf, ";")
843 }
844}
845
846#[cfg(windows)]
847const NEW_LINE_SEPARATOR: &str = "\r\n";
848#[cfg(not(windows))]
849const NEW_LINE_SEPARATOR: &str = "\n";
850
851fn split_namespace(class: &str) -> (StdOption<&str>, &str) {
858 let idx = class.rfind('\\');
859
860 if let Some(idx) = idx {
861 (Some(&class[0..idx]), &class[idx + 1..])
862 } else {
863 (None, class)
864 }
865}
866
867fn indent(s: &str, depth: usize) -> String {
880 let indent = format!("{:depth$}", "", depth = depth);
881
882 s.split('\n')
883 .map(|line| {
884 let mut result = String::new();
885 if line.chars().any(|c| !c.is_whitespace()) {
886 result.push_str(&indent);
887 result.push_str(line);
888 }
889 result
890 })
891 .collect::<StdVec<_>>()
892 .join(NEW_LINE_SEPARATOR)
893}
894
895#[cfg(test)]
896mod test {
897 use super::{ToStub, split_namespace};
898 use crate::flags::DataType;
899
900 #[test]
901 pub fn test_split_ns() {
902 assert_eq!(split_namespace("ext\\php\\rs"), (Some("ext\\php"), "rs"));
903 assert_eq!(split_namespace("test_solo_ns"), (None, "test_solo_ns"));
904 assert_eq!(split_namespace("simple\\ns"), (Some("simple"), "ns"));
905 }
906
907 #[test]
908 #[cfg(not(windows))]
909 #[allow(clippy::uninlined_format_args)]
910 pub fn test_indent() {
911 use super::indent;
912 use crate::describe::stub::NEW_LINE_SEPARATOR;
913
914 assert_eq!(indent("hello", 4), " hello");
915 assert_eq!(
916 indent(&format!("hello{nl}world{nl}", nl = NEW_LINE_SEPARATOR), 4),
917 format!(" hello{nl} world{nl}", nl = NEW_LINE_SEPARATOR)
918 );
919 }
920
921 #[test]
922 #[allow(clippy::unwrap_used)]
923 pub fn test_datatype_to_stub() {
924 assert_eq!(DataType::Void.to_stub().unwrap(), "void");
926 assert_eq!(DataType::Null.to_stub().unwrap(), "null");
927 assert_eq!(DataType::Bool.to_stub().unwrap(), "bool");
928 assert_eq!(DataType::True.to_stub().unwrap(), "bool");
929 assert_eq!(DataType::False.to_stub().unwrap(), "bool");
930 assert_eq!(DataType::Long.to_stub().unwrap(), "int");
931 assert_eq!(DataType::Double.to_stub().unwrap(), "float");
932 assert_eq!(DataType::String.to_stub().unwrap(), "string");
933 assert_eq!(DataType::Array.to_stub().unwrap(), "array");
934 assert_eq!(DataType::Object(None).to_stub().unwrap(), "object");
935 assert_eq!(
936 DataType::Object(Some("Foo\\Bar")).to_stub().unwrap(),
937 "\\Foo\\Bar"
938 );
939 assert_eq!(DataType::Resource.to_stub().unwrap(), "resource");
940 assert_eq!(DataType::Callable.to_stub().unwrap(), "callable");
941 assert_eq!(DataType::Iterable.to_stub().unwrap(), "iterable");
942 assert_eq!(DataType::Mixed.to_stub().unwrap(), "mixed");
943 assert_eq!(DataType::Undef.to_stub().unwrap(), "mixed");
944 assert_eq!(DataType::Ptr.to_stub().unwrap(), "mixed");
945 assert_eq!(DataType::Indirect.to_stub().unwrap(), "mixed");
946 assert_eq!(DataType::ConstantExpression.to_stub().unwrap(), "mixed");
947 assert_eq!(DataType::Reference.to_stub().unwrap(), "reference");
948 }
949
950 #[test]
951 #[allow(clippy::unwrap_used)]
952 fn test_property_stub_typed_no_docs() {
953 use crate::describe::{Property, Visibility, abi::Option};
954
955 let prop = Property {
956 name: "foo".into(),
957 docs: super::DocBlock(vec![].into()),
958 ty: Option::Some(DataType::String),
959 vis: Visibility::Public,
960 static_: false,
961 nullable: false,
962 readonly: false,
963 default: Option::None,
964 };
965 let stub = prop.to_stub().unwrap();
966 assert!(!stub.contains("@var"), "no @var without docs: {stub}");
968 assert_eq!(stub, "public string $foo;\n");
969 }
970
971 #[test]
972 #[allow(clippy::unwrap_used)]
973 fn test_property_stub_nullable_with_default() {
974 use crate::describe::{Property, Visibility, abi::Option};
975
976 let prop = Property {
977 name: "bar".into(),
978 docs: super::DocBlock(vec![].into()),
979 ty: Option::Some(DataType::String),
980 vis: Visibility::Public,
981 static_: false,
982 nullable: true,
983 readonly: false,
984 default: Option::Some("null".into()),
985 };
986 let stub = prop.to_stub().unwrap();
987 assert!(
988 stub.contains("public ?string $bar = null;"),
989 "missing nullable default: {stub}"
990 );
991 }
992
993 #[test]
994 #[allow(clippy::unwrap_used)]
995 fn test_property_stub_static_with_default() {
996 use crate::describe::{Property, Visibility, abi::Option};
997
998 let prop = Property {
999 name: "limit".into(),
1000 docs: super::DocBlock(vec![].into()),
1001 ty: Option::Some(DataType::Long),
1002 vis: Visibility::Public,
1003 static_: true,
1004 nullable: false,
1005 readonly: false,
1006 default: Option::Some("100".into()),
1007 };
1008 let stub = prop.to_stub().unwrap();
1009 assert!(
1010 stub.contains("public static int $limit = 100;"),
1011 "missing static default: {stub}"
1012 );
1013 }
1014
1015 #[test]
1016 #[allow(clippy::unwrap_used)]
1017 fn test_property_stub_static_string_default() {
1018 use crate::describe::{Property, Visibility, abi::Option};
1019
1020 let prop = Property {
1021 name: "label".into(),
1022 docs: super::DocBlock(vec![].into()),
1023 ty: Option::Some(DataType::String),
1024 vis: Visibility::Public,
1025 static_: true,
1026 nullable: false,
1027 readonly: false,
1028 default: Option::Some("'hello'".into()),
1029 };
1030 let stub = prop.to_stub().unwrap();
1031 assert!(
1032 stub.contains("public static string $label = 'hello';"),
1033 "missing static string default: {stub}"
1034 );
1035 }
1036
1037 #[test]
1038 #[allow(clippy::unwrap_used)]
1039 fn test_property_stub_with_docs_includes_var() {
1040 use crate::describe::{Property, Visibility, abi::Option};
1041
1042 let prop = Property {
1043 name: "bar".into(),
1044 docs: super::DocBlock(vec![" The user name.".into()].into()),
1045 ty: Option::Some(DataType::String),
1046 vis: Visibility::Public,
1047 static_: false,
1048 nullable: true,
1049 readonly: false,
1050 default: Option::None,
1051 };
1052 let stub = prop.to_stub().unwrap();
1053 assert!(stub.contains("The user name."), "missing doc: {stub}");
1054 assert!(
1055 stub.contains("@var string|null"),
1056 "missing @var with nullable in docblock: {stub}"
1057 );
1058 assert!(
1059 stub.contains("public ?string $bar;"),
1060 "missing decl: {stub}"
1061 );
1062 }
1063
1064 #[test]
1065 #[allow(clippy::unwrap_used)]
1066 fn test_property_stub_with_docs_no_type() {
1067 use crate::describe::{Property, Visibility, abi::Option};
1068
1069 let prop = Property {
1070 name: "x".into(),
1071 docs: super::DocBlock(vec![" Some value.".into()].into()),
1072 ty: Option::None,
1073 vis: Visibility::Public,
1074 static_: false,
1075 nullable: false,
1076 readonly: false,
1077 default: Option::None,
1078 };
1079 let stub = prop.to_stub().unwrap();
1080 assert!(stub.contains("Some value."), "missing doc: {stub}");
1081 assert!(!stub.contains("@var"), "no @var without type: {stub}");
1082 assert!(stub.contains("public $x;"), "missing decl: {stub}");
1083 }
1084
1085 #[test]
1086 #[allow(clippy::unwrap_used)]
1087 fn test_property_stub_readonly() {
1088 use crate::describe::{Property, Visibility, abi::Option};
1089
1090 let prop = Property {
1091 name: "baz".into(),
1092 docs: super::DocBlock(vec![].into()),
1093 ty: Option::Some(DataType::Array),
1094 vis: Visibility::Public,
1095 static_: false,
1096 nullable: false,
1097 readonly: true,
1098 default: Option::None,
1099 };
1100 let stub = prop.to_stub().unwrap();
1101 assert_eq!(stub, "public readonly array $baz;\n");
1102 }
1103
1104 #[test]
1105 #[allow(clippy::unwrap_used)]
1106 fn test_property_stub_untyped_no_docblock() {
1107 use crate::describe::{Property, Visibility, abi::Option};
1108
1109 let prop = Property {
1110 name: "x".into(),
1111 docs: super::DocBlock(vec![].into()),
1112 ty: Option::None,
1113 vis: Visibility::Public,
1114 static_: false,
1115 nullable: false,
1116 readonly: false,
1117 default: Option::None,
1118 };
1119 let stub = prop.to_stub().unwrap();
1120 assert!(
1121 !stub.contains("/**"),
1122 "no docblock without docs or type: {stub}"
1123 );
1124 assert_eq!(stub, "public $x;\n");
1125 }
1126
1127 #[test]
1128 #[allow(clippy::unwrap_used)]
1129 fn test_property_stub_static_typed() {
1130 use crate::describe::{Property, Visibility, abi::Option};
1131
1132 let prop = Property {
1133 name: "count".into(),
1134 docs: super::DocBlock(vec![].into()),
1135 ty: Option::Some(DataType::Long),
1136 vis: Visibility::Protected,
1137 static_: true,
1138 nullable: false,
1139 readonly: false,
1140 default: Option::None,
1141 };
1142 let stub = prop.to_stub().unwrap();
1143 assert!(
1144 stub.contains("protected static int $count;"),
1145 "missing decl: {stub}"
1146 );
1147 }
1148
1149 #[test]
1150 #[allow(clippy::unwrap_used)]
1151 fn test_property_stub_nullable_mixed_stays_mixed() {
1152 use crate::describe::{Property, Visibility, abi::Option};
1153
1154 let prop = Property {
1155 name: "val".into(),
1156 docs: super::DocBlock(vec![].into()),
1157 ty: Option::Some(DataType::Mixed),
1158 vis: Visibility::Public,
1159 static_: false,
1160 nullable: true,
1161 readonly: false,
1162 default: Option::None,
1163 };
1164 let stub = prop.to_stub().unwrap();
1165 assert!(stub.contains("public mixed $val;"), "missing decl: {stub}");
1167 }
1168
1169 #[test]
1170 #[allow(clippy::unwrap_used)]
1171 fn test_property_stub_nullable_object_with_docs() {
1172 use crate::describe::{Property, Visibility, abi::Option};
1173
1174 let prop = Property {
1175 name: "ref_".into(),
1176 docs: super::DocBlock(vec![" The related entity.".into()].into()),
1177 ty: Option::Some(DataType::Object(Some("App\\Entity"))),
1178 vis: Visibility::Private,
1179 static_: false,
1180 nullable: true,
1181 readonly: false,
1182 default: Option::None,
1183 };
1184 let stub = prop.to_stub().unwrap();
1185 assert!(stub.contains("The related entity."), "missing doc: {stub}");
1186 assert!(
1187 stub.contains("@var \\App\\Entity|null"),
1188 "missing @var with FQCN: {stub}"
1189 );
1190 assert!(
1191 stub.contains("private ?\\App\\Entity $ref_;"),
1192 "missing decl: {stub}"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_parse_rustdoc() {
1198 use super::{Str, parse_rustdoc};
1199
1200 let docs: Vec<Str> = vec![
1202 " Gives you a nice greeting!".into(),
1203 "".into(),
1204 " # Arguments".into(),
1205 "".into(),
1206 " * `name` - Your name".into(),
1207 " * `age` - Your age".into(),
1208 "".into(),
1209 " # Returns".into(),
1210 "".into(),
1211 " Nice greeting!".into(),
1212 ];
1213
1214 let parsed = parse_rustdoc(&docs);
1215
1216 assert_eq!(parsed.summary.len(), 2);
1218 assert!(parsed.summary[0].contains("Gives you a nice greeting"));
1219
1220 assert_eq!(parsed.params.len(), 2);
1222 assert_eq!(parsed.params.get("name"), Some(&"Your name".to_string()));
1223 assert_eq!(parsed.params.get("age"), Some(&"Your age".to_string()));
1224
1225 assert!(parsed.returns.is_some());
1227 assert!(
1228 parsed
1229 .returns
1230 .as_ref()
1231 .is_some_and(|r| r.contains("Nice greeting"))
1232 );
1233 }
1234
1235 #[test]
1236 fn test_parse_param_line() {
1237 use super::parse_param_line;
1238
1239 assert_eq!(
1241 parse_param_line("`name` - Your name"),
1242 Some(("name".to_string(), "Your name".to_string()))
1243 );
1244
1245 assert_eq!(
1247 parse_param_line("`$name` - Your name"),
1248 Some(("name".to_string(), "Your name".to_string()))
1249 );
1250
1251 assert_eq!(
1253 parse_param_line("name - Your name"),
1254 Some(("name".to_string(), "Your name".to_string()))
1255 );
1256
1257 assert_eq!(parse_param_line("no separator here"), None);
1259 }
1260
1261 #[test]
1262 fn test_format_phpdoc() {
1263 use super::{DocBlock, Parameter, Retval, Str, format_phpdoc};
1264 use crate::describe::abi::Option;
1265 use crate::flags::DataType;
1266
1267 let docs = DocBlock(
1269 vec![
1270 Str::from(" Greets the user."),
1271 Str::from(""),
1272 Str::from(" # Arguments"),
1273 Str::from(""),
1274 Str::from(" * `name` - The name to greet"),
1275 Str::from(""),
1276 Str::from(" # Returns"),
1277 Str::from(""),
1278 Str::from(" A greeting string."),
1279 ]
1280 .into(),
1281 );
1282
1283 let params = vec![Parameter {
1284 name: "name".into(),
1285 ty: Option::Some(DataType::String),
1286 nullable: false,
1287 variadic: false,
1288 default: Option::None,
1289 }];
1290
1291 let retval = Retval {
1292 ty: DataType::String,
1293 nullable: false,
1294 };
1295
1296 let mut buf = String::new();
1297 format_phpdoc(&docs, ¶ms, Some(&retval), &mut buf).expect("format_phpdoc failed");
1298
1299 assert!(buf.contains("/**"));
1301 assert!(buf.contains("*/"));
1302 assert!(buf.contains("@param string $name The name to greet"));
1303 assert!(buf.contains("@return string A greeting string."));
1304 assert!(!buf.contains("# Arguments"));
1306 assert!(!buf.contains("# Returns"));
1307 }
1308}