Skip to main content

php_lsp/
symbols.rs

1#![allow(deprecated)]
2
3use std::sync::Arc;
4
5use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
6use tower_lsp::lsp_types::{
7    DocumentSymbol, Location, OneOf, Position, Range, SymbolInformation, SymbolKind, Url,
8    WorkspaceSymbol,
9};
10
11use crate::ast::{ParsedDoc, SourceView, name_range};
12use crate::docblock::{docblock_before, parse_docblock};
13
14pub fn document_symbols(_source: &str, doc: &ParsedDoc) -> Vec<DocumentSymbol> {
15    let sv = doc.view();
16    symbols_from_statements(sv, &doc.program().stmts)
17}
18
19/// Fill in the sv.source() range for a `WorkspaceSymbol` whose `location` carries only a URI
20/// (i.e. `OneOf::Right(WorkspaceLocation)`).  If the range is already present, or if the
21/// document cannot be found, the symbol is returned unchanged.
22pub fn resolve_workspace_symbol(
23    mut symbol: WorkspaceSymbol,
24    docs: &[(Url, Arc<ParsedDoc>)],
25) -> WorkspaceSymbol {
26    let uri = match &symbol.location {
27        // Already fully resolved — nothing to do.
28        OneOf::Left(_) => return symbol,
29        OneOf::Right(wl) => wl.uri.clone(),
30    };
31    for (doc_uri, doc) in docs {
32        if doc_uri == &uri {
33            let range = name_range(doc.source(), doc.line_starts(), &symbol.name);
34            symbol.location = OneOf::Left(Location { uri, range });
35            break;
36        }
37    }
38    symbol
39}
40
41/// Parse an optional kind-filter prefix from the query string.
42///
43/// Supported prefixes:
44/// - `#class:` → `SymbolKind::CLASS`
45/// - `#fn:` or `#function:` → `SymbolKind::FUNCTION`
46/// - `#method:` → `SymbolKind::METHOD`
47/// - `#interface:` → `SymbolKind::INTERFACE`
48/// - `#enum:` → `SymbolKind::ENUM`
49/// - `#const:` → `SymbolKind::CONSTANT`
50/// - `#prop:` or `#property:` → `SymbolKind::PROPERTY`
51///
52/// Returns `(kind_filter, actual_search_term)`.
53fn parse_kind_filter(query: &str) -> (Option<SymbolKind>, &str) {
54    let Some(rest) = query.strip_prefix('#') else {
55        return (None, query);
56    };
57    let (prefix, term) = match rest.split_once(':') {
58        Some((p, t)) => (p, t),
59        None => return (None, query),
60    };
61    let kind = match prefix.to_lowercase().as_str() {
62        "class" | "c" => SymbolKind::CLASS,
63        "fn" | "function" | "f" => SymbolKind::FUNCTION,
64        "method" | "m" => SymbolKind::METHOD,
65        "interface" | "i" => SymbolKind::INTERFACE,
66        "enum" | "e" => SymbolKind::ENUM,
67        "const" | "constant" => SymbolKind::CONSTANT,
68        "prop" | "property" | "p" => SymbolKind::PROPERTY,
69        _ => return (None, query),
70    };
71    (Some(kind), term)
72}
73
74fn symbols_from_statements(sv: SourceView<'_>, stmts: &[Stmt<'_, '_>]) -> Vec<DocumentSymbol> {
75    let mut symbols = Vec::new();
76    for stmt in stmts {
77        match &stmt.kind {
78            StmtKind::Namespace(ns) => {
79                if let NamespaceBody::Braced(inner) = &ns.body {
80                    symbols.extend(symbols_from_statements(sv, inner));
81                }
82            }
83            _ => {
84                if let Some(sym) = statement_to_symbol(sv, stmt) {
85                    symbols.push(sym);
86                }
87            }
88        }
89    }
90    symbols
91}
92
93fn stmt_range(sv: SourceView<'_>, stmt: &Stmt<'_, '_>) -> Range {
94    let start = sv.position_of(stmt.span.start);
95    let end = sv.position_of(stmt.span.end);
96    Range { start, end }
97}
98
99fn member_range(sv: SourceView<'_>, member: &php_ast::ClassMember<'_, '_>) -> Range {
100    let start = sv.position_of(member.span.start);
101    let end = sv.position_of(member.span.end);
102    Range { start, end }
103}
104
105fn param_range(sv: SourceView<'_>, param: &php_ast::Param<'_, '_>) -> Range {
106    let start = sv.position_of(param.span.start);
107    let end = sv.position_of(param.span.end);
108    Range { start, end }
109}
110
111fn statement_to_symbol(sv: SourceView<'_>, stmt: &Stmt<'_, '_>) -> Option<DocumentSymbol> {
112    match &stmt.kind {
113        StmtKind::Function(f) => {
114            let range = stmt_range(sv, stmt);
115            let selection_range = sv.name_range(f.name);
116            let detail = Some(format_fn_signature(&f.params, f.return_type.as_ref()));
117            let is_deprecated = docblock_before(sv.source(), stmt.span.start)
118                .filter(|raw| parse_docblock(raw).deprecated.is_some())
119                .map(|_| true);
120
121            let param_children: Vec<DocumentSymbol> = f
122                .params
123                .iter()
124                .map(|p| {
125                    let prange = param_range(sv, p);
126                    let psel = sv.name_range(p.name);
127                    DocumentSymbol {
128                        name: format!("${}", p.name),
129                        detail: None,
130                        kind: SymbolKind::VARIABLE,
131                        tags: None,
132                        deprecated: None,
133                        range: prange,
134                        selection_range: psel,
135                        children: None,
136                    }
137                })
138                .collect();
139
140            Some(DocumentSymbol {
141                name: f.name.to_string(),
142                detail,
143                kind: SymbolKind::FUNCTION,
144                tags: None,
145                deprecated: is_deprecated,
146                range,
147                selection_range,
148                children: if param_children.is_empty() {
149                    None
150                } else {
151                    Some(param_children)
152                },
153            })
154        }
155
156        StmtKind::Class(c) => {
157            let name = c.name?;
158            let range = stmt_range(sv, stmt);
159            let selection_range = sv.name_range(name);
160            let class_deprecated = docblock_before(sv.source(), stmt.span.start)
161                .filter(|raw| parse_docblock(raw).deprecated.is_some())
162                .map(|_| true);
163
164            let children: Vec<DocumentSymbol> = c
165                .members
166                .iter()
167                .flat_map(|member| -> Vec<DocumentSymbol> {
168                    match &member.kind {
169                        ClassMemberKind::Method(m) => {
170                            let mrange = member_range(sv, member);
171                            let msel = sv.name_range(m.name);
172                            let detail =
173                                Some(format_fn_signature(&m.params, m.return_type.as_ref()));
174                            let method_deprecated = docblock_before(sv.source(), member.span.start)
175                                .filter(|raw| parse_docblock(raw).deprecated.is_some())
176                                .map(|_| true);
177                            vec![DocumentSymbol {
178                                name: m.name.to_string(),
179                                detail,
180                                kind: SymbolKind::METHOD,
181                                tags: None,
182                                deprecated: method_deprecated,
183                                range: mrange,
184                                selection_range: msel,
185                                children: None,
186                            }]
187                        }
188                        ClassMemberKind::Property(p) => {
189                            let prange = member_range(sv, member);
190                            let psel = sv.name_range(p.name);
191                            let prop_deprecated = docblock_before(sv.source(), member.span.start)
192                                .filter(|raw| parse_docblock(raw).deprecated.is_some())
193                                .map(|_| true);
194                            vec![DocumentSymbol {
195                                name: format!("${}", p.name),
196                                detail: None,
197                                kind: SymbolKind::PROPERTY,
198                                tags: None,
199                                deprecated: prop_deprecated,
200                                range: prange,
201                                selection_range: psel,
202                                children: None,
203                            }]
204                        }
205                        ClassMemberKind::ClassConst(cc) => {
206                            let crange = member_range(sv, member);
207                            let csel = sv.name_range(cc.name);
208                            let const_deprecated = docblock_before(sv.source(), member.span.start)
209                                .filter(|raw| parse_docblock(raw).deprecated.is_some())
210                                .map(|_| true);
211                            vec![DocumentSymbol {
212                                name: cc.name.to_string(),
213                                detail: None,
214                                kind: SymbolKind::CONSTANT,
215                                tags: None,
216                                deprecated: const_deprecated,
217                                range: crange,
218                                selection_range: csel,
219                                children: None,
220                            }]
221                        }
222                        _ => vec![],
223                    }
224                })
225                .collect();
226
227            Some(DocumentSymbol {
228                name: name.to_string(),
229                detail: None,
230                kind: SymbolKind::CLASS,
231                tags: None,
232                deprecated: class_deprecated,
233                range,
234                selection_range,
235                children: if children.is_empty() {
236                    None
237                } else {
238                    Some(children)
239                },
240            })
241        }
242
243        StmtKind::Interface(i) => {
244            let range = stmt_range(sv, stmt);
245            let selection_range = sv.name_range(i.name);
246            let iface_deprecated = docblock_before(sv.source(), stmt.span.start)
247                .filter(|raw| parse_docblock(raw).deprecated.is_some())
248                .map(|_| true);
249            let children: Vec<DocumentSymbol> = i
250                .members
251                .iter()
252                .filter_map(|member| match &member.kind {
253                    ClassMemberKind::Method(m) => {
254                        let mrange = member_range(sv, member);
255                        let msel = sv.name_range(m.name);
256                        let method_deprecated = docblock_before(sv.source(), member.span.start)
257                            .filter(|raw| parse_docblock(raw).deprecated.is_some())
258                            .map(|_| true);
259                        Some(DocumentSymbol {
260                            name: m.name.to_string(),
261                            detail: Some(format_fn_signature(&m.params, m.return_type.as_ref())),
262                            kind: SymbolKind::METHOD,
263                            tags: None,
264                            deprecated: method_deprecated,
265                            range: mrange,
266                            selection_range: msel,
267                            children: None,
268                        })
269                    }
270                    ClassMemberKind::ClassConst(cc) => {
271                        let crange = member_range(sv, member);
272                        let csel = sv.name_range(cc.name);
273                        let const_deprecated = docblock_before(sv.source(), member.span.start)
274                            .filter(|raw| parse_docblock(raw).deprecated.is_some())
275                            .map(|_| true);
276                        Some(DocumentSymbol {
277                            name: cc.name.to_string(),
278                            detail: None,
279                            kind: SymbolKind::CONSTANT,
280                            tags: None,
281                            deprecated: const_deprecated,
282                            range: crange,
283                            selection_range: csel,
284                            children: None,
285                        })
286                    }
287                    _ => None,
288                })
289                .collect();
290            Some(DocumentSymbol {
291                name: i.name.to_string(),
292                detail: None,
293                kind: SymbolKind::INTERFACE,
294                tags: None,
295                deprecated: iface_deprecated,
296                range,
297                selection_range,
298                children: if children.is_empty() {
299                    None
300                } else {
301                    Some(children)
302                },
303            })
304        }
305
306        StmtKind::Trait(t) => {
307            let range = stmt_range(sv, stmt);
308            let selection_range = sv.name_range(t.name);
309            let trait_deprecated = docblock_before(sv.source(), stmt.span.start)
310                .filter(|raw| parse_docblock(raw).deprecated.is_some())
311                .map(|_| true);
312            let children: Vec<DocumentSymbol> = t
313                .members
314                .iter()
315                .filter_map(|member| {
316                    if let ClassMemberKind::Method(m) = &member.kind {
317                        let mrange = member_range(sv, member);
318                        let msel = sv.name_range(m.name);
319                        let method_deprecated = docblock_before(sv.source(), member.span.start)
320                            .filter(|raw| parse_docblock(raw).deprecated.is_some())
321                            .map(|_| true);
322                        Some(DocumentSymbol {
323                            name: m.name.to_string(),
324                            detail: Some(format_fn_signature(&m.params, m.return_type.as_ref())),
325                            kind: SymbolKind::METHOD,
326                            tags: None,
327                            deprecated: method_deprecated,
328                            range: mrange,
329                            selection_range: msel,
330                            children: None,
331                        })
332                    } else {
333                        None
334                    }
335                })
336                .collect();
337
338            Some(DocumentSymbol {
339                name: t.name.to_string(),
340                detail: None,
341                kind: SymbolKind::CLASS,
342                tags: None,
343                deprecated: trait_deprecated,
344                range,
345                selection_range,
346                children: if children.is_empty() {
347                    None
348                } else {
349                    Some(children)
350                },
351            })
352        }
353
354        StmtKind::Enum(e) => {
355            let range = stmt_range(sv, stmt);
356            let selection_range = sv.name_range(e.name);
357            let enum_deprecated = docblock_before(sv.source(), stmt.span.start)
358                .filter(|raw| parse_docblock(raw).deprecated.is_some())
359                .map(|_| true);
360            let children: Vec<DocumentSymbol> = e
361                .members
362                .iter()
363                .filter_map(|member| match &member.kind {
364                    EnumMemberKind::Case(c) => {
365                        let crange = Range {
366                            start: sv.position_of(member.span.start),
367                            end: sv.position_of(member.span.end),
368                        };
369                        let csel = sv.name_range(c.name);
370                        Some(DocumentSymbol {
371                            name: c.name.to_string(),
372                            detail: None,
373                            kind: SymbolKind::ENUM_MEMBER,
374                            tags: None,
375                            deprecated: None,
376                            range: crange,
377                            selection_range: csel,
378                            children: None,
379                        })
380                    }
381                    EnumMemberKind::Method(m) => {
382                        let mrange = Range {
383                            start: sv.position_of(member.span.start),
384                            end: sv.position_of(member.span.end),
385                        };
386                        let msel = sv.name_range(m.name);
387                        let method_deprecated = docblock_before(sv.source(), member.span.start)
388                            .filter(|raw| parse_docblock(raw).deprecated.is_some())
389                            .map(|_| true);
390                        Some(DocumentSymbol {
391                            name: m.name.to_string(),
392                            detail: Some(format_fn_signature(&m.params, m.return_type.as_ref())),
393                            kind: SymbolKind::METHOD,
394                            tags: None,
395                            deprecated: method_deprecated,
396                            range: mrange,
397                            selection_range: msel,
398                            children: None,
399                        })
400                    }
401                    _ => None,
402                })
403                .collect();
404
405            Some(DocumentSymbol {
406                name: e.name.to_string(),
407                detail: None,
408                kind: SymbolKind::ENUM,
409                tags: None,
410                deprecated: enum_deprecated,
411                range,
412                selection_range,
413                children: if children.is_empty() {
414                    None
415                } else {
416                    Some(children)
417                },
418            })
419        }
420
421        _ => None,
422    }
423}
424
425fn format_fn_signature(
426    params: &[php_ast::Param<'_, '_>],
427    ret: Option<&php_ast::TypeHint<'_, '_>>,
428) -> String {
429    use crate::ast::format_type_hint;
430    let params_str = params
431        .iter()
432        .map(|p| {
433            let mut s = String::new();
434            if p.by_ref {
435                s.push('&');
436            }
437            if p.variadic {
438                s.push_str("...");
439            }
440            if let Some(t) = &p.type_hint {
441                s.push_str(&format!("{} ", format_type_hint(t)));
442            }
443            s.push_str(&format!("${}", p.name));
444            s
445        })
446        .collect::<Vec<_>>()
447        .join(", ");
448    let ret_str = ret
449        .map(|r| format!(": {}", format_type_hint(r)))
450        .unwrap_or_default();
451    format!("({}){}", params_str, ret_str)
452}
453
454fn _pos_from_offset(sv: SourceView<'_>, offset: u32) -> Position {
455    sv.position_of(offset)
456}
457
458// ── Index-based variants ──────────────────────────────────────────────────────
459
460/// `workspace_symbols` variant that queries `FileIndex` entries instead of
461/// full `ParsedDoc` ASTs.  Used by the backend for cross-file symbol search
462/// when background files only retain a compact index.
463#[allow(deprecated)]
464pub fn workspace_symbols_from_index(
465    query: &str,
466    indexes: &[(Url, Arc<crate::file_index::FileIndex>)],
467) -> Vec<SymbolInformation> {
468    use crate::file_index::ClassKind;
469    use crate::util::fuzzy_camel_match;
470
471    let (kind_filter, term) = parse_kind_filter(query);
472    let matches_kind = |k: SymbolKind| kind_filter.is_none_or(|f| f == k);
473
474    let line_range = |line: u32| -> Range {
475        let pos = Position { line, character: 0 };
476        Range {
477            start: pos,
478            end: pos,
479        }
480    };
481
482    let mut results = Vec::new();
483    for (uri, idx) in indexes {
484        if matches_kind(SymbolKind::FUNCTION) {
485            for f in &idx.functions {
486                if fuzzy_camel_match(term, &f.name) {
487                    results.push(SymbolInformation {
488                        name: f.name.clone(),
489                        kind: SymbolKind::FUNCTION,
490                        location: Location {
491                            uri: uri.clone(),
492                            range: line_range(f.start_line),
493                        },
494                        tags: None,
495                        deprecated: None,
496                        container_name: None,
497                    });
498                }
499            }
500        }
501        for cls in &idx.classes {
502            let class_kind = match cls.kind {
503                ClassKind::Class | ClassKind::Trait => SymbolKind::CLASS,
504                ClassKind::Interface => SymbolKind::INTERFACE,
505                ClassKind::Enum => SymbolKind::ENUM,
506            };
507            if matches_kind(class_kind) && fuzzy_camel_match(term, &cls.name) {
508                results.push(SymbolInformation {
509                    name: cls.name.clone(),
510                    kind: class_kind,
511                    location: Location {
512                        uri: uri.clone(),
513                        range: line_range(cls.start_line),
514                    },
515                    tags: None,
516                    deprecated: None,
517                    container_name: None,
518                });
519            }
520            if matches_kind(SymbolKind::METHOD) {
521                for m in &cls.methods {
522                    if fuzzy_camel_match(term, &m.name) {
523                        results.push(SymbolInformation {
524                            name: m.name.clone(),
525                            kind: SymbolKind::METHOD,
526                            location: Location {
527                                uri: uri.clone(),
528                                range: line_range(m.start_line),
529                            },
530                            tags: None,
531                            deprecated: None,
532                            container_name: Some(cls.name.clone()),
533                        });
534                    }
535                }
536            }
537            if matches_kind(SymbolKind::ENUM_MEMBER) && cls.kind == ClassKind::Enum {
538                for case in &cls.cases {
539                    if fuzzy_camel_match(term, case) {
540                        results.push(SymbolInformation {
541                            name: case.clone(),
542                            kind: SymbolKind::ENUM_MEMBER,
543                            location: Location {
544                                uri: uri.clone(),
545                                range: line_range(cls.start_line),
546                            },
547                            tags: None,
548                            deprecated: None,
549                            container_name: Some(cls.name.clone()),
550                        });
551                    }
552                }
553            }
554        }
555    }
556    results
557}
558
559/// Phase J — Thin wrapper over `workspace_symbols_from_index` that reads the
560/// `(Url, Arc<FileIndex>)` list out of the salsa-memoized aggregate. The
561/// inner walk is unchanged (fuzzy match is inherently O(total symbols)); the
562/// win is that every handler shares the same aggregate `Arc`, rebuilt only on
563/// edits, instead of each request rebuilding the list via `all_indexes()`
564/// (which takes the host mutex once per file).
565pub fn workspace_symbols_from_workspace(
566    query: &str,
567    wi: &crate::db::workspace_index::WorkspaceIndexData,
568) -> Vec<SymbolInformation> {
569    workspace_symbols_from_index(query, &wi.files)
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    #[test]
577    fn function_has_function_kind_and_signature_detail() {
578        let src = "<?php\nfunction greet(string $name): string {}";
579        let doc = ParsedDoc::parse(src.to_string());
580        let syms = document_symbols(src, &doc);
581        let f = syms
582            .iter()
583            .find(|s| s.name == "greet")
584            .expect("greet not found");
585        assert_eq!(f.kind, SymbolKind::FUNCTION);
586        let detail = f.detail.as_deref().unwrap_or("");
587        assert!(
588            detail.contains("$name"),
589            "detail should contain '$name', got: {detail}"
590        );
591        assert!(
592            detail.contains(": string"),
593            "detail should contain return type, got: {detail}"
594        );
595    }
596
597    #[test]
598    fn function_parameters_are_variable_children() {
599        let src = "<?php\nfunction process($input, $count) {}";
600        let doc = ParsedDoc::parse(src.to_string());
601        let syms = document_symbols(src, &doc);
602        let f = syms
603            .iter()
604            .find(|s| s.name == "process")
605            .expect("process not found");
606        let children = f.children.as_ref().expect("should have children");
607        assert!(
608            children
609                .iter()
610                .any(|c| c.name == "$input" && c.kind == SymbolKind::VARIABLE),
611            "missing $input child"
612        );
613        assert!(
614            children
615                .iter()
616                .any(|c| c.name == "$count" && c.kind == SymbolKind::VARIABLE),
617            "missing $count child"
618        );
619    }
620
621    #[test]
622    fn class_has_class_kind_with_method_children() {
623        let src = "<?php\nclass Calc { public function add() {} public function sub() {} }";
624        let doc = ParsedDoc::parse(src.to_string());
625        let syms = document_symbols(src, &doc);
626        let c = syms
627            .iter()
628            .find(|s| s.name == "Calc")
629            .expect("Calc not found");
630        assert_eq!(c.kind, SymbolKind::CLASS);
631        let children = c.children.as_ref().expect("should have method children");
632        assert!(
633            children
634                .iter()
635                .any(|m| m.name == "add" && m.kind == SymbolKind::METHOD)
636        );
637        assert!(
638            children
639                .iter()
640                .any(|m| m.name == "sub" && m.kind == SymbolKind::METHOD)
641        );
642    }
643
644    #[test]
645    fn interface_has_interface_kind() {
646        let src = "<?php\ninterface Serializable {}";
647        let doc = ParsedDoc::parse(src.to_string());
648        let syms = document_symbols(src, &doc);
649        let i = syms
650            .iter()
651            .find(|s| s.name == "Serializable")
652            .expect("Serializable not found");
653        assert_eq!(i.kind, SymbolKind::INTERFACE);
654    }
655
656    #[test]
657    fn trait_has_class_kind() {
658        let src = "<?php\ntrait Loggable {}";
659        let doc = ParsedDoc::parse(src.to_string());
660        let syms = document_symbols(src, &doc);
661        let t = syms
662            .iter()
663            .find(|s| s.name == "Loggable")
664            .expect("Loggable not found");
665        assert_eq!(t.kind, SymbolKind::CLASS);
666    }
667
668    #[test]
669    fn symbols_inside_namespace_are_returned() {
670        let src = "<?php\nnamespace App {\nfunction render() {}\nclass View {}\n}";
671        let doc = ParsedDoc::parse(src.to_string());
672        let syms = document_symbols(src, &doc);
673        assert!(syms.iter().any(|s| s.name == "render"), "missing 'render'");
674        assert!(syms.iter().any(|s| s.name == "View"), "missing 'View'");
675    }
676
677    #[test]
678    fn range_start_lte_selection_range_start() {
679        let src = "<?php\nfunction hello(string $x): int {}";
680        let doc = ParsedDoc::parse(src.to_string());
681        let syms = document_symbols(src, &doc);
682        let f = syms
683            .iter()
684            .find(|s| s.name == "hello")
685            .expect("hello not found");
686        assert!(f.range.start.line <= f.selection_range.start.line);
687        if f.range.start.line == f.selection_range.start.line {
688            assert!(f.range.start.character <= f.selection_range.start.character);
689        }
690    }
691
692    #[test]
693    fn class_properties_are_property_children() {
694        let src = "<?php\nclass User { public string $name; private int $age; }";
695        let doc = ParsedDoc::parse(src.to_string());
696        let syms = document_symbols(src, &doc);
697        let c = syms
698            .iter()
699            .find(|s| s.name == "User")
700            .expect("User not found");
701        let children = c.children.as_ref().expect("should have children");
702        assert!(
703            children
704                .iter()
705                .any(|ch| ch.name == "$name" && ch.kind == SymbolKind::PROPERTY)
706        );
707        assert!(
708            children
709                .iter()
710                .any(|ch| ch.name == "$age" && ch.kind == SymbolKind::PROPERTY)
711        );
712    }
713
714    #[test]
715    fn class_constants_are_constant_children() {
716        let src = "<?php\nclass Status { const ACTIVE = 1; const INACTIVE = 0; }";
717        let doc = ParsedDoc::parse(src.to_string());
718        let syms = document_symbols(src, &doc);
719        let c = syms
720            .iter()
721            .find(|s| s.name == "Status")
722            .expect("Status not found");
723        let children = c.children.as_ref().expect("should have children");
724        assert!(
725            children
726                .iter()
727                .any(|ch| ch.name == "ACTIVE" && ch.kind == SymbolKind::CONSTANT)
728        );
729        assert!(
730            children
731                .iter()
732                .any(|ch| ch.name == "INACTIVE" && ch.kind == SymbolKind::CONSTANT)
733        );
734    }
735
736    #[test]
737    fn trait_methods_are_method_children() {
738        let src = "<?php\ntrait Loggable { public function log() {} public function warn() {} }";
739        let doc = ParsedDoc::parse(src.to_string());
740        let syms = document_symbols(src, &doc);
741        let t = syms
742            .iter()
743            .find(|s| s.name == "Loggable")
744            .expect("Loggable not found");
745        let children = t
746            .children
747            .as_ref()
748            .expect("trait should have method children");
749        assert!(
750            children
751                .iter()
752                .any(|m| m.name == "log" && m.kind == SymbolKind::METHOD)
753        );
754        assert!(
755            children
756                .iter()
757                .any(|m| m.name == "warn" && m.kind == SymbolKind::METHOD)
758        );
759    }
760
761    #[test]
762    fn partial_ast_on_parse_error_returns_valid_symbols() {
763        let src = "<?php\nfunction valid() {}\nclass {";
764        let doc = ParsedDoc::parse(src.to_string());
765        let syms = document_symbols(src, &doc);
766        assert!(
767            syms.iter().any(|s| s.name == "valid"),
768            "should still return 'valid' despite parse error"
769        );
770    }
771
772    #[test]
773    fn function_symbol_has_correct_range() {
774        // The symbol range should start at the line where the `function` keyword is.
775        // Source: line 0 = "<?php", line 1 = "function myFunc() {}"
776        let src = "<?php\nfunction myFunc() {}";
777        let doc = ParsedDoc::parse(src.to_string());
778        let syms = document_symbols(src, &doc);
779        let f = syms
780            .iter()
781            .find(|s| s.name == "myFunc")
782            .expect("myFunc not found");
783        assert_eq!(
784            f.kind,
785            SymbolKind::FUNCTION,
786            "symbol should have FUNCTION kind"
787        );
788        assert_eq!(
789            f.range.start.line, 1,
790            "function range should start at line 1 (where 'function' keyword is)"
791        );
792        // The selection_range (name range) should also be on line 1.
793        assert_eq!(
794            f.selection_range.start.line, 1,
795            "selection_range should start at line 1"
796        );
797    }
798
799    #[test]
800    fn enum_symbol_has_correct_kind() {
801        // An enum declaration should produce a symbol with SymbolKind::ENUM.
802        let src = "<?php\nenum Color { case Red; case Green; case Blue; }";
803        let doc = ParsedDoc::parse(src.to_string());
804        let syms = document_symbols(src, &doc);
805        let e = syms
806            .iter()
807            .find(|s| s.name == "Color")
808            .expect("Color enum not found");
809        assert_eq!(
810            e.kind,
811            SymbolKind::ENUM,
812            "enum should produce a symbol with SymbolKind::ENUM"
813        );
814        assert_eq!(e.range.start.line, 1, "enum range should start at line 1");
815    }
816
817    #[test]
818    fn interface_constants_are_constant_children() {
819        // Interface constants should appear as CONSTANT children in document symbols.
820        let src =
821            "<?php\ninterface Config {\n    const VERSION = '1.0';\n    const DEBUG = false;\n}";
822        let doc = ParsedDoc::parse(src.to_string());
823        let syms = document_symbols(src, &doc);
824        let i = syms
825            .iter()
826            .find(|s| s.name == "Config")
827            .expect("Config interface not found");
828        let children = i
829            .children
830            .as_ref()
831            .expect("interface should have constant children");
832        assert!(
833            children
834                .iter()
835                .any(|c| c.name == "VERSION" && c.kind == SymbolKind::CONSTANT),
836            "missing VERSION constant child, got: {:?}",
837            children.iter().map(|c| &c.name).collect::<Vec<_>>()
838        );
839        assert!(
840            children
841                .iter()
842                .any(|c| c.name == "DEBUG" && c.kind == SymbolKind::CONSTANT),
843            "missing DEBUG constant child"
844        );
845        assert_eq!(
846            children.len(),
847            2,
848            "expected exactly 2 constant children, got: {:?}",
849            children.iter().map(|c| &c.name).collect::<Vec<_>>()
850        );
851    }
852
853    #[test]
854    fn interface_with_only_methods_has_method_children() {
855        let src = "<?php\ninterface Runnable {\n    public function run(): void;\n}";
856        let doc = ParsedDoc::parse(src.to_string());
857        let syms = document_symbols(src, &doc);
858        let i = syms
859            .iter()
860            .find(|s| s.name == "Runnable")
861            .expect("Runnable not found");
862        let children = i
863            .children
864            .as_ref()
865            .expect("interface with methods should have children");
866        assert!(
867            children
868                .iter()
869                .any(|c| c.name == "run" && c.kind == SymbolKind::METHOD),
870            "missing run method child, got: {:?}",
871            children.iter().map(|c| &c.name).collect::<Vec<_>>()
872        );
873    }
874
875    #[test]
876    fn parse_kind_filter_extracts_class_prefix() {
877        let (kind, term) = parse_kind_filter("#class:MyClass");
878        assert_eq!(kind, Some(SymbolKind::CLASS));
879        assert_eq!(term, "MyClass");
880    }
881
882    #[test]
883    fn parse_kind_filter_no_prefix_returns_none() {
884        let (kind, term) = parse_kind_filter("MyClass");
885        assert_eq!(kind, None);
886        assert_eq!(term, "MyClass");
887    }
888
889    #[test]
890    fn deprecated_function_sets_deprecated_field() {
891        let src = "<?php\n/** @deprecated Use newGreet() instead */\nfunction greet() {}";
892        let doc = ParsedDoc::parse(src.to_string());
893        let syms = document_symbols(src, &doc);
894        let f = syms
895            .iter()
896            .find(|s| s.name == "greet")
897            .expect("greet not found");
898        assert_eq!(
899            f.deprecated,
900            Some(true),
901            "deprecated function should have deprecated: Some(true)"
902        );
903    }
904
905    #[test]
906    fn non_deprecated_function_has_no_deprecated_field() {
907        let src = "<?php\n/** Does stuff. */\nfunction greet() {}";
908        let doc = ParsedDoc::parse(src.to_string());
909        let syms = document_symbols(src, &doc);
910        let f = syms
911            .iter()
912            .find(|s| s.name == "greet")
913            .expect("greet not found");
914        assert_eq!(
915            f.deprecated, None,
916            "non-deprecated function should have deprecated: None"
917        );
918    }
919
920    #[test]
921    fn deprecated_class_sets_deprecated_field() {
922        let src = "<?php\n/** @deprecated */\nclass OldService {}";
923        let doc = ParsedDoc::parse(src.to_string());
924        let syms = document_symbols(src, &doc);
925        let c = syms
926            .iter()
927            .find(|s| s.name == "OldService")
928            .expect("OldService not found");
929        assert_eq!(
930            c.deprecated,
931            Some(true),
932            "deprecated class should have deprecated: Some(true)"
933        );
934    }
935
936    #[test]
937    fn deprecated_method_sets_deprecated_field() {
938        let src =
939            "<?php\nclass Svc {\n    /** @deprecated */\n    public function oldMethod() {}\n}";
940        let doc = ParsedDoc::parse(src.to_string());
941        let syms = document_symbols(src, &doc);
942        let c = syms
943            .iter()
944            .find(|s| s.name == "Svc")
945            .expect("Svc not found");
946        let children = c.children.as_ref().expect("Svc should have children");
947        let m = children
948            .iter()
949            .find(|ch| ch.name == "oldMethod")
950            .expect("oldMethod not found");
951        assert_eq!(
952            m.deprecated,
953            Some(true),
954            "deprecated method should have deprecated: Some(true)"
955        );
956    }
957}