1use crate::ast::{InlineTag, PhpDoc, PhpDocTag, PhpDocText, TextSegment};
11use crate::Span;
12
13pub 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 parse_tags(&lines[tag_start..])
27 } else {
28 Vec::new()
29 };
30 PhpDoc {
31 summary,
32 description,
33 tags,
34 span,
35 }
36}
37
38struct CleanLine {
43 text: String,
44 base_offset: u32,
46}
47
48fn strip_delimiters(text: &str) -> (&str, u32) {
53 let (s, start) = if let Some(rest) = text.strip_prefix("/**") {
54 (rest, 3u32)
55 } else if let Some(rest) = text.strip_prefix("/*") {
56 (rest, 2u32)
57 } else {
58 (text, 0u32)
59 };
60 let s = s.strip_suffix("*/").unwrap_or(s);
61 (s, start)
62}
63
64fn clean_lines(inner: &str, content_start: u32) -> Vec<CleanLine> {
69 let mut lines = Vec::new();
70 let mut offset_in_inner: u32 = 0;
71
72 for raw_line in inner.split('\n') {
73 let line_abs_start = content_start + offset_in_inner;
74
75 let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
79 let bytes = line.as_bytes();
80
81 let mut stripped_bytes: u32 = 0;
82
83 let ws_count = bytes
84 .iter()
85 .take_while(|&&b| b == b' ' || b == b'\t')
86 .count();
87 stripped_bytes += ws_count as u32;
88 let after_ws = &line[ws_count..];
89
90 let (cleaned, extra_stripped) = if let Some(rest) = after_ws.strip_prefix("* ") {
91 (rest, 2u32)
92 } else if let Some(rest) = after_ws.strip_prefix('*') {
93 (rest, 1u32)
94 } else {
95 (after_ws, 0u32)
96 };
97 stripped_bytes += extra_stripped;
98
99 lines.push(CleanLine {
100 text: cleaned.to_owned(),
101 base_offset: line_abs_start + stripped_bytes,
102 });
103
104 offset_in_inner += raw_line.len() as u32 + 1;
105 }
106
107 lines
108}
109
110fn extract_prose(lines: &[CleanLine]) -> (Option<PhpDocText>, Option<PhpDocText>, usize) {
115 let tag_start = lines
116 .iter()
117 .position(|l| l.text.trim_start().starts_with('@'))
118 .unwrap_or(lines.len());
119
120 let prose_lines = &lines[..tag_start];
121
122 let Some(start) = prose_lines.iter().position(|l| !l.text.trim().is_empty()) else {
123 return (None, None, tag_start);
124 };
125
126 let summary = {
127 let line = &prose_lines[start];
128 let trimmed = line.text.trim();
129 if trimmed.is_empty() {
130 None
131 } else {
132 let leading = (line.text.len() - line.text.trim_start().len()) as u32;
133 Some(text_from_str(trimmed, line.base_offset + leading))
134 }
135 };
136
137 let blank_after_summary = prose_lines[start..]
138 .iter()
139 .position(|l| l.text.trim().is_empty())
140 .map(|i| i + start);
141
142 let description = if let Some(blank) = blank_after_summary {
143 let desc_start = prose_lines[blank..]
144 .iter()
145 .position(|l| !l.text.trim().is_empty())
146 .map(|i| i + blank);
147
148 if let Some(ds) = desc_start {
149 let desc_end = prose_lines
150 .iter()
151 .rposition(|l| !l.text.trim().is_empty())
152 .map(|i| i + 1)
153 .unwrap_or(ds);
154
155 let slice: Vec<&CleanLine> = prose_lines[ds..desc_end].iter().collect();
156 description_to_text(&slice)
157 } else {
158 None
159 }
160 } else {
161 None
162 };
163
164 (summary, description, tag_start)
165}
166
167fn parse_tags(lines: &[CleanLine]) -> Vec<PhpDocTag> {
172 let mut tags = Vec::new();
173 let mut i = 0;
174
175 while i < lines.len() {
176 let line_text = lines[i].text.trim_start();
177 if !line_text.starts_with('@') {
178 i += 1;
179 continue;
180 }
181
182 let tag_start_offset = lines[i].base_offset;
183
184 let mut tag_lines: Vec<&CleanLine> = vec![&lines[i]];
185 i += 1;
186 while i < lines.len() && !lines[i].text.trim_start().starts_with('@') {
187 tag_lines.push(&lines[i]);
188 i += 1;
189 }
190
191 let last = tag_lines.last().unwrap();
192 let tag_end_offset = last.base_offset + last.text.len() as u32;
193 let tag_span = Span::new(tag_start_offset, tag_end_offset);
194
195 let first = tag_lines[0]
196 .text
197 .trim_start()
198 .strip_prefix('@')
199 .unwrap_or("");
200
201 let (tag_name, body_on_first) = match first.find(|c: char| c.is_whitespace()) {
202 Some(pos) => {
203 let body = first[pos..].trim();
204 (
205 &first[..pos],
206 if body.is_empty() { None } else { Some(body) },
207 )
208 }
209 None => (first, None),
210 };
211
212 let body_base_offset = {
213 let after_at = &tag_lines[0].text.trim_start()[1 + tag_name.len()..];
214 let ws = (after_at.len() - after_at.trim_start().len()) as u32;
215 tag_lines[0].base_offset + 1 + tag_name.len() as u32 + ws
216 };
217
218 let first_piece = body_on_first.map(|t| (t, body_base_offset));
219 let body = tag_body_to_text(first_piece, &tag_lines[1..]);
220
221 tags.push(PhpDocTag {
222 name: tag_name.to_owned(),
223 body,
224 span: tag_span,
225 });
226 }
227
228 tags
229}
230
231#[derive(Clone, Copy)]
237enum BlankLinePolicy {
238 Insignificant,
241 ParagraphBreak,
244}
245
246struct PhpDocTextBuilder {
253 segments: Vec<TextSegment>,
254 span_start: Option<u32>,
255 span_end: u32,
256 blank_policy: BlankLinePolicy,
257 started: bool,
260}
261
262impl PhpDocTextBuilder {
263 fn new(blank_policy: BlankLinePolicy) -> Self {
264 Self {
265 segments: Vec::new(),
266 span_start: None,
267 span_end: 0,
268 blank_policy,
269 started: false,
270 }
271 }
272
273 fn push_line(&mut self, text: &str, base: u32) {
276 let trimmed = text.trim();
277
278 if trimmed.is_empty() {
279 if let BlankLinePolicy::ParagraphBreak = self.blank_policy {
280 if self.started {
281 push_text(&mut self.segments, "\n");
282 }
283 self.started = true;
284 }
285 return;
286 }
287
288 if self.started {
289 push_text(&mut self.segments, "\n");
290 }
291 self.started = true;
292
293 let leading = (text.len() - text.trim_start().len()) as u32;
294 let content_offset = base + leading;
295 if self.span_start.is_none() {
296 self.span_start = Some(content_offset);
297 }
298 self.span_end = content_offset + trimmed.len() as u32;
299 merge_into(
300 &mut self.segments,
301 text_from_str(trimmed, content_offset).segments,
302 );
303 }
304
305 fn build(self) -> Option<PhpDocText> {
306 self.span_start.map(|start| PhpDocText {
307 segments: self.segments,
308 span: Span::new(start, self.span_end),
309 })
310 }
311}
312
313fn tag_body_to_text(
320 first_piece: Option<(&str, u32)>,
321 continuation: &[&CleanLine],
322) -> Option<PhpDocText> {
323 let mut builder = PhpDocTextBuilder::new(BlankLinePolicy::Insignificant);
324 if let Some((text, base)) = first_piece {
325 builder.push_line(text, base);
326 }
327 for line in continuation {
328 builder.push_line(&line.text, line.base_offset);
329 }
330 builder.build()
331}
332
333fn description_to_text(lines: &[&CleanLine]) -> Option<PhpDocText> {
337 let mut builder = PhpDocTextBuilder::new(BlankLinePolicy::ParagraphBreak);
338 for line in lines {
339 builder.push_line(&line.text, line.base_offset);
340 }
341 builder.build()
342}
343
344fn push_text(segments: &mut Vec<TextSegment>, text: &str) {
346 if text.is_empty() {
347 return;
348 }
349 if let Some(TextSegment::Text(last)) = segments.last_mut() {
350 last.push_str(text);
351 } else {
352 segments.push(TextSegment::Text(text.to_owned()));
353 }
354}
355
356fn merge_into(dest: &mut Vec<TextSegment>, src: Vec<TextSegment>) {
358 for seg in src {
359 match seg {
360 TextSegment::Text(t) => push_text(dest, &t),
361 other => dest.push(other),
362 }
363 }
364}
365
366fn text_from_str(s: &str, base_offset: u32) -> PhpDocText {
372 let mut segments = Vec::new();
373 let bytes = s.as_bytes();
374 let mut i = 0;
375 let mut text_start = 0;
376
377 while i < bytes.len() {
378 if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'@') {
379 if i > text_start {
380 segments.push(TextSegment::Text(s[text_start..i].to_owned()));
381 }
382
383 let tag_abs_start = i;
384 i += 2; let name_start = i;
387 while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'}' {
388 i += 1;
389 }
390 let name = s[name_start..i].to_owned();
391
392 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
393 i += 1;
394 }
395
396 let body_start = i;
397 let mut depth = 1i32;
398 while i < bytes.len() {
399 match bytes[i] {
400 b'{' => {
401 depth += 1;
402 i += 1;
403 }
404 b'}' if depth == 1 => break,
405 b'}' => {
406 depth -= 1;
407 i += 1;
408 }
409 _ => {
410 i += 1;
411 }
412 }
413 }
414
415 let body_raw = s[body_start..i].trim();
416 let body = if body_raw.is_empty() {
417 None
418 } else {
419 Some(body_raw.to_owned())
420 };
421
422 if i < bytes.len() {
423 i += 1; }
425
426 segments.push(TextSegment::InlineTag(InlineTag {
427 name,
428 body,
429 span: Span::new(base_offset + tag_abs_start as u32, base_offset + i as u32),
430 }));
431
432 text_start = i;
433 } else {
434 i += 1;
435 }
436 }
437
438 if text_start < s.len() {
439 segments.push(TextSegment::Text(s[text_start..].to_owned()));
440 }
441
442 PhpDocText {
443 segments,
444 span: Span::new(base_offset, base_offset + s.len() as u32),
445 }
446}