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
39#[derive(Clone, Copy)]
46struct CleanLine<'a> {
47 text: &'a str,
48 base_offset: u32,
50 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 fn trimmed_start(&self) -> &'a str {
66 &self.text[self.lead as usize..]
67 }
68
69 fn is_blank(&self) -> bool {
71 self.lead as usize == self.text.len()
72 }
73
74 fn starts_tag(&self) -> bool {
76 self.trimmed_start().starts_with('@')
77 }
78}
79
80fn 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
96fn 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 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
139fn 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
194fn 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#[derive(Clone, Copy)]
305enum BlankLinePolicy {
306 Insignificant,
309 ParagraphBreak,
312}
313
314struct PhpDocTextBuilder {
321 segments: Vec<TextSegment>,
322 span_start: Option<u32>,
323 span_end: u32,
324 blank_policy: BlankLinePolicy,
325 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 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
378fn 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
398fn 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
409fn 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
421fn 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
435fn 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; 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; }
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}