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 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
39struct CleanLine {
44 text: String,
45 base_offset: u32,
47}
48
49fn 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
65fn 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 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
111fn 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
168fn 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#[derive(Clone, Copy)]
294enum BlankLinePolicy {
295 Insignificant,
298 ParagraphBreak,
301}
302
303struct PhpDocTextBuilder {
310 segments: Vec<TextSegment>,
311 span_start: Option<u32>,
312 span_end: u32,
313 blank_policy: BlankLinePolicy,
314 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 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
370fn 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
390fn 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
401fn 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
413fn 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
423fn 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; 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; }
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}