1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7macro_rules! doc_text_newtype {
8 ($name:ident) => {
9 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10 pub struct $name(String);
11
12 impl $name {
13 pub fn new(input: &str) -> Result<Self, PhpDocblockError> {
14 let trimmed = input.trim();
15 if trimmed.is_empty() {
16 Err(PhpDocblockError::Empty)
17 } else {
18 Ok(Self(trimmed.to_string()))
19 }
20 }
21
22 pub fn as_str(&self) -> &str {
23 &self.0
24 }
25 }
26
27 impl fmt::Display for $name {
28 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29 formatter.write_str(self.as_str())
30 }
31 }
32
33 impl FromStr for $name {
34 type Err = PhpDocblockError;
35
36 fn from_str(input: &str) -> Result<Self, Self::Err> {
37 Self::new(input)
38 }
39 }
40 };
41}
42
43doc_text_newtype!(TagName);
44doc_text_newtype!(DocblockTypeString);
45doc_text_newtype!(DocblockSummary);
46doc_text_newtype!(DocblockBody);
47
48#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
50pub enum DocblockTagKind {
51 Param,
52 Return,
53 Throws,
54 Var,
55 Property,
56 Method,
57 Template,
58 Deprecated,
59 Since,
60 See,
61 Other,
62}
63
64impl DocblockTagKind {
65 pub const fn as_str(self) -> &'static str {
66 match self {
67 Self::Param => "param",
68 Self::Return => "return",
69 Self::Throws => "throws",
70 Self::Var => "var",
71 Self::Property => "property",
72 Self::Method => "method",
73 Self::Template => "template",
74 Self::Deprecated => "deprecated",
75 Self::Since => "since",
76 Self::See => "see",
77 Self::Other => "other",
78 }
79 }
80}
81
82impl fmt::Display for DocblockTagKind {
83 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84 formatter.write_str(self.as_str())
85 }
86}
87
88impl FromStr for DocblockTagKind {
89 type Err = PhpDocblockError;
90
91 fn from_str(input: &str) -> Result<Self, Self::Err> {
92 match normalized_tag(input)?.as_str() {
93 "param" => Ok(Self::Param),
94 "return" | "returns" => Ok(Self::Return),
95 "throws" | "throw" => Ok(Self::Throws),
96 "var" => Ok(Self::Var),
97 "property" => Ok(Self::Property),
98 "method" => Ok(Self::Method),
99 "template" => Ok(Self::Template),
100 "deprecated" => Ok(Self::Deprecated),
101 "since" => Ok(Self::Since),
102 "see" => Ok(Self::See),
103 _ => Ok(Self::Other),
104 }
105 }
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct DocblockTag {
111 name: TagName,
112 kind: Option<DocblockTagKind>,
113 type_string: Option<DocblockTypeString>,
114 description: Option<String>,
115}
116
117impl DocblockTag {
118 pub fn new(name: TagName) -> Self {
119 Self {
120 name,
121 kind: None,
122 type_string: None,
123 description: None,
124 }
125 }
126
127 pub const fn with_kind(mut self, kind: DocblockTagKind) -> Self {
128 self.kind = Some(kind);
129 self
130 }
131
132 pub fn with_type_string(mut self, type_string: DocblockTypeString) -> Self {
133 self.type_string = Some(type_string);
134 self
135 }
136
137 pub fn with_description(mut self, description: &str) -> Self {
138 let trimmed = description.trim();
139 if !trimmed.is_empty() {
140 self.description = Some(trimmed.to_string());
141 }
142 self
143 }
144
145 pub const fn name(&self) -> &TagName {
146 &self.name
147 }
148
149 pub const fn kind(&self) -> Option<DocblockTagKind> {
150 self.kind
151 }
152
153 pub const fn type_string(&self) -> Option<&DocblockTypeString> {
154 self.type_string.as_ref()
155 }
156
157 pub fn description(&self) -> Option<&str> {
158 self.description.as_deref()
159 }
160}
161
162#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct Docblock {
165 raw: String,
166 summary: String,
167 body: String,
168}
169
170impl Docblock {
171 pub fn new(input: &str) -> Result<Self, PhpDocblockError> {
172 let cleaned = clean_docblock_text(input);
173 if cleaned.trim().is_empty() {
174 return Err(PhpDocblockError::Empty);
175 }
176 let (summary, body) = split_docblock_summary_body(&cleaned);
177 Ok(Self {
178 raw: input.to_string(),
179 summary,
180 body,
181 })
182 }
183
184 pub fn raw(&self) -> &str {
185 &self.raw
186 }
187
188 pub fn summary(&self) -> &str {
189 &self.summary
190 }
191
192 pub fn body(&self) -> &str {
193 &self.body
194 }
195}
196
197#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199pub enum PhpDocblockError {
200 Empty,
201}
202
203impl fmt::Display for PhpDocblockError {
204 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
205 formatter.write_str("PHPDoc metadata cannot be empty")
206 }
207}
208
209impl Error for PhpDocblockError {}
210
211pub fn clean_docblock_text(input: &str) -> String {
212 input
213 .lines()
214 .map(|line| {
215 line.trim()
216 .trim_start_matches("/**")
217 .trim_start_matches("/*")
218 .trim_end_matches("*/")
219 .trim_start_matches('*')
220 .trim()
221 .to_string()
222 })
223 .filter(|line| !line.is_empty())
224 .collect::<Vec<_>>()
225 .join("\n")
226}
227
228pub fn split_docblock_summary_body(input: &str) -> (String, String) {
229 let cleaned = clean_docblock_text(input);
230 let mut lines = cleaned.lines();
231 let summary = lines.next().unwrap_or_default().trim().to_string();
232 let body = lines.collect::<Vec<_>>().join("\n").trim().to_string();
233 (summary, body)
234}
235
236fn normalized_tag(input: &str) -> Result<String, PhpDocblockError> {
237 let trimmed = input.trim().trim_start_matches('@');
238 if trimmed.is_empty() {
239 Err(PhpDocblockError::Empty)
240 } else {
241 Ok(trimmed.to_ascii_lowercase())
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::{
248 Docblock, DocblockTag, DocblockTagKind, DocblockTypeString, PhpDocblockError, TagName,
249 split_docblock_summary_body,
250 };
251
252 #[test]
253 fn splits_summary_and_body() -> Result<(), PhpDocblockError> {
254 let block = Docblock::new("/**\n * Create a user.\n *\n * Stores metadata only.\n */")?;
255 let tag = DocblockTag::new(TagName::new("return")?)
256 .with_kind(DocblockTagKind::Return)
257 .with_type_string(DocblockTypeString::new("User")?);
258
259 assert_eq!(block.summary(), "Create a user.");
260 assert_eq!(block.body(), "Stores metadata only.");
261 assert_eq!(tag.kind(), Some(DocblockTagKind::Return));
262 assert_eq!(tag.type_string().expect("type").as_str(), "User");
263 Ok(())
264 }
265
266 #[test]
267 fn helper_accepts_plain_text() {
268 let (summary, body) = split_docblock_summary_body("Summary.\nBody.");
269 assert_eq!(summary, "Summary.");
270 assert_eq!(body, "Body.");
271 }
272}