Skip to main content

use_php_docblock/
lib.rs

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/// Common PHPDoc tag kind metadata.
49#[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/// Lightweight PHPDoc tag metadata.
109#[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/// PHPDoc block summary/body metadata.
163#[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/// Error returned when PHPDoc metadata is invalid.
198#[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}