Skip to main content

mago_syntax/comments/
docblock.rs

1use mago_span::HasSpan;
2
3use crate::ast::Program;
4use crate::ast::Trivia;
5use crate::ast::TriviaKind;
6
7/// An iterator that yields docblock trivia nodes preceding a position, walking
8/// backwards through stacked docblocks using the same gap-checking logic as
9/// `get_docblock_before_position`.
10///
11/// By default all docblocks are yielded. Call `important_only(patterns)` to
12/// restrict the iterator to docblocks whose text contains at least one of the
13/// given substrings, skipping non-matching entries rather than stopping.
14pub struct PrecedingDocblocks<'arena, 'pat> {
15    trivia: &'arena [Trivia<'arena>],
16    start: u32,
17    important_patterns: &'pat [&'pat [u8]],
18}
19
20impl<'arena> PrecedingDocblocks<'arena, 'static> {
21    #[must_use]
22    pub fn new(trivia: &'arena [Trivia<'arena>], start_offset: u32) -> Self {
23        Self { trivia, start: start_offset, important_patterns: &[] }
24    }
25}
26
27impl<'arena> PrecedingDocblocks<'arena, '_> {
28    /// Restrict this iterator to docblocks whose text contains at least one of
29    /// `patterns`. Non-matching docblocks are skipped; the search continues
30    /// backward past them.
31    #[must_use]
32    pub fn important_only<'new_pat>(
33        self,
34        patterns: &'new_pat [&'new_pat [u8]],
35    ) -> PrecedingDocblocks<'arena, 'new_pat> {
36        PrecedingDocblocks { trivia: self.trivia, start: self.start, important_patterns: patterns }
37    }
38}
39
40impl<'arena> Iterator for PrecedingDocblocks<'arena, '_> {
41    type Item = &'arena Trivia<'arena>;
42
43    fn next(&mut self) -> Option<Self::Item> {
44        loop {
45            let trivia = get_docblock_before_position(self.trivia, self.start)?;
46            self.start = trivia.span.start_offset();
47            if self.important_patterns.is_empty()
48                || self.important_patterns.iter().any(|p| memchr::memmem::find(trivia.value, p).is_some())
49            {
50                return Some(trivia);
51            }
52        }
53    }
54}
55
56/// Retrieves the docblock comment associated with a given node in the program.
57/// If the node is preceded by a docblock comment, it returns that comment.
58///
59/// This function searches for the last docblock comment that appears before the node's start position,
60/// ensuring that it is directly preceding the node without any non-whitespace characters in between.
61///
62/// # Arguments
63///
64/// * `program` - The program containing the trivia.
65/// * `node` - The node for which to find the preceding docblock comment.
66///
67/// # Returns
68///
69/// An `Option` containing a reference to the `Trivia` representing the docblock comment if found,
70/// or `None` if no suitable docblock comment exists before the node.
71#[inline]
72#[must_use]
73pub fn get_docblock_for_node<'arena>(
74    program: &'arena Program<'arena>,
75    node: impl HasSpan,
76) -> Option<&'arena Trivia<'arena>> {
77    get_docblock_before_position(program.trivia.as_slice(), node.span().start.offset)
78}
79
80/// Retrieves the docblock comment that appears before a specific position in the source code.
81///
82/// This function scans the trivia associated with the source code and returns the last docblock comment
83/// that appears before the specified position, ensuring that it is directly preceding the node
84/// without any non-whitespace characters in between.
85///
86/// # Arguments
87///
88/// * `trivias` - A slice of trivia associated with the source code.
89/// * `node_start_offset` - The start offset of the node for which to find the preceding docblock comment.
90///
91/// # Returns
92///
93/// An `Option` containing a reference to the `Trivia` representing the docblock comment if found,
94#[must_use]
95pub fn get_docblock_before_position<'arena>(
96    trivias: &'arena [Trivia<'arena>],
97    node_start_offset: u32,
98) -> Option<&'arena Trivia<'arena>> {
99    let candidate_partition_idx = trivias.partition_point(|trivia| trivia.span.start.offset < node_start_offset);
100    if candidate_partition_idx == 0 {
101        return None;
102    }
103
104    // Track the earliest position we've "covered" by trivia.
105    // Start from node_start_offset and work backwards.
106    // As we iterate, we verify that each trivia connects to the next (no code gaps).
107    // Since the parser captures all whitespace as WhiteSpace trivia, any gap not covered
108    // by a trivia node is actual code, so we just check for contiguity.
109    let mut covered_from = node_start_offset;
110
111    for i in (0..candidate_partition_idx).rev() {
112        let trivia = &trivias[i];
113        let trivia_end = trivia.span.end_offset();
114
115        if trivia_end != covered_from {
116            // Gap between this trivia and our covered region contains code.
117            return None;
118        }
119
120        match trivia.kind {
121            TriviaKind::DocBlockComment => {
122                // Found a docblock with no code between it and the node.
123                return Some(trivia);
124            }
125            TriviaKind::WhiteSpace
126            | TriviaKind::SingleLineComment
127            | TriviaKind::MultiLineComment
128            | TriviaKind::HashComment => {
129                covered_from = trivia.span.start_offset();
130            }
131        }
132    }
133
134    // Iterated through all preceding trivia without finding a suitable docblock.
135    None
136}
137
138#[cfg(test)]
139#[allow(clippy::unwrap_used)]
140mod tests {
141    use bumpalo::Bump;
142    use mago_database::file::FileId;
143    use mago_span::HasSpan;
144
145    use crate::parser::parse_file_content;
146
147    use super::get_docblock_before_position;
148
149    #[test]
150    fn whitespace_between_docblock_and_class_is_trivia() {
151        // The parser emits WhiteSpace trivia for all whitespace, so there is no
152        // code gap between the docblock's end offset and the class's start offset.
153        // This verifies the assumption that strict trivia contiguity == no code gap.
154        let arena = Bump::new();
155        let program = parse_file_content(&arena, FileId::zero(), b"<?php\n\n/** @return int */\n\nclass Foo {}");
156        // statements[0] is the <?php opening tag; statements[1] is the class.
157        let class_start = program.statements.iter().nth(1).unwrap().span().start.offset;
158        let docblock = get_docblock_before_position(program.trivia.as_slice(), class_start);
159        assert!(docblock.is_some(), "expected docblock to be found across whitespace");
160        assert!(memchr::memmem::find(docblock.unwrap().value, b"@return int").is_some());
161    }
162
163    #[test]
164    fn code_between_docblock_and_function_blocks_attribution() {
165        let arena = Bump::new();
166        let program =
167            parse_file_content(&arena, FileId::zero(), b"<?php\n/** @return int */\necho 1;\nfunction foo() {}");
168        // statements: [0]=OpeningTag, [1]=Echo, [2]=Function
169        let func_start = program.statements.iter().nth(2).unwrap().span().start.offset;
170        let docblock = get_docblock_before_position(program.trivia.as_slice(), func_start);
171        assert!(docblock.is_none(), "expected no docblock when code intervenes");
172    }
173}