Skip to main content

phpdoc_parser/
parser.rs

1//! Top-level PHPDoc comment parser.
2//!
3//! Parses `/** ... */` doc-block comments into a [`PhpDoc`] with summary,
4//! description, and generic tags. All spans are comment-relative
5//! (0 = start of `/**`).
6//!
7//! Tag bodies are exposed as raw [`PhpDocText`] — callers apply their own
8//! type parsers and validation rules to the body text.
9
10use crate::ast::{InlineTag, PhpDoc, PhpDocTag, PhpDocText, TextSegment};
11use crate::Span;
12
13// =============================================================================
14// Public entry point
15// =============================================================================
16
17/// Parse a raw doc-comment string into a [`PhpDoc`].
18///
19/// The input may include `/**` and `*/` delimiters or be already stripped.
20pub fn parse(text: &str) -> PhpDoc {
21    let span = Span::new(0, text.len() as u32);
22    let (inner, content_start) = strip_delimiters(text);
23    let lines = clean_lines(inner, content_start);
24    let (summary, description, tag_start) = extract_prose(&lines);
25    let tags = if tag_start < lines.len() {
26        let expanded = split_inline_tag_lines(&lines[tag_start..]);
27        parse_tags(&expanded)
28    } else {
29        Vec::new()
30    };
31    PhpDoc {
32        summary,
33        description,
34        tags,
35        span,
36    }
37}
38
39// =============================================================================
40// Internal types
41// =============================================================================
42
43struct CleanLine {
44    text: String,
45    /// Byte offset of `text[0]` within the original comment string.
46    base_offset: u32,
47}
48
49// =============================================================================
50// Delimiter stripping
51// =============================================================================
52
53fn strip_delimiters(text: &str) -> (&str, u32) {
54    let (s, start) = if let Some(rest) = text.strip_prefix("/**") {
55        (rest, 3u32)
56    } else if let Some(rest) = text.strip_prefix("/*") {
57        (rest, 2u32)
58    } else {
59        (text, 0u32)
60    };
61    let s = s.strip_suffix("*/").unwrap_or(s);
62    (s, start)
63}
64
65// =============================================================================
66// Line cleaning
67// =============================================================================
68
69fn clean_lines(inner: &str, content_start: u32) -> Vec<CleanLine> {
70    let mut lines = Vec::new();
71    let mut offset_in_inner: u32 = 0;
72
73    for raw_line in inner.split('\n') {
74        let line_abs_start = content_start + offset_in_inner;
75
76        // Strip trailing \r so CRLF input doesn't leak into text content.
77        // Use raw_line.len() (including the \r) for offset tracking so that
78        // byte positions in the original string remain accurate.
79        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
80        let bytes = line.as_bytes();
81
82        let mut stripped_bytes: u32 = 0;
83
84        let ws_count = bytes
85            .iter()
86            .take_while(|&&b| b == b' ' || b == b'\t')
87            .count();
88        stripped_bytes += ws_count as u32;
89        let after_ws = &line[ws_count..];
90
91        let (cleaned, extra_stripped) = if let Some(rest) = after_ws.strip_prefix("* ") {
92            (rest, 2u32)
93        } else if let Some(rest) = after_ws.strip_prefix('*') {
94            (rest, 1u32)
95        } else {
96            (after_ws, 0u32)
97        };
98        stripped_bytes += extra_stripped;
99
100        lines.push(CleanLine {
101            text: cleaned.to_owned(),
102            base_offset: line_abs_start + stripped_bytes,
103        });
104
105        offset_in_inner += raw_line.len() as u32 + 1;
106    }
107
108    lines
109}
110
111// =============================================================================
112// Prose extraction
113// =============================================================================
114
115fn extract_prose(lines: &[CleanLine]) -> (Option<PhpDocText>, Option<PhpDocText>, usize) {
116    let tag_start = lines
117        .iter()
118        .position(|l| l.text.trim_start().starts_with('@'))
119        .unwrap_or(lines.len());
120
121    let prose_lines = &lines[..tag_start];
122
123    let Some(start) = prose_lines.iter().position(|l| !l.text.trim().is_empty()) else {
124        return (None, None, tag_start);
125    };
126
127    let summary = {
128        let line = &prose_lines[start];
129        let trimmed = line.text.trim();
130        if trimmed.is_empty() {
131            None
132        } else {
133            let leading = (line.text.len() - line.text.trim_start().len()) as u32;
134            Some(text_from_str(trimmed, line.base_offset + leading))
135        }
136    };
137
138    let blank_after_summary = prose_lines[start..]
139        .iter()
140        .position(|l| l.text.trim().is_empty())
141        .map(|i| i + start);
142
143    let description = if let Some(blank) = blank_after_summary {
144        let desc_start = prose_lines[blank..]
145            .iter()
146            .position(|l| !l.text.trim().is_empty())
147            .map(|i| i + blank);
148
149        if let Some(ds) = desc_start {
150            let desc_end = prose_lines
151                .iter()
152                .rposition(|l| !l.text.trim().is_empty())
153                .map(|i| i + 1)
154                .unwrap_or(ds);
155
156            let slice: Vec<&CleanLine> = prose_lines[ds..desc_end].iter().collect();
157            description_to_text(&slice)
158        } else {
159            None
160        }
161    } else {
162        None
163    };
164
165    (summary, description, tag_start)
166}
167
168// =============================================================================
169// Tag parsing
170// =============================================================================
171
172/// Split a physical line at each additional `@tag` boundary that appears
173/// mid-line, so `@param int $x @return int` parses as two tags instead of
174/// one tag whose body swallows the second verbatim. A boundary is a `@`
175/// preceded by whitespace and followed by a letter — this excludes
176/// email-like text (`user@example.com`, no preceding whitespace) and
177/// `{@inline}` tags (preceded by `{`, not whitespace).
178///
179/// Only lines that themselves start with `@` are scanned: a tag's
180/// continuation/description lines are prose, so an `@word` inside them
181/// (e.g. `Contact user @example for details.`) must not be mistaken for a
182/// new tag.
183///
184/// Known limitation: an `@word` inside a tag's own first-line body (not a
185/// continuation line) is indistinguishable from a genuine second tag, since
186/// this crate deliberately has no notion of which tag names are "real"
187/// (see the crate-level docs). `@param string $email Contact @jsmith` still
188/// splits into a bogus `jsmith` tag.
189fn split_inline_tag_lines(lines: &[CleanLine]) -> Vec<CleanLine> {
190    let mut out = Vec::with_capacity(lines.len());
191    for line in lines {
192        if !line.text.trim_start().starts_with('@') {
193            out.push(CleanLine {
194                text: line.text.clone(),
195                base_offset: line.base_offset,
196            });
197            continue;
198        }
199        let bytes = line.text.as_bytes();
200        let mut starts = vec![0usize];
201        for i in 1..bytes.len() {
202            if bytes[i] == b'@'
203                && bytes[i - 1].is_ascii_whitespace()
204                && bytes.get(i + 1).is_some_and(u8::is_ascii_alphabetic)
205            {
206                starts.push(i);
207            }
208        }
209        if starts.len() == 1 {
210            out.push(CleanLine {
211                text: line.text.clone(),
212                base_offset: line.base_offset,
213            });
214            continue;
215        }
216        starts.push(bytes.len());
217        for w in starts.windows(2) {
218            let (s, e) = (w[0], w[1]);
219            out.push(CleanLine {
220                text: line.text[s..e].to_string(),
221                base_offset: line.base_offset + s as u32,
222            });
223        }
224    }
225    out
226}
227
228fn parse_tags(lines: &[CleanLine]) -> Vec<PhpDocTag> {
229    let mut tags = Vec::new();
230    let mut i = 0;
231
232    while i < lines.len() {
233        let line_text = lines[i].text.trim_start();
234        if !line_text.starts_with('@') {
235            i += 1;
236            continue;
237        }
238
239        let tag_start_offset = lines[i].base_offset;
240
241        let mut tag_lines: Vec<&CleanLine> = vec![&lines[i]];
242        i += 1;
243        while i < lines.len() && !lines[i].text.trim_start().starts_with('@') {
244            tag_lines.push(&lines[i]);
245            i += 1;
246        }
247
248        let last = tag_lines.last().unwrap();
249        let tag_end_offset = last.base_offset + last.text.len() as u32;
250        let tag_span = Span::new(tag_start_offset, tag_end_offset);
251
252        let first = tag_lines[0]
253            .text
254            .trim_start()
255            .strip_prefix('@')
256            .unwrap_or("");
257
258        let (tag_name, body_on_first) = match first.find(|c: char| c.is_whitespace()) {
259            Some(pos) => {
260                let body = first[pos..].trim();
261                (
262                    &first[..pos],
263                    if body.is_empty() { None } else { Some(body) },
264                )
265            }
266            None => (first, None),
267        };
268
269        let body_base_offset = {
270            let after_at = &tag_lines[0].text.trim_start()[1 + tag_name.len()..];
271            let ws = (after_at.len() - after_at.trim_start().len()) as u32;
272            tag_lines[0].base_offset + 1 + tag_name.len() as u32 + ws
273        };
274
275        let first_piece = body_on_first.map(|t| (t, body_base_offset));
276        let body = tag_body_to_text(first_piece, &tag_lines[1..]);
277
278        tags.push(PhpDocTag {
279            name: tag_name.to_owned(),
280            body,
281            span: tag_span,
282        });
283    }
284
285    tags
286}
287
288// =============================================================================
289// Text builders
290// =============================================================================
291
292/// What blank source lines mean to a [`PhpDocTextBuilder`].
293#[derive(Clone, Copy)]
294enum BlankLinePolicy {
295    /// Blank lines carry no meaning — dropped entirely, no separator or break.
296    /// Used for tag bodies, which are a single logical value with no paragraphs.
297    Insignificant,
298    /// A blank line is a paragraph separator, so consecutive blanks become `\n\n`.
299    /// Used for descriptions.
300    ParagraphBreak,
301}
302
303/// Accumulates trimmed source lines into a single [`PhpDocText`].
304///
305/// Each non-blank line is trimmed, scanned for `{@inline}` tags at its own true
306/// source offset (so spans stay accurate across lines), and appended after a `\n`
307/// separator. The running [`Span`] covers the first through last non-blank line.
308/// Blank-line handling is governed by [`BlankLinePolicy`].
309struct PhpDocTextBuilder {
310    segments: Vec<TextSegment>,
311    span_start: Option<u32>,
312    span_end: u32,
313    blank_policy: BlankLinePolicy,
314    /// Whether any line has been pushed yet — controls the leading separator so
315    /// the first line never gets a `\n` in front of it.
316    started: bool,
317}
318
319impl PhpDocTextBuilder {
320    fn new(blank_policy: BlankLinePolicy) -> Self {
321        Self {
322            segments: Vec::new(),
323            span_start: None,
324            span_end: 0,
325            blank_policy,
326            started: false,
327        }
328    }
329
330    /// Push one source line. `text` is the raw (untrimmed) line and `base` is the
331    /// source offset of its first byte.
332    fn push_line(&mut self, text: &str, base: u32) {
333        let trimmed = text.trim();
334
335        if trimmed.is_empty() {
336            if let BlankLinePolicy::ParagraphBreak = self.blank_policy {
337                if self.started {
338                    push_text(&mut self.segments, "\n");
339                }
340                self.started = true;
341            }
342            return;
343        }
344
345        if self.started {
346            push_text(&mut self.segments, "\n");
347        }
348        self.started = true;
349
350        let leading = (text.len() - text.trim_start().len()) as u32;
351        let content_offset = base + leading;
352        if self.span_start.is_none() {
353            self.span_start = Some(content_offset);
354        }
355        self.span_end = content_offset + trimmed.len() as u32;
356        merge_into(
357            &mut self.segments,
358            text_from_str(trimmed, content_offset).segments,
359        );
360    }
361
362    fn build(self) -> Option<PhpDocText> {
363        self.span_start.map(|start| PhpDocText {
364            segments: self.segments,
365            span: Span::new(start, self.span_end),
366        })
367    }
368}
369
370/// Build a [`PhpDocText`] for a `@tag` body.
371///
372/// `first_piece` is `Some((text, base_offset))` for the text on the `@tag` line
373/// itself (after the tag name); continuation lines follow. Lines are joined with
374/// a newline, preserving line boundaries so callers can separate a type
375/// expression from its description (e.g. `@var T\nThe description`).
376fn tag_body_to_text(
377    first_piece: Option<(&str, u32)>,
378    continuation: &[&CleanLine],
379) -> Option<PhpDocText> {
380    let mut builder = PhpDocTextBuilder::new(BlankLinePolicy::Insignificant);
381    if let Some((text, base)) = first_piece {
382        builder.push_line(text, base);
383    }
384    for line in continuation {
385        builder.push_line(&line.text, line.base_offset);
386    }
387    builder.build()
388}
389
390/// Build a [`PhpDocText`] for a description (multi-line prose after the summary).
391///
392/// Lines are joined with `\n`; blank lines produce `\n\n` paragraph breaks.
393fn description_to_text(lines: &[&CleanLine]) -> Option<PhpDocText> {
394    let mut builder = PhpDocTextBuilder::new(BlankLinePolicy::ParagraphBreak);
395    for line in lines {
396        builder.push_line(&line.text, line.base_offset);
397    }
398    builder.build()
399}
400
401/// Append `text` to the last `Text` segment, or push a new one.
402fn push_text(segments: &mut Vec<TextSegment>, text: &str) {
403    if text.is_empty() {
404        return;
405    }
406    if let Some(TextSegment::Text(last)) = segments.last_mut() {
407        last.push_str(text);
408    } else {
409        segments.push(TextSegment::Text(text.to_owned()));
410    }
411}
412
413/// Extend `dest` with `src`, merging adjacent `Text` segments at the boundary.
414fn merge_into(dest: &mut Vec<TextSegment>, src: Vec<TextSegment>) {
415    for seg in src {
416        match seg {
417            TextSegment::Text(t) => push_text(dest, &t),
418            other => dest.push(other),
419        }
420    }
421}
422
423// =============================================================================
424// Inline-tag scanning
425// =============================================================================
426
427/// Build a [`PhpDocText`] from a string, scanning for `{@tagname body}` inline tags.
428fn text_from_str(s: &str, base_offset: u32) -> PhpDocText {
429    let mut segments = Vec::new();
430    let bytes = s.as_bytes();
431    let mut i = 0;
432    let mut text_start = 0;
433
434    while i < bytes.len() {
435        if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'@') {
436            if i > text_start {
437                segments.push(TextSegment::Text(s[text_start..i].to_owned()));
438            }
439
440            let tag_abs_start = i;
441            i += 2; // skip `{@`
442
443            let name_start = i;
444            while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'}' {
445                i += 1;
446            }
447            let name = s[name_start..i].to_owned();
448
449            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
450                i += 1;
451            }
452
453            let body_start = i;
454            let mut depth = 1i32;
455            while i < bytes.len() {
456                match bytes[i] {
457                    b'{' => {
458                        depth += 1;
459                        i += 1;
460                    }
461                    b'}' if depth == 1 => break,
462                    b'}' => {
463                        depth -= 1;
464                        i += 1;
465                    }
466                    _ => {
467                        i += 1;
468                    }
469                }
470            }
471
472            let body_raw = s[body_start..i].trim();
473            let body = if body_raw.is_empty() {
474                None
475            } else {
476                Some(body_raw.to_owned())
477            };
478
479            if i < bytes.len() {
480                i += 1; // consume `}`
481            }
482
483            segments.push(TextSegment::InlineTag(InlineTag {
484                name,
485                body,
486                span: Span::new(base_offset + tag_abs_start as u32, base_offset + i as u32),
487            }));
488
489            text_start = i;
490        } else {
491            i += 1;
492        }
493    }
494
495    if text_start < s.len() {
496        segments.push(TextSegment::Text(s[text_start..].to_owned()));
497    }
498
499    PhpDocText {
500        segments,
501        span: Span::new(base_offset, base_offset + s.len() as u32),
502    }
503}