Skip to main content

mago_docblock/
tag.rs

1use crate::error::ParseError;
2use mago_span::Span;
3use serde::Deserialize;
4use serde::Serialize;
5use std::fmt;
6
7#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
8pub struct Variable {
9    pub name: String,          // normalized: includes `$`, excludes `...` and `&`
10    pub is_variadic: bool,     // true if `...` was present
11    pub is_by_reference: bool, // true if `&` was present
12}
13
14#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
15pub enum Visibility {
16    Public,
17    Protected,
18    Private,
19}
20
21#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
22pub struct Method {
23    pub visibility: Visibility,
24    pub is_static: bool,
25    pub name: String,
26    pub argument_list: Vec<Argument>,
27}
28
29#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
30pub struct Argument {
31    pub type_hint: Option<TypeString>,
32    pub variable: Variable,
33    pub has_default: bool,
34    pub argument_span: Span,
35    pub variable_span: Span,
36}
37
38#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
39pub struct PropertyTag {
40    pub span: Span,
41    pub type_string: Option<TypeString>,
42    pub variable: Variable,
43    pub is_read: bool,
44    pub is_write: bool,
45}
46
47impl fmt::Display for Variable {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        if self.is_by_reference {
50            f.write_str("&")?;
51        }
52        if self.is_variadic {
53            f.write_str("...")?;
54        }
55        f.write_str(&self.name)
56    }
57}
58
59impl fmt::Display for Method {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.write_str(&self.name)
62    }
63}
64
65#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
66pub struct TypeString {
67    pub value: String,
68    pub span: Span,
69}
70
71#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
72pub struct ReturnTypeTag {
73    pub span: Span,
74    pub type_string: TypeString,
75    pub description: String,
76}
77
78#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
79pub struct TypeTag {
80    pub span: Span,
81    pub name: String,
82    pub type_string: TypeString,
83}
84
85#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
86pub struct ImportTypeTag {
87    pub span: Span,
88    pub name: String,
89    pub from: String,
90    pub alias: Option<String>,
91}
92
93#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
94pub struct ParameterTag {
95    pub span: Span,
96    pub variable: Variable,
97    pub type_string: Option<TypeString>,
98    pub description: String,
99}
100
101#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
102pub struct ParameterOutTag {
103    pub span: Span,
104    pub variable: Variable,
105    pub type_string: TypeString,
106}
107
108#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
109pub struct ThrowsTag {
110    pub span: Span,
111    pub type_string: TypeString,
112    pub description: String,
113}
114
115#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
116#[repr(u8)]
117pub enum TemplateModifier {
118    Of,
119    As,
120    Super,
121}
122
123#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
124pub struct TemplateTag {
125    /// The full span of the original content parsed (e.g., "T as Foo").
126    pub span: Span,
127    /// The name of the template parameter (e.g., "T").
128    pub name: String,
129    /// The optional modifier (`as`, `of`, `super`).
130    pub modifier: Option<TemplateModifier>,
131    /// The optional constraint type string following the modifier, with its span.
132    pub type_string: Option<TypeString>,
133    /// Whether the template was declared as covariant (`@template-covariant`).
134    pub covariant: bool,
135    /// Whether the template was declared as contravariant (`@template-contravariant`).
136    pub contravariant: bool,
137}
138
139#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
140#[repr(u8)]
141pub enum WhereModifier {
142    Is,
143    Colon,
144}
145
146#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
147pub struct WhereTag {
148    /// The full span of the original content parsed (e.g., "T is Foo").
149    pub span: Span,
150    /// The name of the template parameter (e.g., "T").
151    pub name: String,
152    /// The modifier (`is`, `:`).
153    pub modifier: WhereModifier,
154    /// The constraint type string following the modifier, with its span.
155    pub type_string: TypeString,
156}
157
158#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
159pub struct AssertionTag {
160    pub span: Span,
161    pub type_string: TypeString,
162    pub variable: Variable,
163}
164
165#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
166pub struct VarTag {
167    pub span: Span,
168    pub type_string: TypeString,
169    pub variable: Option<Variable>,
170}
171
172#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
173pub struct MethodTag {
174    pub span: Span,
175    pub method: Method,
176    pub type_string: TypeString,
177    pub description: String,
178}
179/// Parses a `PHPDoc` variable token and returns a structured `Variable`.
180///
181/// If `allow_property_access` is false:
182/// - Supports `$name`, `...$name`, and `&$name`.
183///
184/// If `allow_property_access` is true:
185/// - Supports `$name` with optional property/array access like `$foo->bar` or `$foo['key']`
186/// - Can be recursive: `$foo->bar->baz['key']->qux`
187/// - Does NOT support `...` (variadic) or `&` (reference) prefixes
188///
189/// The returned `Variable` stores a normalized `name` (with `$`, without leading `...` or `&`),
190/// and sets flags `is_variadic` and `is_by_reference` that can be used for display/rendering.
191///
192/// Examples (`allow_property_access` = false):
193/// - "$foo"       → Some(Variable { name: "$foo", `is_variadic`: false, `is_by_reference`: false })
194/// - "&$foo"      → Some(Variable { name: "$foo", `is_variadic`: false, `is_by_reference`: true })
195/// - "...$ids"    → Some(Variable { name: "$ids", `is_variadic`: true, `is_by_reference`: false })
196/// - "$"          → None
197/// - "...$"       → None
198/// - "$1x"        → None
199///
200/// Examples (`allow_property_access` = true):
201/// - "$foo->bar"     → Some(Variable { name: "$foo->bar", `is_variadic`: false, `is_by_reference`: false })
202/// - "$foo['key']"   → Some(Variable { name: "$foo['key']", `is_variadic`: false, `is_by_reference`: false })
203/// - "$foo->bar->baz['key']" → Some(Variable { name: "$foo->bar->baz['key']", `is_variadic`: false, `is_by_reference`: false })
204/// - "&$foo->bar"    → None (reference not allowed with property access)
205/// - "...$foo->bar"  → None (variadic not allowed with property access)
206#[inline]
207fn parse_var_ident(raw: &str, allow_property_access: bool) -> Option<Variable> {
208    if allow_property_access {
209        // When property access is allowed, we don't support & or ...
210        if raw.starts_with('&') || raw.starts_with("...") {
211            return None;
212        }
213
214        // Must start with $
215        if !raw.starts_with('$') {
216            return None;
217        }
218
219        let rest = &raw[1..]; // Skip the $
220        let bytes = rest.as_bytes();
221
222        if bytes.is_empty() {
223            return None;
224        }
225
226        // Parse the initial identifier
227        let is_start = |b: u8| b == b'_' || b.is_ascii_alphabetic();
228        let is_cont = |b: u8| is_start(b) || b.is_ascii_digit();
229
230        if !is_start(bytes[0]) {
231            return None;
232        }
233
234        let mut pos = 1;
235        while pos < bytes.len() && is_cont(bytes[pos]) {
236            pos += 1;
237        }
238
239        // Now parse any property/array access chains
240        while pos < bytes.len() {
241            if pos + 1 < bytes.len() && &bytes[pos..pos + 2] == b"->" {
242                // Object property access: ->identifier
243                pos += 2; // Skip ->
244
245                if pos >= bytes.len() || !is_start(bytes[pos]) {
246                    return None; // Invalid: -> must be followed by valid identifier
247                }
248
249                pos += 1;
250                while pos < bytes.len() && is_cont(bytes[pos]) {
251                    pos += 1;
252                }
253            } else if bytes[pos] == b'[' {
254                // Array access: [...]
255                pos += 1; // Skip [
256                let mut bracket_depth = 1;
257
258                while pos < bytes.len() && bracket_depth > 0 {
259                    if bytes[pos] == b'[' {
260                        bracket_depth += 1;
261                    } else if bytes[pos] == b']' {
262                        bracket_depth -= 1;
263                    }
264                    pos += 1;
265                }
266
267                if bracket_depth != 0 {
268                    return None; // Unmatched brackets
269                }
270            } else {
271                // End of valid property access chain
272                break;
273            }
274        }
275
276        // The full token should be consumed for a valid property access chain
277        let token = &raw[..=pos]; // Include the initial $
278
279        Some(Variable { name: token.to_owned(), is_variadic: false, is_by_reference: false })
280    } else {
281        // Original logic for when property access is not allowed
282        let is_by_reference = raw.starts_with('&');
283        // tolerate "&$x" in docblocks
284        let raw = raw.strip_prefix('&').unwrap_or(raw);
285        // accept "$name" or "...$name"
286        let (prefix_len, rest, is_variadic) = if let Some(r) = raw.strip_prefix("...$") {
287            (4usize, r, true)
288        } else if let Some(r) = raw.strip_prefix('$') {
289            (1usize, r, false)
290        } else {
291            return None;
292        };
293        // PHP identifier rules (ASCII + underscore): [_A-Za-z][_A-Za-z0-9]*
294        let bytes = rest.as_bytes();
295        if bytes.is_empty() {
296            return None;
297        }
298        let is_start = |b: u8| b == b'_' || b.is_ascii_alphabetic();
299        let is_cont = |b: u8| is_start(b) || b.is_ascii_digit();
300        if !is_start(bytes[0]) {
301            return None;
302        }
303        let mut len = 1usize;
304        while len < bytes.len() && is_cont(bytes[len]) {
305            len += 1;
306        }
307        let token = &raw[..prefix_len + len];
308        // normalized: remove variadic prefix if present, keep `$`
309        let normalized = if is_variadic { &token[3..] } else { token };
310        Some(Variable { name: normalized.to_owned(), is_variadic, is_by_reference })
311    }
312}
313
314/// Parses the content string of a `@template` or `@template-covariant` tag.
315///
316/// Extracts the template name, an optional modifier (`as`, `of`, `super`),
317/// and an optional constraint type following the modifier.
318///
319/// Examples:
320///
321/// - "T" -> name="T", modifier=None, type=None
322/// - "T of U" -> name="T", modifier=Of, type="U"
323/// - "T as string" -> name="T", modifier=As, type="string"
324/// - "T super \\My\\Class" -> name="T", modifier=Super, type="\\My\\Class"
325/// - "T string" -> name="T", modifier=None, type=None (ignores "string")
326/// - "T of" -> name="T", modifier=Of, type=None
327///
328/// # Arguments
329///
330/// * `content` - The string slice content following `@template` or `@template-covariant`.
331/// * `span` - The original `Span` of the `content` slice within its source file.
332/// * `covariant` - `true` if the tag was `@template-covariant`.
333/// * `contravariant` - `true` if the tag was `@template-contravariant`.
334///
335/// # Errors
336///
337/// Returns a [`ParseError`] if the template tag syntax is invalid.
338#[inline]
339pub fn parse_template_tag(
340    content: &str,
341    span: Span,
342    mut covariant: bool,
343    mut contravariant: bool,
344) -> Result<TemplateTag, ParseError> {
345    // Find start offset of trimmed content relative to original `content`
346    let trim_start_offset_rel = content.find(|c: char| !c.is_whitespace()).unwrap_or(0);
347    let trimmed_content = content.trim();
348
349    if trimmed_content.is_empty() {
350        return Err(ParseError::InvalidTemplateTag(span, "Expected template parameter name".to_string()));
351    }
352
353    let mut parts = trimmed_content.split_whitespace();
354
355    let mut name_part = parts
356        .next()
357        .ok_or_else(|| ParseError::InvalidTemplateTag(span, "Expected template parameter name".to_string()))?;
358    if name_part.starts_with('+') && !contravariant && !covariant {
359        covariant = true;
360        name_part = &name_part[1..];
361    } else if name_part.starts_with('-') && !contravariant && !covariant {
362        contravariant = true;
363        name_part = &name_part[1..];
364    }
365
366    let name = name_part.to_string();
367
368    let mut modifier: Option<TemplateModifier> = None;
369    let mut type_string_opt: Option<TypeString> = None;
370
371    // Track current position relative to the start of the *original* content string
372    // Start after the name part
373    let mut current_offset_rel = trim_start_offset_rel + name_part.len();
374
375    // 2. Check for optional modifier
376    // Need to peek into the *original* content slice to find the next non-whitespace char
377    let remaining_after_name = content.get(current_offset_rel..).unwrap_or("");
378    let whitespace_len1 = remaining_after_name.find(|c: char| !c.is_whitespace()).unwrap_or(0);
379    let after_whitespace1_offset_rel = current_offset_rel + whitespace_len1;
380    let potential_modifier_slice = remaining_after_name.trim_start();
381
382    if !potential_modifier_slice.is_empty() {
383        let mut modifier_parts = potential_modifier_slice.split_whitespace().peekable();
384        if let Some(potential_modifier_str) = modifier_parts.peek().copied() {
385            let modifier_val = match potential_modifier_str.to_ascii_lowercase().as_str() {
386                "as" => Some(TemplateModifier::As),
387                "of" => Some(TemplateModifier::Of),
388                "super" => Some(TemplateModifier::Super),
389                _ => None,
390            };
391
392            if modifier_val.is_some() {
393                modifier = modifier_val;
394                modifier_parts.next();
395                current_offset_rel = after_whitespace1_offset_rel + potential_modifier_str.len();
396
397                // 3. If modifier found, look for the type string part
398                let remaining_after_modifier = content.get(current_offset_rel..).unwrap_or("");
399                if let Some((type_string, _)) =
400                    split_tag_content(remaining_after_modifier, span.subspan(current_offset_rel as u32, 0))
401                {
402                    type_string_opt = Some(type_string);
403                }
404            }
405        }
406    }
407
408    Ok(TemplateTag { span, name, modifier, type_string: type_string_opt, covariant, contravariant })
409}
410
411/// Parses the content string of a `@where` tag.
412///
413/// # Arguments
414///
415/// * `content` - The string slice content following `@where`.
416/// * `span` - The original `Span` of the `content` slice.
417///
418/// # Errors
419///
420/// Returns a [`ParseError`] if the where tag syntax is invalid.
421pub fn parse_where_tag(content: &str, span: Span) -> Result<WhereTag, ParseError> {
422    let name_end_pos = content.find(char::is_whitespace).ok_or_else(|| {
423        ParseError::InvalidWhereTag(span, "Expected template parameter name and constraint".to_string())
424    })?;
425    let (name_part, mut rest) = content.split_at(name_end_pos);
426
427    if !is_valid_identifier_start(name_part, false) {
428        return Err(ParseError::InvalidWhereTag(span, format!("Invalid template parameter name: '{name_part}'")));
429    }
430
431    rest = rest.trim_start();
432    let modifier = if rest.starts_with("is") && rest.chars().nth(2).is_some_and(char::is_whitespace) {
433        rest = &rest[2..];
434        WhereModifier::Is
435    } else if rest.starts_with(':') {
436        rest = &rest[1..];
437        WhereModifier::Colon
438    } else {
439        return Err(ParseError::InvalidWhereTag(
440            span,
441            "Expected 'is' or ':' after template parameter name".to_string(),
442        ));
443    };
444
445    let consumed_len = content.len() - rest.len();
446    let type_part_start_pos = span.start.forward(consumed_len as u32);
447    let type_part_span = Span::new(span.file_id, type_part_start_pos, span.end);
448
449    let (type_string, _rest) = split_tag_content(rest, type_part_span)
450        .ok_or_else(|| ParseError::InvalidWhereTag(span, "Failed to parse type constraint".to_string()))?;
451
452    Ok(WhereTag { span, name: name_part.to_owned(), modifier, type_string })
453}
454
455/// Parses the content string of a `@param` tag.
456///
457/// # Arguments
458///
459/// * `content` - The string slice content following `@param`.
460/// * `span` - The original `Span` of the `content` slice.
461///
462/// # Errors
463///
464/// Returns a [`ParseError`] if the param tag syntax is invalid.
465pub fn parse_param_tag(content: &str, span: Span) -> Result<ParameterTag, ParseError> {
466    let trimmed = content.trim_start();
467
468    // Check if content starts with a variable (no type specified)
469    if trimmed.starts_with('$') {
470        // No type specified, just parse variable and description
471        let mut parts = trimmed.split_whitespace();
472        let raw_name =
473            parts.next().ok_or_else(|| ParseError::InvalidParameterTag(span, "Expected parameter name".to_string()))?;
474
475        let variable = parse_var_ident(raw_name, false)
476            .ok_or_else(|| ParseError::InvalidParameterTag(span, format!("Invalid parameter name: '{raw_name}'")))?;
477
478        let desc_start = trimmed.find(&variable.name).map_or(0, |i| i + variable.name.len());
479        let description = trimmed[desc_start..].trim().to_owned();
480
481        return Ok(ParameterTag { span, variable, type_string: None, description });
482    }
483
484    // Type is specified, parse it
485    let (type_string, rest_slice) = split_tag_content(content, span)
486        .ok_or_else(|| ParseError::InvalidParameterTag(span, "Failed to parse parameter type".to_string()))?;
487
488    // Type must be valid (not empty, not starting with { or $)
489    if type_string.value.is_empty()
490        || type_string.value.starts_with('{')
491        || (type_string.value.starts_with('$') && type_string.value != "$this")
492    {
493        return Err(ParseError::InvalidParameterTag(span, format!("Invalid parameter type: '{}'", type_string.value)));
494    }
495
496    if rest_slice.is_empty() {
497        // Variable name is mandatory
498        return Err(ParseError::InvalidParameterTag(span, "Missing parameter name".to_string()));
499    }
500
501    let mut rest_parts = rest_slice.split_whitespace();
502    let raw_name = rest_parts
503        .next()
504        .ok_or_else(|| ParseError::InvalidParameterTag(span, "Expected parameter name".to_string()))?;
505    let variable = parse_var_ident(raw_name, false)
506        .ok_or_else(|| ParseError::InvalidParameterTag(span, format!("Invalid parameter name: '{raw_name}'")))?;
507
508    let desc_start = rest_slice.find(&variable.name).map_or(0, |i| i + variable.name.len());
509    let description = rest_slice[desc_start..].trim_start().to_owned();
510
511    Ok(ParameterTag { span, variable, type_string: Some(type_string), description })
512}
513
514/// Parses the content string of a `@param-out` tag.
515///
516/// # Arguments
517///
518/// * `content` - The string slice content following `@param-out`.
519/// * `span` - The original `Span` of the `content` slice.
520///
521/// # Errors
522///
523/// Returns a [`ParseError`] if the param-out tag syntax is invalid.
524pub fn parse_param_out_tag(content: &str, span: Span) -> Result<ParameterOutTag, ParseError> {
525    let (type_string, rest_slice) = split_tag_content(content, span)
526        .ok_or_else(|| ParseError::InvalidParameterOutTag(span, "Failed to parse parameter type".to_string()))?;
527
528    // Type must exist and be valid
529    if type_string.value.is_empty()
530        || type_string.value.starts_with('{')
531        || (type_string.value.starts_with('$') && type_string.value != "$this")
532    {
533        return Err(ParseError::InvalidParameterOutTag(
534            span,
535            format!("Invalid parameter type: '{}'", type_string.value),
536        ));
537    }
538
539    if rest_slice.is_empty() {
540        return Err(ParseError::InvalidParameterOutTag(span, "Missing parameter name".to_string()));
541    }
542
543    let raw_name = rest_slice
544        .split_whitespace()
545        .next()
546        .ok_or_else(|| ParseError::InvalidParameterOutTag(span, "Expected parameter name".to_string()))?;
547    let variable = parse_var_ident(raw_name, false)
548        .ok_or_else(|| ParseError::InvalidParameterOutTag(span, format!("Invalid parameter name: '{raw_name}'")))?;
549
550    Ok(ParameterOutTag { span, variable, type_string })
551}
552
553/// Parses the content string of a `@return` tag.
554///
555/// # Arguments
556///
557/// * `content` - The string slice content following `@return`.
558/// * `span` - The original `Span` of the `content` slice.
559///
560/// # Errors
561///
562/// Returns a [`ParseError`] if the return tag syntax is invalid.
563pub fn parse_return_tag(content: &str, span: Span) -> Result<ReturnTypeTag, ParseError> {
564    let (type_string, rest_slice) = split_tag_content(content, span)
565        .ok_or_else(|| ParseError::InvalidReturnTag(span, "Failed to parse return type".to_string()))?;
566
567    // Type cannot start with '{'
568    if type_string.value.starts_with('{') {
569        return Err(ParseError::InvalidReturnTag(span, format!("Invalid return type: '{}'", type_string.value)));
570    }
571
572    let description = rest_slice.to_owned();
573
574    Ok(ReturnTypeTag { span, type_string, description })
575}
576
577/// Parses the content string of a `@throws` tag.
578///
579/// # Arguments
580///
581/// * `content` - The string slice content following `@throws`.
582/// * `span` - The original `Span` of the `content` slice.
583///
584/// # Errors
585///
586/// Returns a [`ParseError`] if the throws tag syntax is invalid.
587pub fn parse_throws_tag(content: &str, span: Span) -> Result<ThrowsTag, ParseError> {
588    let (type_string, rest_slice) = split_tag_content(content, span)
589        .ok_or_else(|| ParseError::InvalidThrowsTag(span, "Failed to parse exception type".to_string()))?;
590
591    // Type cannot start with '{'
592    if type_string.value.starts_with('{') {
593        return Err(ParseError::InvalidThrowsTag(span, format!("Invalid exception type: '{}'", type_string.value)));
594    }
595
596    // Type cannot start with '$' unless it is "$this"
597    if type_string.value.starts_with('$') && type_string.value != "$this" {
598        return Err(ParseError::InvalidThrowsTag(span, format!("Invalid exception type: '{}'", type_string.value)));
599    }
600
601    let description = rest_slice.to_owned();
602
603    Ok(ThrowsTag { span, type_string, description })
604}
605
606/// Parses the content string of an `@assert`, `@assert-if-true`, or `@assert-if-false` tag.
607///
608/// # Arguments
609///
610/// * `content` - The string slice content following the tag.
611/// * `span` - The original `Span` of the `content` slice.
612///
613/// # Errors
614///
615/// Returns a [`ParseError`] if the assertion tag syntax is invalid.
616pub fn parse_assertion_tag(content: &str, span: Span) -> Result<AssertionTag, ParseError> {
617    let (type_string, rest_slice) = split_tag_content(content, span)
618        .ok_or_else(|| ParseError::InvalidAssertionTag(span, "Failed to parse assertion type".to_string()))?;
619
620    // Type must exist and be valid
621    if type_string.value.is_empty()
622        || type_string.value.starts_with('{')
623        || (type_string.value.starts_with('$') && type_string.value != "$this")
624    {
625        return Err(ParseError::InvalidAssertionTag(span, format!("Invalid assertion type: '{}'", type_string.value)));
626    }
627
628    if rest_slice.is_empty() {
629        // Variable name is mandatory
630        return Err(ParseError::InvalidAssertionTag(span, "Missing variable name".to_string()));
631    }
632
633    let mut rest_parts = rest_slice.split_whitespace();
634
635    let raw_name =
636        rest_parts.next().ok_or_else(|| ParseError::InvalidAssertionTag(span, "Expected variable name".to_string()))?;
637    let variable = parse_var_ident(raw_name, true)
638        .ok_or_else(|| ParseError::InvalidAssertionTag(span, format!("Invalid variable name: '{raw_name}'")))?;
639
640    Ok(AssertionTag { span, type_string, variable })
641}
642
643/// Parses the content string of a `@var` tag.
644///
645/// # Arguments
646///
647/// * `content` - The string slice content following the tag.
648/// * `span` - The original `Span` of the `content` slice.
649///
650/// # Errors
651///
652/// Returns a [`ParseError`] if the var tag syntax is invalid.
653pub fn parse_var_tag(content: &str, span: Span) -> Result<VarTag, ParseError> {
654    let (type_string, rest_slice) = split_tag_content(content, span)
655        .ok_or_else(|| ParseError::InvalidVarTag(span, "Failed to parse variable type".to_string()))?;
656
657    // Type must exist and be valid
658    if type_string.value.is_empty()
659        || type_string.value.starts_with('{')
660        || (type_string.value.starts_with('$') && type_string.value != "$this")
661    {
662        return Err(ParseError::InvalidVarTag(span, format!("Invalid variable type: '{}'", type_string.value)));
663    }
664
665    let variable = if rest_slice.is_empty() {
666        None
667    } else {
668        let var_part = rest_slice
669            .split_whitespace()
670            .next()
671            .ok_or_else(|| ParseError::InvalidVarTag(span, "Expected variable name".to_string()))?;
672        parse_var_ident(var_part, true)
673    };
674
675    Ok(VarTag { span, type_string, variable })
676}
677
678/// Parses the content string of a `@type` tag.
679///
680/// # Arguments
681///
682/// * `content` - The string slice content following the tag.
683/// * `span` - The original `Span` of the `content` slice.
684///
685/// # Errors
686///
687/// Returns a [`ParseError`] if the type tag syntax is invalid.
688pub fn parse_type_tag(content: &str, span: Span) -> Result<TypeTag, ParseError> {
689    let leading_ws = (content.len() - content.trim_start().len()) as u32;
690    let content = content.trim_start();
691
692    if content.is_empty() {
693        return Err(ParseError::InvalidTypeTag(span, "Type alias declaration is empty".to_string()));
694    }
695
696    let (potential_name, _) = content.split_once(char::is_whitespace).ok_or_else(|| {
697        let trimmed = content.trim();
698        ParseError::InvalidTypeTag(span, format!("Type alias name '{trimmed}' must be followed by a type definition"))
699    })?;
700
701    let name_len = potential_name.len();
702    let after_name = &content[name_len..];
703    let trimmed_after_name = after_name.trim_start();
704
705    let (name, type_part, type_offset) = if let Some(after_equals) = trimmed_after_name.strip_prefix('=') {
706        // Format: @type Name = Type
707        let name = potential_name.trim();
708
709        if !is_valid_identifier_start(name, false) {
710            return Err(ParseError::InvalidTypeTag(span, format!("Invalid type alias name: '{name}'")));
711        }
712
713        let type_start_offset = name_len + (after_name.len() - trimmed_after_name.len()) + 1;
714
715        (name, after_equals, leading_ws + type_start_offset as u32)
716    } else {
717        let name = potential_name.trim();
718
719        if !is_valid_identifier_start(name, false) {
720            return Err(ParseError::InvalidTypeTag(span, format!("Invalid type alias name: '{name}'")));
721        }
722
723        let rest = after_name.trim_start();
724        let type_start_offset = name_len + (after_name.len() - rest.len());
725
726        (name, rest, leading_ws + type_start_offset as u32)
727    };
728
729    let (type_string, _) = split_tag_content(type_part, span.subspan(type_offset, 0))
730        .ok_or_else(|| ParseError::InvalidTypeTag(span, "Failed to parse type definition".to_string()))?;
731
732    if type_string.value.is_empty()
733        || type_string.value.starts_with('{')
734        || (type_string.value.starts_with('$') && type_string.value != "$this")
735    {
736        return Err(ParseError::InvalidTypeTag(span, format!("Invalid type definition: '{}'", type_string.value)));
737    }
738
739    Ok(TypeTag { span, name: name.to_owned(), type_string })
740}
741
742/// Parses the content string of an `@import-type` tag.
743///
744/// # Arguments
745///
746/// * `content` - The string slice content following the tag.
747/// * `span` - The original `Span` of the `content` slice.
748///
749/// # Errors
750///
751/// Returns a [`ParseError`] if the import-type tag syntax is invalid.
752pub fn parse_import_type_tag(content: &str, span: Span) -> Result<ImportTypeTag, ParseError> {
753    let (name, rest) = content.trim_start().split_once(' ').ok_or_else(|| {
754        ParseError::InvalidImportTypeTag(span, "Expected type alias name and 'from' clause".to_string())
755    })?;
756    let name = name.trim();
757    let rest = rest.trim();
758
759    if !is_valid_identifier_start(name, false) {
760        return Err(ParseError::InvalidImportTypeTag(span, format!("Invalid type alias name: '{name}'")));
761    }
762
763    if rest.is_empty() {
764        return Err(ParseError::InvalidImportTypeTag(span, "Missing 'from' clause".to_string()));
765    }
766
767    let (from, rest) = rest.split_once(' ').ok_or_else(|| {
768        ParseError::InvalidImportTypeTag(span, "Expected 'from' keyword followed by class name".to_string())
769    })?;
770
771    if !from.eq_ignore_ascii_case("from") {
772        return Err(ParseError::InvalidImportTypeTag(span, format!("Expected 'from' keyword, found '{from}'")));
773    }
774
775    if rest.is_empty() {
776        return Err(ParseError::InvalidImportTypeTag(span, "Missing class name after 'from'".to_string()));
777    }
778
779    let (imported_from, rest) = if let Some((imp_from, rest)) = rest.split_once(' ') {
780        (imp_from.trim(), rest.trim())
781    } else {
782        (rest.trim(), "")
783    };
784
785    if !is_valid_identifier_start(imported_from, true) {
786        return Err(ParseError::InvalidImportTypeTag(span, format!("Invalid class name: '{imported_from}'")));
787    }
788
789    let mut alias = None;
790
791    if let Some((r#as, rest)) = rest.split_once(' ')
792        && r#as.trim().eq_ignore_ascii_case("as")
793        && !rest.is_empty()
794    {
795        let alias_name = rest
796            .split_whitespace()
797            .next()
798            .ok_or_else(|| ParseError::InvalidImportTypeTag(span, "Expected alias name after 'as'".to_string()))?
799            .trim()
800            .to_owned();
801        alias = Some(alias_name);
802    }
803
804    Ok(ImportTypeTag { span, name: name.to_owned(), from: imported_from.to_owned(), alias })
805}
806
807/// Parses the content string of a `@property` tag.
808///
809/// # Errors
810///
811/// Returns a [`ParseError`] if the property tag syntax is invalid.
812pub fn parse_property_tag(content: &str, span: Span, is_read: bool, is_write: bool) -> Result<PropertyTag, ParseError> {
813    // If we are at `$` and not `$this`, then no type is present:
814    let (type_string, variable) = if content.trim_start().starts_with('$') && !content.trim_start().starts_with("$this")
815    {
816        let var_part = content
817            .split_whitespace()
818            .next()
819            .ok_or_else(|| ParseError::InvalidPropertyTag(span, "Expected variable name".to_string()))?;
820        let variable = parse_var_ident(var_part, false)
821            .ok_or_else(|| ParseError::InvalidPropertyTag(span, format!("Invalid variable name: '{var_part}'")))?;
822
823        (None, variable)
824    } else {
825        let (type_string, rest_slice) = split_tag_content(content, span)
826            .ok_or_else(|| ParseError::InvalidPropertyTag(span, "Failed to parse type definition".to_string()))?;
827
828        // Type must exist and be valid
829        if type_string.value.is_empty()
830            || type_string.value.starts_with('{')
831            || (type_string.value.starts_with('$') && type_string.value != "$this")
832        {
833            return Err(ParseError::InvalidPropertyTag(
834                span,
835                format!("Invalid type definition: '{}'", type_string.value),
836            ));
837        }
838
839        if rest_slice.is_empty() {
840            return Err(ParseError::InvalidPropertyTag(span, "Missing variable name after type".to_string()));
841        }
842
843        let var_part = rest_slice
844            .split_whitespace()
845            .next()
846            .ok_or_else(|| ParseError::InvalidPropertyTag(span, "Expected variable name".to_string()))?;
847        let variable = parse_var_ident(var_part, false)
848            .ok_or_else(|| ParseError::InvalidPropertyTag(span, format!("Invalid variable name: '{var_part}'")))?;
849
850        (Some(type_string), variable)
851    };
852
853    Ok(PropertyTag { span, type_string, variable, is_read, is_write })
854}
855
856/// Splits tag content into the type string part and the rest, respecting brackets/quotes.
857/// Calculates the absolute span of the identified type string.
858///
859/// Returns None if parsing fails or input is empty.
860///
861/// Output: `Some((TypeString, rest_slice))` or `None`
862#[inline]
863#[must_use]
864pub fn split_tag_content(content: &str, input_span: Span) -> Option<(TypeString, &str)> {
865    // Find start byte offset of trimmed content relative to original `content` slice
866    let trim_start_offset = content.find(|c: char| !c.is_whitespace()).unwrap_or(0);
867    // Calculate the absolute start position of the trimmed content
868    let trimmed_start_pos = input_span.start.forward(trim_start_offset as u32);
869
870    // Get the trimmed slice reference to iterate over
871    let trimmed_content = content.trim();
872    if trimmed_content.is_empty() {
873        return None;
874    }
875
876    let mut bracket_stack: Vec<char> = Vec::with_capacity(8);
877    let mut quote_char: Option<char> = None;
878    let mut escaped = false;
879    let mut last_char_was_significant = false;
880    // Potential split point *relative to trimmed_content*
881    let mut split_point_rel: Option<usize> = None;
882
883    let mut iter = trimmed_content.char_indices().peekable();
884
885    while let Some((i, char)) = iter.next() {
886        if let Some(q) = quote_char {
887            if char == q && !escaped {
888                quote_char = None;
889            } else {
890                escaped = char == '\\' && !escaped;
891            }
892            last_char_was_significant = true;
893            continue;
894        }
895        if char == '\'' || char == '"' {
896            quote_char = Some(char);
897            last_char_was_significant = true;
898            continue;
899        }
900        match char {
901            '<' | '(' | '[' | '{' => bracket_stack.push(char),
902            '>' | ')' | ']' | '}' => {
903                match bracket_stack.pop() {
904                    Some(opening) if brackets_match(opening, char) => {}
905                    _ => return None, // Mismatch or unbalanced
906                }
907            }
908            _ => {}
909        }
910
911        // if we are at `:`, `|`, or `&` then consider it significant and consume following
912        // whitespaces, and continue processing
913        // This allows union/intersection types like `int | string` or `Foo & Bar`
914        // as well as callable return types like `callable(): int`
915        if char == ':' || char == '|' || char == '&' {
916            last_char_was_significant = true;
917            while let Some(&(_, next_char)) = iter.peek() {
918                if next_char.is_whitespace() {
919                    iter.next();
920                } else {
921                    break;
922                }
923            }
924
925            continue;
926        }
927
928        if char == '/' && iter.peek().is_some_and(|&(_, c)| c == '/') {
929            if !bracket_stack.is_empty() {
930                while let Some(&(_, next_char)) = iter.peek() {
931                    if next_char == '\n' {
932                        break;
933                    }
934
935                    iter.next();
936                }
937                last_char_was_significant = true;
938                continue;
939            }
940
941            // Split point is BEFORE the comment start
942            split_point_rel = Some(i);
943
944            // Stop processing line here, rest will be handled outside loop
945            break;
946        }
947
948        if char.is_whitespace() {
949            if bracket_stack.is_empty() && last_char_was_significant {
950                let mut temp_iter = iter.clone();
951                let mut found_continuation = false;
952
953                while let Some(&(_, next_char)) = temp_iter.peek() {
954                    if next_char.is_whitespace() {
955                        temp_iter.next();
956                    } else {
957                        found_continuation = next_char == ':' || next_char == '|' || next_char == '&';
958                        break;
959                    }
960                }
961
962                if found_continuation {
963                    while let Some(&(_, next_char)) = iter.peek() {
964                        if next_char.is_whitespace() {
965                            iter.next();
966                        } else {
967                            break;
968                        }
969                    }
970
971                    last_char_was_significant = true;
972                } else {
973                    split_point_rel = Some(i);
974                    break;
975                }
976            } else {
977                last_char_was_significant = false;
978            }
979        } else if char == '.' {
980            // Only treat '.' as a split point if it's NOT part of a numeric literal
981            // Check if this is a numeric literal by looking at surrounding chars
982            let prev_is_digit = i > 0 && trimmed_content.as_bytes()[i - 1].is_ascii_digit();
983            let next_is_digit = iter.peek().is_some_and(|&(_, c)| c.is_ascii_digit());
984
985            if prev_is_digit && next_is_digit {
986                // This is part of a numeric literal like "24.0"
987                last_char_was_significant = true;
988            } else {
989                // This is a description separator like "string[]. something"
990                if bracket_stack.is_empty() && last_char_was_significant {
991                    split_point_rel = Some(i);
992                    break;
993                }
994                last_char_was_significant = false;
995            }
996        } else {
997            last_char_was_significant = true;
998        }
999    }
1000
1001    // After loop checks
1002    if !bracket_stack.is_empty() || quote_char.is_some() {
1003        return None;
1004    }
1005
1006    if let Some(split_idx_rel) = split_point_rel {
1007        // Split occurred
1008        let type_part_slice = trimmed_content[..split_idx_rel].trim_end();
1009        let rest_part_slice = trimmed_content[split_idx_rel..].trim_start();
1010
1011        // Calculate span relative to the *start* of the trimmed content
1012        let type_span =
1013            Span::new(input_span.file_id, trimmed_start_pos, trimmed_start_pos.forward(type_part_slice.len() as u32));
1014
1015        Some((TypeString { value: type_part_slice.to_owned(), span: type_span }, rest_part_slice))
1016    } else {
1017        // No split, entire trimmed content is the type
1018        let type_part_slice = trimmed_content;
1019        let type_span =
1020            Span::new(input_span.file_id, trimmed_start_pos, trimmed_start_pos.forward(type_part_slice.len() as u32));
1021
1022        Some((TypeString { value: type_part_slice.to_owned(), span: type_span }, ""))
1023    }
1024}
1025
1026/// Parses the content string of a `@method` tag.
1027///
1028/// # Arguments
1029///
1030/// * `content` - The string slice content following `@method`.
1031/// * `span` - The original `Span` of the `content` slice.
1032///
1033/// # Errors
1034///
1035/// Returns a [`ParseError`] if the method tag syntax is invalid.
1036pub fn parse_method_tag(mut content: &str, mut span: Span) -> Result<MethodTag, ParseError> {
1037    let (trimmed_content, leading_ws) = consume_whitespace(content);
1038    content = trimmed_content;
1039    span = span.subspan(leading_ws as u32, span.length());
1040
1041    let mut is_static = false;
1042    let mut visibility = None;
1043
1044    let mut acc_len = 0;
1045
1046    // Track the position and length of the static modifier, in case we need to treat it as return type
1047    let mut static_modifier_start = 0u32;
1048    let mut static_modifier_len = 0u32;
1049
1050    loop {
1051        if let Some((new_content, char_count)) = try_consume(content, "static ") {
1052            if is_static {
1053                return Err(ParseError::InvalidMethodTag(span, "Duplicate 'static' modifier".to_string()));
1054            }
1055
1056            is_static = true;
1057            static_modifier_start = acc_len as u32;
1058            static_modifier_len = 6; // "static" without the space
1059            acc_len += char_count;
1060            content = new_content;
1061        } else if let Some((new_content, char_count)) = try_consume(content, "public ") {
1062            if visibility.is_some() {
1063                return Err(ParseError::InvalidMethodTag(span, "Duplicate visibility modifier".to_string()));
1064            }
1065
1066            visibility = Some(Visibility::Public);
1067            acc_len += char_count;
1068            content = new_content;
1069        } else if let Some((new_content, char_count)) = try_consume(content, "protected ") {
1070            if visibility.is_some() {
1071                return Err(ParseError::InvalidMethodTag(span, "Duplicate visibility modifier".to_string()));
1072            }
1073
1074            visibility = Some(Visibility::Protected);
1075            acc_len += char_count;
1076            content = new_content;
1077        } else if let Some((new_content, char_count)) = try_consume(content, "private ") {
1078            if visibility.is_some() {
1079                return Err(ParseError::InvalidMethodTag(span, "Duplicate visibility modifier".to_string()));
1080            }
1081
1082            visibility = Some(Visibility::Private);
1083            acc_len += char_count;
1084            content = new_content;
1085        } else {
1086            break;
1087        }
1088    }
1089
1090    let rest_span = span.subspan(acc_len as u32, span.length());
1091
1092    let (type_string, rest_slice, rest_slice_span) = if is_static && looks_like_method_signature_only(content) {
1093        is_static = false;
1094        let static_span = span.subspan(static_modifier_start, static_modifier_start + static_modifier_len);
1095        let type_string = TypeString { value: "static".into(), span: static_span };
1096        let (rest_slice, whitespace_count) = consume_whitespace(content);
1097        let rest_slice_span = rest_span.subspan(whitespace_count as u32, rest_span.length());
1098        (type_string, rest_slice, rest_slice_span)
1099    } else {
1100        let type_string = split_tag_content(content, rest_span)
1101            .ok_or_else(|| ParseError::InvalidMethodTag(span, "Failed to parse return type".to_string()))?
1102            .0;
1103        let (rest_slice, whitespace_count) = consume_whitespace(&content[type_string.span.length() as usize..]);
1104        let rest_slice_span =
1105            rest_span.subspan(type_string.span.length() + whitespace_count as u32, rest_span.length());
1106        (type_string, rest_slice, rest_slice_span)
1107    };
1108
1109    // Type must exist and be valid
1110    if type_string.value.is_empty()
1111        || type_string.value.starts_with('{')
1112        || (type_string.value.starts_with('$') && type_string.value != "$this")
1113    {
1114        return Err(ParseError::InvalidMethodTag(span, format!("Invalid return type: '{}'", type_string.value)));
1115    }
1116
1117    if rest_slice.is_empty() {
1118        // Method definition is mandatory
1119        return Err(ParseError::InvalidMethodTag(span, "Missing method signature".to_string()));
1120    }
1121
1122    let mut chars = rest_slice.char_indices().peekable();
1123
1124    let mut name_end = None;
1125
1126    for (i, ch) in &mut chars {
1127        if ch == '(' {
1128            name_end = Some(i);
1129            break;
1130        }
1131    }
1132
1133    let name_end = name_end.ok_or_else(|| {
1134        ParseError::InvalidMethodTag(span, "Missing opening parenthesis '(' for method arguments".to_string())
1135    })?;
1136
1137    let name = rest_slice[..name_end].trim();
1138
1139    if name.is_empty() {
1140        return Err(ParseError::InvalidMethodTag(span, "Missing method name".to_string()));
1141    }
1142
1143    let mut depth = 1;
1144    let mut args_end = None;
1145
1146    for (i, ch) in &mut chars {
1147        match ch {
1148            '(' => depth += 1,
1149            ')' => {
1150                depth -= 1;
1151                if depth == 0 {
1152                    args_end = Some(i);
1153                    break;
1154                }
1155            }
1156            _ => {}
1157        }
1158    }
1159
1160    let args_end = args_end.ok_or_else(|| {
1161        ParseError::InvalidMethodTag(span, "Missing closing parenthesis ')' for method arguments".to_string())
1162    })?;
1163    let (args_str, whitespace_count) = consume_whitespace(&rest_slice[name_end + 1..args_end]);
1164    let args_span = rest_slice_span.subspan((whitespace_count + name_end) as u32 + 1, args_end as u32);
1165
1166    let description = rest_slice[args_end..].trim();
1167    let arguments_split = split_args(args_str, args_span);
1168    let arguments = arguments_split.iter().filter_map(|(arg, span)| parse_argument(arg, span)).collect::<Vec<_>>();
1169
1170    let method = Method {
1171        name: name.into(),
1172        argument_list: arguments,
1173        visibility: visibility.unwrap_or(Visibility::Public),
1174        is_static,
1175    };
1176
1177    Ok(MethodTag { span, type_string, method, description: description.into() })
1178}
1179
1180fn consume_whitespace(input: &str) -> (&str, usize) {
1181    let mut iter = input.chars().peekable();
1182    let mut count = 0;
1183
1184    while let Some(ch) = iter.peek() {
1185        if ch.is_whitespace() {
1186            iter.next();
1187            count += 1;
1188        } else {
1189            break;
1190        }
1191    }
1192
1193    (&input[count..], count)
1194}
1195
1196fn try_consume<'a>(input: &'a str, token: &str) -> Option<(&'a str, usize)> {
1197    let (input, whitespace_count) = consume_whitespace(input);
1198
1199    if !input.starts_with(token) {
1200        return None;
1201    }
1202
1203    let len = token.len() + whitespace_count;
1204    let input = &input[len..];
1205
1206    let (input, whitespace_count) = consume_whitespace(input);
1207
1208    Some((input, len + whitespace_count))
1209}
1210
1211/// Checks if the given content looks like only a method signature (no return type).
1212/// e.g., "`foo()`" or "foo($arg)" returns true
1213/// e.g., "Money `foo()`" or "int bar($x)" returns false
1214fn looks_like_method_signature_only(content: &str) -> bool {
1215    let trimmed = content.trim();
1216    if let Some(paren_pos) = trimmed.find('(') {
1217        let before_paren = trimmed[..paren_pos].trim();
1218        !before_paren.is_empty() && !before_paren.contains(' ')
1219    } else {
1220        false
1221    }
1222}
1223
1224fn split_args(args_str: &str, span: Span) -> Vec<(&str, Span)> {
1225    let mut args = Vec::new();
1226
1227    let mut start = 0;
1228    let mut depth = 0;
1229    for (i, ch) in args_str.char_indices() {
1230        match ch {
1231            '(' | '[' => depth += 1,
1232            ')' | ']' => depth -= 1,
1233            ',' if depth == 0 => {
1234                let (arg, whitespace_count) = consume_whitespace(&args_str[start..i]);
1235                if !arg.is_empty() {
1236                    args.push((arg, span.subspan((whitespace_count + start) as u32, i as u32)));
1237                }
1238                start = i + 1;
1239            }
1240            _ => {}
1241        }
1242    }
1243
1244    if start < args_str.len() {
1245        let (arg, whitespace_count) = consume_whitespace(&args_str[start..]);
1246        let arg_trimmed = arg.trim_end();
1247        if !arg.is_empty() {
1248            args.push((
1249                arg_trimmed,
1250                span.subspan(
1251                    (whitespace_count + start) as u32,
1252                    (args_str.len() - arg.len() + arg_trimmed.len()) as u32,
1253                ),
1254            ));
1255        }
1256    }
1257
1258    args
1259}
1260
1261fn parse_argument(arg_str: &str, span: &Span) -> Option<Argument> {
1262    let default_value_split = arg_str.rsplit_once('=');
1263
1264    let ((arg_type, raw_name), default_value): ((_, _), Option<&str>) =
1265        if let Some((variable_definition, default_value)) = default_value_split {
1266            let arg = variable_definition.trim();
1267            if let Some((arg_type, raw_name)) = arg.rsplit_once(' ') {
1268                ((Some(arg_type), raw_name), Some(default_value.trim()))
1269            } else {
1270                ((None, arg), Some(default_value))
1271            }
1272        } else {
1273            let arg = arg_str.trim();
1274            if let Some((arg_type, raw_name)) = arg.rsplit_once(' ') {
1275                ((Some(arg_type), raw_name), None)
1276            } else {
1277                ((None, arg), None)
1278            }
1279        };
1280
1281    let type_string =
1282        arg_type.map(|arg_type| TypeString { value: arg_type.into(), span: span.subspan(0, arg_type.len() as u32) });
1283
1284    let variable_span = span.subspan(arg_type.map_or(0, |t| 1 + t.len() as u32), span.length());
1285
1286    let variable = parse_var_ident(raw_name, false)?;
1287
1288    Some(Argument {
1289        type_hint: type_string,
1290        variable,
1291        has_default: default_value.is_some(),
1292        argument_span: *span,
1293        variable_span,
1294    })
1295}
1296
1297/// Checks if an opening bracket matches a closing one.
1298#[inline]
1299const fn brackets_match(open: char, close: char) -> bool {
1300    matches!((open, close), ('<', '>') | ('(', ')') | ('[', ']') | ('{', '}'))
1301}
1302
1303/// Checks if the identifier is valid
1304#[inline]
1305fn is_valid_identifier_start(mut identifier: &str, allow_qualified: bool) -> bool {
1306    if allow_qualified && identifier.starts_with('\\') {
1307        identifier = &identifier[1..];
1308    }
1309
1310    !identifier.is_empty()
1311        && identifier.chars().all(|c| c.is_alphanumeric() || c == '_' || (allow_qualified && c == '\\'))
1312        && identifier.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_')
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317    use mago_database::file::FileId;
1318    use mago_span::Position;
1319    use mago_span::Span;
1320
1321    use super::*;
1322
1323    fn test_span(input: &str, start_offset: u32) -> Span {
1324        let base_start = Position::new(start_offset);
1325        Span::new(FileId::zero(), base_start, base_start.forward(input.len() as u32))
1326    }
1327
1328    fn test_span_for(s: &str) -> Span {
1329        test_span(s, 0)
1330    }
1331
1332    fn make_span(start: u32, end: u32) -> Span {
1333        Span::new(FileId::zero(), Position::new(start), Position::new(end))
1334    }
1335
1336    #[test]
1337    fn test_parse_var_ident() {
1338        struct Expect<'a> {
1339            s: &'a str,
1340            variadic: bool,
1341            by_ref: bool,
1342        }
1343        let cases: &[(&str, Option<Expect>)] = &[
1344            ("$x", Some(Expect { s: "$x", variadic: false, by_ref: false })),
1345            ("&$refVar", Some(Expect { s: "$refVar", variadic: false, by_ref: true })),
1346            ("$foo,", Some(Expect { s: "$foo", variadic: false, by_ref: false })),
1347            ("...$ids)", Some(Expect { s: "$ids", variadic: true, by_ref: false })),
1348            ("...$items,", Some(Expect { s: "$items", variadic: true, by_ref: false })),
1349            ("$", None),
1350            ("...$", None),
1351            ("$1x", None),
1352            ("foo", None),
1353        ];
1354
1355        for (input, expected) in cases {
1356            let got = parse_var_ident(input, false);
1357            match (got, expected) {
1358                (None, None) => {}
1359                (Some(v), Some(e)) => {
1360                    assert_eq!(v.name, e.s, "input={input}");
1361                    assert_eq!(v.is_variadic, e.variadic, "input={input}");
1362                    assert_eq!(v.is_by_reference, e.by_ref, "input={input}");
1363                }
1364                _ => panic!("mismatch for input={input}"),
1365            }
1366        }
1367    }
1368
1369    #[test]
1370    fn test_variable_display_and_raw() {
1371        let cases = vec![("$x", "$x"), ("&$x", "&$x"), ("...$x", "...$x"), ("...$x)", "...$x"), ("...$x,", "...$x")];
1372
1373        for (input, expected_raw) in cases {
1374            let v = parse_var_ident(input, false).expect("should parse variable");
1375            assert_eq!(v.to_string(), expected_raw);
1376        }
1377    }
1378
1379    #[test]
1380    fn test_splitter_brackets() {
1381        let input = "array<int, (string|bool)> desc";
1382        let span = test_span_for(input);
1383        let (ts, rest) = split_tag_content(input, span).unwrap();
1384        assert_eq!(ts.value, "array<int, (string|bool)>");
1385        assert_eq!(ts.span, make_span(0, "array<int, (string|bool)>".len() as u32));
1386        assert_eq!(rest, "desc");
1387
1388        let input = "array<int, string> desc";
1389        let span = test_span_for(input);
1390        let (ts, rest) = split_tag_content(input, span).unwrap();
1391        assert_eq!(ts.value, "array<int, string>");
1392        assert_eq!(ts.span, make_span(0, "array<int, string>".len() as u32));
1393        assert_eq!(rest, "desc");
1394
1395        assert!(split_tag_content("array<int", test_span_for("array<int")).is_none()); // Unclosed
1396        assert!(split_tag_content("array<int)", test_span_for("array<int)")).is_none()); // Mismatched
1397        assert!(split_tag_content("array(int>", test_span_for("array(int>")).is_none()); // Mismatched
1398        assert!(split_tag_content("string>", test_span_for("string>")).is_none()); // Closing without opening
1399    }
1400
1401    #[test]
1402    fn test_splitter_quotes() {
1403        let input = " 'inside quote' outside ";
1404        let span = test_span_for(input);
1405        let (ts, rest) = split_tag_content(input, span).unwrap();
1406        assert_eq!(ts.value, "'inside quote'");
1407        assert_eq!(ts.span, make_span(1, "'inside quote'".len() as u32 + 1));
1408        assert_eq!(rest, "outside");
1409
1410        let input = r#""string \" with escape" $var"#;
1411        let span = test_span_for(input);
1412        let (ts, rest) = split_tag_content(input, span).unwrap();
1413        assert_eq!(ts.value, r#""string \" with escape""#);
1414        assert_eq!(ts.span, make_span(0, r#""string \" with escape""#.len() as u32));
1415        assert_eq!(rest, "$var");
1416
1417        assert!(split_tag_content("\"unterminated", test_span_for("\"unterminated")).is_none());
1418    }
1419
1420    #[test]
1421    fn test_splitter_comments() {
1422        let input = "(string // comment \n | int) $var";
1423        let span = test_span_for(input);
1424        let (ts, rest) = split_tag_content(input, span).unwrap();
1425        assert_eq!(ts.value, "(string // comment \n | int)");
1426        assert_eq!(ts.span, make_span(0, "(string // comment \n | int)".len() as u32));
1427        assert_eq!(rest, "$var");
1428
1429        let input = "string // comment goes to end";
1430        let span = test_span_for(input);
1431        let (ts, rest) = split_tag_content(input, span).unwrap();
1432        assert_eq!(ts.value, "string");
1433        assert_eq!(ts.span, make_span(0, "string".len() as u32));
1434        assert_eq!(rest, "// comment goes to end");
1435
1436        let input = "array<string // comment\n> $var";
1437        let span = test_span_for(input);
1438        let (ts, rest) = split_tag_content(input, span).unwrap();
1439        assert_eq!(ts.value, "array<string // comment\n>");
1440        assert_eq!(ts.span, make_span(0, "array<string // comment\n>".len() as u32));
1441        assert_eq!(rest, "$var");
1442    }
1443
1444    #[test]
1445    fn test_splitter_whole_string_is_type() {
1446        let input = " array<int, string> ";
1447        let span = test_span_for(input);
1448        let (ts, rest) = split_tag_content(input, span).unwrap();
1449        assert_eq!(ts.value, "array<int, string>");
1450        assert_eq!(ts.span, make_span(1, "array<int, string>".len() as u32 + 1));
1451        assert_eq!(rest, ""); // No rest part
1452    }
1453
1454    #[test]
1455    fn test_splitter_with_dot() {
1456        let input = "string[]. something";
1457        let span = test_span_for(input);
1458        let (ts, rest) = split_tag_content(input, span).unwrap();
1459        assert_eq!(ts.value, "string[]");
1460        assert_eq!(ts.span, make_span(0, "string[]".len() as u32));
1461        assert_eq!(rest, ". something");
1462    }
1463
1464    #[test]
1465    fn test_param_basic() {
1466        let offset = 10;
1467        let content = " string|int $myVar Description here ";
1468        let span = test_span(content, offset);
1469        let result = parse_param_tag(content, span).unwrap();
1470
1471        assert_eq!(result.type_string.as_ref().unwrap().value, "string|int"); // Check owned string value
1472        assert_eq!(result.type_string.as_ref().unwrap().span.start.offset, offset + 1); // Span of type part
1473        assert_eq!(result.type_string.as_ref().unwrap().span.end.offset, offset + 1 + "string|int".len() as u32);
1474        assert_eq!(result.variable.name, "$myVar");
1475        assert_eq!(result.description, "Description here");
1476        assert_eq!(result.span, span); // Check overall span
1477    }
1478
1479    #[test]
1480    fn test_param_complex_type_no_desc() {
1481        let offset = 5;
1482        let content = " array<int, string> $param ";
1483        let span = test_span(content, offset);
1484        let result = parse_param_tag(content, span).unwrap();
1485        assert_eq!(result.type_string.as_ref().unwrap().value, "array<int, string>"); // Check owned string
1486        assert_eq!(result.type_string.as_ref().unwrap().span.start.offset, offset + 1);
1487        assert_eq!(
1488            result.type_string.as_ref().unwrap().span.end.offset,
1489            offset + 1 + "array<int, string>".len() as u32
1490        );
1491        assert_eq!(result.variable.name, "$param");
1492        assert_eq!(result.description, "");
1493    }
1494
1495    #[test]
1496    fn test_param_type_with_comment() {
1497        let offset = 20;
1498        let content = " (string // comment \n | int) $var desc";
1499        let span = test_span(content, offset);
1500        let result = parse_param_tag(content, span).unwrap();
1501        assert_eq!(result.type_string.as_ref().unwrap().value, "(string // comment \n | int)");
1502        assert_eq!(result.type_string.as_ref().unwrap().span.start.offset, offset + 1);
1503        assert_eq!(
1504            result.type_string.as_ref().unwrap().span.end.offset,
1505            offset + 1 + "(string // comment \n | int)".len() as u32
1506        );
1507        assert_eq!(result.variable.name, "$var");
1508        assert_eq!(result.description, "desc");
1509    }
1510
1511    #[test]
1512    fn test_param_no_type() {
1513        let content = " $param Description here ";
1514        let span = test_span(content, 0);
1515        let result = parse_param_tag(content, span).unwrap();
1516        assert!(result.type_string.is_none()); // No type specified
1517        assert_eq!(result.variable.name, "$param");
1518        assert_eq!(result.description, "Description here");
1519    }
1520
1521    #[test]
1522    fn test_return_basic() {
1523        let offset = 10u32;
1524        let content = " string Description here ";
1525        let span = test_span(content, offset);
1526        let result = parse_return_tag(content, span).unwrap();
1527        assert_eq!(result.type_string.value, "string");
1528        assert_eq!(result.type_string.span.start.offset, offset + 1);
1529        assert_eq!(result.type_string.span.end.offset, offset + 1 + "string".len() as u32);
1530        assert_eq!(result.description, "Description here");
1531        assert_eq!(result.span, span);
1532    }
1533
1534    #[test]
1535    fn test_return_complex_type_with_desc() {
1536        let offset = 0;
1537        let content = " array<int, (string|null)> Description ";
1538        let span = test_span(content, offset);
1539        let result = parse_return_tag(content, span).unwrap();
1540        assert_eq!(result.type_string.value, "array<int, (string|null)>");
1541        assert_eq!(result.type_string.span.start.offset, offset + 1);
1542        assert_eq!(result.type_string.span.end.offset, offset + 1 + "array<int, (string|null)>".len() as u32);
1543        assert_eq!(result.description, "Description");
1544    }
1545
1546    #[test]
1547    fn test_return_complex_type_no_desc() {
1548        let offset = 0;
1549        let content = " array<int, (string|null)> ";
1550        let span = test_span(content, offset);
1551        let result = parse_return_tag(content, span).unwrap();
1552        assert_eq!(result.type_string.value, "array<int, (string|null)>");
1553        assert_eq!(result.type_string.span.start.offset, offset + 1);
1554        assert_eq!(result.type_string.span.end.offset, offset + 1 + "array<int, (string|null)>".len() as u32);
1555        assert_eq!(result.description, "");
1556    }
1557
1558    #[test]
1559    fn test_param_out_no_type() {
1560        let content = " $myVar ";
1561        let span = test_span(content, 0);
1562        assert!(parse_param_out_tag(content, span).is_err());
1563    }
1564
1565    #[test]
1566    fn test_param_out_no_var() {
1567        let content = " string ";
1568        let span = test_span(content, 0);
1569        assert!(parse_param_out_tag(content, span).is_err());
1570    }
1571
1572    #[test]
1573    fn test_type() {
1574        let content = "MyType = string";
1575        let span = test_span_for(content);
1576        let result = parse_type_tag(content, span).unwrap();
1577        assert_eq!(result.name, "MyType");
1578        assert_eq!(result.type_string.value, "string");
1579        assert_eq!(result.type_string.span.start.offset, 9);
1580        assert_eq!(result.type_string.span.end.offset, 9 + "string".len() as u32);
1581        assert_eq!(result.span, span);
1582    }
1583
1584    #[test]
1585    fn test_import_type() {
1586        let content = "MyType from \\My\\Namespace\\Class as Alias";
1587        let span = test_span_for(content);
1588        let result = parse_import_type_tag(content, span).unwrap();
1589        assert_eq!(result.name, "MyType");
1590        assert_eq!(result.from, "\\My\\Namespace\\Class");
1591        assert_eq!(result.alias, Some("Alias".to_owned()));
1592        assert_eq!(result.span, span);
1593    }
1594
1595    #[test]
1596    fn test_param_trailing_comma_is_ignored_in_name() {
1597        let content = " string $foo, desc";
1598        let span = test_span_for(content);
1599        let result = parse_param_tag(content, span).unwrap();
1600        assert_eq!(result.variable.name, "$foo");
1601        assert_eq!(result.description, ", desc");
1602    }
1603
1604    #[test]
1605    fn test_param_variadic_trailing_paren_is_ignored_in_name() {
1606        let content = " list<int> ...$items) rest";
1607        let span = test_span_for(content);
1608        let result = parse_param_tag(content, span).unwrap();
1609        assert_eq!(result.variable.name, "$items");
1610        assert_eq!(result.description, ") rest");
1611    }
1612
1613    #[test]
1614    fn test_param_out_trailing_comma() {
1615        let content = " int $out,";
1616        let span = test_span_for(content);
1617        let result = parse_param_out_tag(content, span).unwrap();
1618        assert_eq!(result.variable.name, "$out");
1619    }
1620
1621    #[test]
1622    fn test_assertion_trailing_comma() {
1623        let content = " int $x,";
1624        let span = test_span_for(content);
1625        let result = parse_assertion_tag(content, span).unwrap();
1626        assert_eq!(result.variable.name, "$x");
1627    }
1628
1629    #[test]
1630    fn test_param_trailing_without_space() {
1631        let content = " string $foo,desc";
1632        let span = test_span_for(content);
1633        let result = parse_param_tag(content, span).unwrap();
1634        assert_eq!(result.variable.name, "$foo");
1635        assert_eq!(result.description, ",desc");
1636    }
1637
1638    #[test]
1639    fn test_param_variadic_trailing_paren_without_space() {
1640        let content = " list<int> ...$items)more";
1641        let span = test_span_for(content);
1642        let result = parse_param_tag(content, span).unwrap();
1643        assert_eq!(result.variable.name, "$items");
1644        assert_eq!(result.description, ")more");
1645    }
1646
1647    #[test]
1648    fn test_param_with_numeric_literals_in_union() {
1649        let content = "-1|-24.0|string $a";
1650        let span = test_span_for(content);
1651        let result = parse_param_tag(content, span).unwrap();
1652        assert_eq!(result.type_string.as_ref().unwrap().value, "-1|-24.0|string");
1653        assert_eq!(result.variable.name, "$a");
1654        assert_eq!(result.description, "");
1655    }
1656
1657    #[test]
1658    fn test_param_with_float_literals() {
1659        let content = "1.5|2.0|3.14 $value";
1660        let span = test_span_for(content);
1661        let result = parse_param_tag(content, span).unwrap();
1662        assert_eq!(result.type_string.as_ref().unwrap().value, "1.5|2.0|3.14");
1663        assert_eq!(result.variable.name, "$value");
1664    }
1665
1666    #[test]
1667    fn test_splitter_with_dot_still_works_as_separator() {
1668        // Ensure we didn't break the original use case where . separates description
1669        let input = "string[]. something else";
1670        let span = test_span_for(input);
1671        let (ts, rest) = split_tag_content(input, span).unwrap();
1672        assert_eq!(ts.value, "string[]");
1673        assert_eq!(rest, ". something else");
1674    }
1675
1676    #[test]
1677    fn test_splitter_with_colon_after_whitespace() {
1678        let input = "callable(string)    :         string     $callback";
1679        let span = test_span_for(input);
1680        let (ts, rest) = split_tag_content(input, span).unwrap();
1681        assert_eq!(ts.value, "callable(string)    :         string");
1682        assert_eq!(rest, "$callback");
1683
1684        let input2 = "callable(string) : string $callback";
1685        let span2 = test_span_for(input2);
1686        let (ts2, rest2) = split_tag_content(input2, span2).unwrap();
1687        assert_eq!(ts2.value, "callable(string) : string");
1688        assert_eq!(rest2, "$callback");
1689
1690        let input3 = "callable(string): string $callback";
1691        let span3 = test_span_for(input3);
1692        let (ts3, rest3) = split_tag_content(input3, span3).unwrap();
1693        assert_eq!(ts3.value, "callable(string): string");
1694        assert_eq!(rest3, "$callback");
1695    }
1696}