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