Skip to main content

perl_lsp_document_highlight/
lib.rs

1//! Document Highlight Provider for Perl LSP
2//!
3//! Highlights all occurrences of a symbol when cursor is positioned on it.
4//! Distinguishes between read and write access.
5
6use perl_ast::{Node, NodeKind, SourceLocation};
7
8/// Types of symbol highlights
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum DocumentHighlightKind {
11    /// Regular text occurrence (read access)
12    Text = 1,
13    /// Read access to a symbol
14    Read = 2,
15    /// Write access to a symbol
16    Write = 3,
17}
18
19/// A highlighted range in the document
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct DocumentHighlight {
22    /// Source location of the highlight
23    pub location: SourceLocation,
24    /// Type of highlight
25    pub kind: DocumentHighlightKind,
26}
27
28/// Document Highlight Provider
29pub struct DocumentHighlightProvider;
30
31impl Default for DocumentHighlightProvider {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl DocumentHighlightProvider {
38    /// Create a new document highlight provider
39    pub fn new() -> Self {
40        Self
41    }
42
43    /// Find all highlights for the symbol at the given position in source code
44    pub fn find_highlights(
45        &self,
46        ast: &Node,
47        source: &str,
48        byte_offset: usize,
49    ) -> Vec<DocumentHighlight> {
50        // Find the node at the cursor position
51        let target_node = self.find_node_at_offset(ast, byte_offset);
52
53        // Get the symbol name and kind
54        let symbol_info = if let Some(ref node) = target_node {
55            // Check if this variable is inside a subscript operation and normalize
56            // the sigil accordingly (e.g., $array[0] -> @array, $hash{k} -> %hash)
57            self.extract_symbol_info_with_context(node, source, ast, byte_offset)
58        } else {
59            // Fallback: check for synthetic positions (e.g., catch parameters)
60            self.extract_symbol_at_offset(ast, source, byte_offset)
61        };
62
63        let symbol_info = match symbol_info {
64            Some(info) => info,
65            None => return Vec::new(),
66        };
67
68        // Find all occurrences of this symbol
69        let mut highlights = Vec::new();
70        self.collect_highlights(ast, source, &symbol_info, &mut highlights);
71
72        // Deduplicate highlights by location, preferring Write over Read
73        self.deduplicate_highlights(highlights)
74    }
75
76    /// Deduplicate highlights by location, preferring Write kind over Read
77    fn deduplicate_highlights(&self, highlights: Vec<DocumentHighlight>) -> Vec<DocumentHighlight> {
78        use std::collections::HashMap;
79
80        // Group by location (start, end)
81        let mut by_location: HashMap<(usize, usize), DocumentHighlight> = HashMap::new();
82
83        for h in highlights {
84            let key = (h.location.start, h.location.end);
85            by_location
86                .entry(key)
87                .and_modify(|existing| {
88                    // Prefer Write (3) over Read (2) over Text (1)
89                    if (h.kind as u8) > (existing.kind as u8) {
90                        *existing = h.clone();
91                    }
92                })
93                .or_insert(h);
94        }
95
96        // Return sorted by position
97        let mut result: Vec<_> = by_location.into_values().collect();
98        result.sort_by_key(|h| h.location.start);
99        result
100    }
101
102    /// Find the node at the given byte offset
103    fn find_node_at_offset(&self, node: &Node, offset: usize) -> Option<Node> {
104        // Check if offset is within this node
105        if offset < node.location.start || offset >= node.location.end {
106            return None;
107        }
108
109        // Check children first for more specific matches
110        if let Some(children) = self.get_children(node) {
111            for child in children {
112                if let Some(found) = self.find_node_at_offset(child, offset) {
113                    return Some(found);
114                }
115            }
116        }
117
118        // Check if this node is a relevant symbol
119        if self.is_symbol_node(node) {
120            return Some(node.clone());
121        }
122
123        None
124    }
125
126    /// Extract symbol info at an offset not covered by normal AST nodes
127    /// (e.g., catch parameter variables stored as strings in Try nodes)
128    fn extract_symbol_at_offset(
129        &self,
130        node: &Node,
131        source: &str,
132        offset: usize,
133    ) -> Option<SymbolInfo> {
134        if offset < node.location.start || offset >= node.location.end {
135            return None;
136        }
137
138        // Check for Try catch parameters
139        if let NodeKind::Try { catch_blocks, .. } = &node.kind {
140            for (param, _) in catch_blocks {
141                if let Some(var_str) = param {
142                    // Find the catch parameter location in the source within this node
143                    let node_source = source.get(node.location.start..node.location.end)?;
144                    let relative_offset = offset - node.location.start;
145                    // Search for the variable string near the offset
146                    for (pos, _) in node_source.match_indices(var_str.as_str()) {
147                        if pos <= relative_offset && relative_offset < pos + var_str.len() {
148                            let first_char = var_str.chars().next()?;
149                            if matches!(first_char, '$' | '@' | '%') {
150                                return Some(SymbolInfo {
151                                    name: var_str.get(1..)?.to_string(),
152                                    sigil: Some(first_char.to_string()),
153                                    is_method: false,
154                                    is_function: false,
155                                });
156                            }
157                        }
158                    }
159                }
160            }
161        }
162
163        // Check for subroutine/method name at cursor position
164        if let NodeKind::Subroutine { name: Some(sub_name), name_span: Some(span), .. } = &node.kind
165        {
166            if offset >= span.start && offset <= span.end {
167                return Some(SymbolInfo {
168                    name: sub_name.clone(),
169                    sigil: None,
170                    is_method: false,
171                    is_function: true,
172                });
173            }
174        }
175
176        // Recurse into children
177        if let Some(children) = self.get_children(node) {
178            for child in children {
179                if let Some(info) = self.extract_symbol_at_offset(child, source, offset) {
180                    return Some(info);
181                }
182            }
183        }
184
185        None
186    }
187
188    /// Get children of a node
189    fn get_children<'a>(&self, node: &'a Node) -> Option<Vec<&'a Node>> {
190        match &node.kind {
191            NodeKind::Program { statements } => Some(statements.iter().collect()),
192            NodeKind::VariableDeclaration { variable, initializer, .. } => {
193                let mut children = vec![variable.as_ref()];
194                if let Some(init) = initializer {
195                    children.push(init.as_ref());
196                }
197                Some(children)
198            }
199            NodeKind::VariableListDeclaration { variables, initializer, .. } => {
200                let mut children: Vec<&Node> = variables.iter().collect();
201                if let Some(init) = initializer {
202                    children.push(init.as_ref());
203                }
204                Some(children)
205            }
206            NodeKind::Assignment { lhs, rhs, .. } => Some(vec![lhs.as_ref(), rhs.as_ref()]),
207            NodeKind::Binary { left, right, .. } => Some(vec![left.as_ref(), right.as_ref()]),
208            NodeKind::Unary { operand, .. } => Some(vec![operand.as_ref()]),
209            NodeKind::MethodCall { object, args, .. } => {
210                let mut children = vec![object.as_ref()];
211                children.extend(args.iter().map(|a| a as &Node));
212                Some(children)
213            }
214            NodeKind::FunctionCall { args, .. } => Some(args.iter().collect()),
215            NodeKind::Block { statements } => Some(statements.iter().collect()),
216            NodeKind::If { condition, then_branch, elsif_branches, else_branch } => {
217                let mut children = vec![condition.as_ref(), then_branch.as_ref()];
218                for (cond, branch) in elsif_branches {
219                    children.push(cond.as_ref());
220                    children.push(branch.as_ref());
221                }
222                if let Some(else_b) = else_branch {
223                    children.push(else_b.as_ref());
224                }
225                Some(children)
226            }
227            NodeKind::For { init, condition, update, body, .. } => {
228                let mut children = Vec::new();
229                if let Some(i) = init {
230                    children.push(i.as_ref());
231                }
232                if let Some(c) = condition {
233                    children.push(c.as_ref());
234                }
235                if let Some(u) = update {
236                    children.push(u.as_ref());
237                }
238                children.push(body.as_ref());
239                Some(children)
240            }
241            NodeKind::Foreach { variable, list, body, continue_block } => {
242                if let Some(cb) = continue_block {
243                    Some(vec![variable.as_ref(), list.as_ref(), body.as_ref(), cb.as_ref()])
244                } else {
245                    Some(vec![variable.as_ref(), list.as_ref(), body.as_ref()])
246                }
247            }
248            NodeKind::While { condition, body, .. } => {
249                Some(vec![condition.as_ref(), body.as_ref()])
250            }
251            NodeKind::Subroutine { body, signature, .. } => {
252                let mut children = Vec::new();
253                if let Some(sig) = signature {
254                    // Signature node may have zero-width span; expose parameters directly
255                    if let NodeKind::Signature { parameters } = &sig.kind {
256                        children.extend(parameters.iter());
257                    } else {
258                        children.push(sig.as_ref());
259                    }
260                }
261                children.push(body.as_ref());
262                Some(children)
263            }
264            NodeKind::Return { value } => value.as_ref().map(|v| vec![v.as_ref()]),
265            NodeKind::ArrayLiteral { elements } => Some(elements.iter().collect()),
266            NodeKind::HashLiteral { pairs } => {
267                let mut children = Vec::new();
268                for (k, v) in pairs {
269                    children.push(k);
270                    children.push(v);
271                }
272                Some(children)
273            }
274            NodeKind::Ternary { condition, then_expr, else_expr } => {
275                Some(vec![condition.as_ref(), then_expr.as_ref(), else_expr.as_ref()])
276            }
277            NodeKind::VariableWithAttributes { variable, .. } => Some(vec![variable.as_ref()]),
278            NodeKind::ExpressionStatement { expression } => Some(vec![expression.as_ref()]),
279            // Statement modifiers (Issue #191)
280            NodeKind::StatementModifier { statement, condition, .. } => {
281                Some(vec![statement.as_ref(), condition.as_ref()])
282            }
283            // Regex operations - only expr is a child node, patterns are strings (Issue #191)
284            NodeKind::Match { expr, .. }
285            | NodeKind::Substitution { expr, .. }
286            | NodeKind::Transliteration { expr, .. } => Some(vec![expr.as_ref()]),
287            // Control flow (Issue #191)
288            NodeKind::Given { expr, body } => Some(vec![expr.as_ref(), body.as_ref()]),
289            NodeKind::When { condition, body } => Some(vec![condition.as_ref(), body.as_ref()]),
290            NodeKind::Default { body } => Some(vec![body.as_ref()]),
291            NodeKind::LabeledStatement { statement, .. } => Some(vec![statement.as_ref()]),
292            // Code evaluation (Issue #191)
293            NodeKind::Eval { block } | NodeKind::Do { block } => Some(vec![block.as_ref()]),
294            // Error handling (Issue #191)
295            NodeKind::Try { body, catch_blocks, finally_block } => {
296                let mut children = vec![body.as_ref()];
297                for (_, catch_body) in catch_blocks {
298                    children.push(catch_body.as_ref());
299                }
300                if let Some(finally) = finally_block {
301                    children.push(finally.as_ref());
302                }
303                Some(children)
304            }
305            // Method declarations (Issue #191)
306            NodeKind::Method { body, signature, .. } => {
307                let mut children = Vec::new();
308                if let Some(sig) = signature {
309                    // Signature node may have zero-width span; expose parameters directly
310                    if let NodeKind::Signature { parameters } = &sig.kind {
311                        children.extend(parameters.iter());
312                    } else {
313                        children.push(sig.as_ref());
314                    }
315                }
316                children.push(body.as_ref());
317                Some(children)
318            }
319            // Indirect calls (Issue #191)
320            NodeKind::IndirectCall { object, args, .. } => {
321                let mut children = vec![object.as_ref()];
322                children.extend(args.iter());
323                Some(children)
324            }
325            // Class declarations (Issue #191)
326            NodeKind::Class { body, .. } => Some(vec![body.as_ref()]),
327            // Signature and parameter types (Issue #191)
328            NodeKind::Signature { parameters } => Some(parameters.iter().collect()),
329            NodeKind::MandatoryParameter { variable } => Some(vec![variable.as_ref()]),
330            NodeKind::OptionalParameter { variable, default_value } => {
331                Some(vec![variable.as_ref(), default_value.as_ref()])
332            }
333            NodeKind::SlurpyParameter { variable } => Some(vec![variable.as_ref()]),
334            NodeKind::NamedParameter { variable } => Some(vec![variable.as_ref()]),
335            _ => None,
336        }
337    }
338
339    /// Check if a node represents a symbol we can highlight
340    fn is_symbol_node(&self, node: &Node) -> bool {
341        matches!(
342            node.kind,
343            NodeKind::Variable { .. }
344                | NodeKind::FunctionCall { .. }
345                | NodeKind::MethodCall { .. }
346                | NodeKind::Identifier { .. }
347        )
348    }
349
350    /// Extract symbol information from a node
351    fn extract_symbol_info(&self, node: &Node, source: &str) -> Option<SymbolInfo> {
352        match &node.kind {
353            NodeKind::Variable { sigil, name } => Some(SymbolInfo {
354                name: name.clone(),
355                sigil: Some(sigil.clone()),
356                is_method: false,
357                is_function: false,
358            }),
359            NodeKind::Identifier { name } => Some(SymbolInfo {
360                name: name.clone(),
361                sigil: None,
362                is_method: false,
363                is_function: false,
364            }),
365            NodeKind::FunctionCall { name, .. } => Some(SymbolInfo {
366                name: name.clone(),
367                sigil: None,
368                is_method: false,
369                is_function: true,
370            }),
371            NodeKind::MethodCall { method, .. } => Some(SymbolInfo {
372                name: method.clone(),
373                sigil: None,
374                is_method: true,
375                is_function: false,
376            }),
377            _ => {
378                // Try to extract from source text
379                let text = source.get(node.location.start..node.location.end)?;
380                // Check for sigil prefix and extract safely
381                let first = text.chars().next();
382                match first {
383                    Some(sigil @ ('$' | '@' | '%')) => Some(SymbolInfo {
384                        name: text.get(1..).unwrap_or("").to_string(),
385                        sigil: Some(sigil.to_string()),
386                        is_method: false,
387                        is_function: false,
388                    }),
389                    _ => None,
390                }
391            }
392        }
393    }
394
395    /// Extract symbol info with AST context awareness.
396    ///
397    /// When the cursor is on a variable inside a subscript operation, this
398    /// normalizes the sigil to the canonical container type:
399    /// - `$array[0]` -> canonical sigil `@` (array access)
400    /// - `$hash{key}` -> canonical sigil `%` (hash access)
401    /// - `$#array` -> canonical sigil `@` (array last index)
402    fn extract_symbol_info_with_context(
403        &self,
404        node: &Node,
405        source: &str,
406        ast: &Node,
407        byte_offset: usize,
408    ) -> Option<SymbolInfo> {
409        let base_info = self.extract_symbol_info(node, source)?;
410
411        // Only normalize when we have a $ sigil variable
412        if base_info.sigil.as_deref() != Some("$") {
413            return Some(base_info);
414        }
415
416        // Handle $#array -> normalize to @array
417        if let Some(bare_name) = base_info.name.strip_prefix('#') {
418            if !bare_name.is_empty() {
419                return Some(SymbolInfo {
420                    name: bare_name.to_string(),
421                    sigil: Some("@".to_string()),
422                    is_method: false,
423                    is_function: false,
424                });
425            }
426        }
427
428        // Check if this $var is the left child of a Binary { op: "[]" | "{}" }
429        if let Some(parent_op) = self.find_subscript_parent(ast, byte_offset) {
430            match parent_op.as_str() {
431                "[]" => {
432                    return Some(SymbolInfo {
433                        name: base_info.name,
434                        sigil: Some("@".to_string()),
435                        is_method: false,
436                        is_function: false,
437                    });
438                }
439                "{}" => {
440                    return Some(SymbolInfo {
441                        name: base_info.name,
442                        sigil: Some("%".to_string()),
443                        is_method: false,
444                        is_function: false,
445                    });
446                }
447                _ => {}
448            }
449        }
450
451        Some(base_info)
452    }
453
454    /// Find the subscript operator of a Binary node that is the parent of the
455    /// variable at the given offset, but only if the variable is the `left` child
456    /// (the container being subscripted, not the index/key).
457    fn find_subscript_parent(&self, node: &Node, offset: usize) -> Option<String> {
458        if offset < node.location.start || offset >= node.location.end {
459            return None;
460        }
461
462        // If this is a Binary subscript and the offset falls inside the left child
463        if let NodeKind::Binary { op, left, .. } = &node.kind {
464            if (op == "[]" || op == "{}")
465                && offset >= left.location.start
466                && offset < left.location.end
467            {
468                // Verify the left child is a Variable with $ sigil
469                if let NodeKind::Variable { sigil, .. } = &left.kind {
470                    if sigil == "$" {
471                        return Some(op.clone());
472                    }
473                }
474            }
475        }
476
477        // Recurse into children
478        if let Some(children) = self.get_children(node) {
479            for child in children {
480                if let Some(op) = self.find_subscript_parent(child, offset) {
481                    return Some(op);
482                }
483            }
484        }
485
486        None
487    }
488
489    /// Collect all highlights for a symbol
490    fn collect_highlights(
491        &self,
492        node: &Node,
493        source: &str,
494        target: &SymbolInfo,
495        highlights: &mut Vec<DocumentHighlight>,
496    ) {
497        self.collect_highlights_with_parent(node, source, target, highlights, None);
498    }
499
500    /// Collect all highlights for a symbol with parent context
501    fn collect_highlights_with_parent(
502        &self,
503        node: &Node,
504        source: &str,
505        target: &SymbolInfo,
506        highlights: &mut Vec<DocumentHighlight>,
507        parent: Option<&Node>,
508    ) {
509        // Check if this node matches our symbol
510        if self.node_matches_symbol(node, source, target) {
511            let kind = self.determine_highlight_kind_with_parent(node, parent);
512            // Use the full location including the sigil
513            highlights.push(DocumentHighlight { location: node.location, kind });
514        }
515
516        // Cross-sigil matching for variables that refer to the same underlying
517        // container but use a different sigil due to Perl's context rules:
518        //   %hash  <-> $hash{key}   (hash element access)
519        //   %hash  <-> @hash{@keys} (hash slice)
520        //   @array <-> $array[idx]  (array element access)
521        //   @array <-> $#array      (array last index)
522        if let NodeKind::Variable { sigil, name } = &node.kind {
523            if !self.node_matches_symbol(node, source, target) {
524                if let Some(target_sigil) = &target.sigil {
525                    let cross_match =
526                        self.is_cross_sigil_match(sigil, name, target_sigil, &target.name, parent);
527                    if cross_match {
528                        let kind = self.determine_highlight_kind_with_parent(node, parent);
529                        highlights.push(DocumentHighlight { location: node.location, kind });
530                    }
531                }
532            }
533        }
534
535        // Emit highlight for subroutine definition name_span
536        if let NodeKind::Subroutine { name: Some(sub_name), name_span: Some(span), .. } = &node.kind
537        {
538            if target.is_function && sub_name == &target.name {
539                highlights.push(DocumentHighlight {
540                    location: *span,
541                    kind: DocumentHighlightKind::Write,
542                });
543            }
544        }
545
546        // Recursively check children with this node as parent
547        if let Some(children) = self.get_children(node) {
548            for child in children {
549                self.collect_highlights_with_parent(child, source, target, highlights, Some(node));
550            }
551        }
552
553        // Emit synthetic highlights for Try catch parameter variables
554        if let NodeKind::Try { catch_blocks, body, .. } = &node.kind {
555            if let Some(target_sigil) = &target.sigil {
556                let expected = format!("{}{}", target_sigil, target.name);
557                let mut search_from = body.location.end;
558                for (param, catch_body) in catch_blocks {
559                    if let Some(var_str) = param {
560                        if var_str == &expected {
561                            // Search between previous body/catch end and catch body start
562                            let search_end = catch_body.location.start;
563                            if search_from < search_end && search_end <= source.len() {
564                                if let Some(search_area) = source.get(search_from..search_end) {
565                                    if let Some(pos) = search_area.find(var_str.as_str()) {
566                                        let var_start = search_from + pos;
567                                        highlights.push(DocumentHighlight {
568                                            location: SourceLocation {
569                                                start: var_start,
570                                                end: var_start + var_str.len(),
571                                            },
572                                            kind: DocumentHighlightKind::Write,
573                                        });
574                                    }
575                                }
576                            }
577                        }
578                    }
579                    search_from = catch_body.location.end;
580                }
581            }
582        }
583
584        // Scan interpolated strings for variable references
585        if let NodeKind::String { interpolated: true, .. } = &node.kind {
586            if let Some(target_sigil) = &target.sigil {
587                let expected = format!("{}{}", target_sigil, target.name);
588                if let Some(node_text) = source.get(node.location.start..node.location.end) {
589                    for (pos, _) in node_text.match_indices(expected.as_str()) {
590                        // Avoid matching prefixes of longer variable names
591                        let end_pos = pos + expected.len();
592                        if end_pos < node_text.len() {
593                            let next = node_text.as_bytes()[end_pos];
594                            if next.is_ascii_alphanumeric() || next == b'_' {
595                                continue;
596                            }
597                        }
598                        let abs_start = node.location.start + pos;
599                        // Skip if this is the whole node (already matched by normal traversal)
600                        if abs_start == node.location.start
601                            && node.location.end == abs_start + expected.len()
602                        {
603                            continue;
604                        }
605                        highlights.push(DocumentHighlight {
606                            location: SourceLocation {
607                                start: abs_start,
608                                end: abs_start + expected.len(),
609                            },
610                            kind: DocumentHighlightKind::Read,
611                        });
612                    }
613                }
614            }
615        }
616    }
617
618    /// Check whether a variable occurrence with `(sigil, name)` is a cross-sigil
619    /// match for the target `(target_sigil, target_name)`.
620    ///
621    /// Cross-sigil relationships in Perl:
622    /// - `$hash{key}` accesses `%hash` -> `$` + `{}` parent = `%`
623    /// - `@hash{qw(a b)}` slices `%hash` -> `@` + `{}` parent = `%`
624    /// - `$array[idx]` accesses `@array` -> `$` + `[]` parent = `@`
625    /// - `$#array` is the last index of `@array` -> name `#foo` maps to `@foo`
626    fn is_cross_sigil_match(
627        &self,
628        sigil: &str,
629        name: &str,
630        target_sigil: &str,
631        target_name: &str,
632        parent: Option<&Node>,
633    ) -> bool {
634        // Handle $#array <-> @array
635        // $#array is Variable { sigil: "$", name: "#array" }
636        if target_sigil == "@" && sigil == "$" {
637            if let Some(bare) = name.strip_prefix('#') {
638                if bare == target_name {
639                    return true;
640                }
641            }
642        }
643        // Reverse: target is $#array (normalized to @array), node is @array
644        // This case is handled by the normal sigil matching since we normalized
645        // the target sigil in extract_symbol_info_with_context.
646
647        // Same-name checks with subscript context
648        if name != target_name {
649            return false;
650        }
651
652        if let Some(parent_node) = parent {
653            if let NodeKind::Binary { op, .. } = &parent_node.kind {
654                // $hash{key} when target is %hash
655                if target_sigil == "%" && sigil == "$" && op == "{}" {
656                    return true;
657                }
658                // @hash{@keys} (hash slice) when target is %hash
659                if target_sigil == "%" && sigil == "@" && op == "{}" {
660                    return true;
661                }
662                // $array[idx] when target is @array
663                if target_sigil == "@" && sigil == "$" && op == "[]" {
664                    return true;
665                }
666                // @array[0,1] (array slice) when target is @array
667                // This is already matched by normal sigil matching since both are @.
668            }
669        }
670
671        false
672    }
673
674    /// Check if a node matches the target symbol
675    fn node_matches_symbol(&self, node: &Node, source: &str, target: &SymbolInfo) -> bool {
676        match &node.kind {
677            NodeKind::Variable { sigil, name } => {
678                if let Some(target_sigil) = &target.sigil {
679                    sigil == target_sigil && name == &target.name
680                } else {
681                    false
682                }
683            }
684            NodeKind::Identifier { name } => {
685                !target.is_method && target.sigil.is_none() && name == &target.name
686            }
687            NodeKind::FunctionCall { name, .. } => target.is_function && name == &target.name,
688            NodeKind::MethodCall { method, .. } => target.is_method && method == &target.name,
689            _ => {
690                // Check source text as fallback
691                if let Some(target_sigil) = &target.sigil {
692                    let expected = format!("{}{}", target_sigil, target.name);
693                    source
694                        .get(node.location.start..node.location.end)
695                        .is_some_and(|text| text == expected)
696                } else {
697                    false
698                }
699            }
700        }
701    }
702
703    /// Determine the kind of highlight based on context with parent information
704    fn determine_highlight_kind_with_parent(
705        &self,
706        node: &Node,
707        parent: Option<&Node>,
708    ) -> DocumentHighlightKind {
709        // Check if this variable is being written to (declaration or assignment)
710        // Look for parent nodes that indicate write access
711        match &node.kind {
712            NodeKind::Variable { .. } => {
713                // Check parent context to determine if this is a write or read
714                if let Some(parent_node) = parent {
715                    match &parent_node.kind {
716                        // Variable declarations are writes
717                        NodeKind::VariableDeclaration { variable, .. } => {
718                            if std::ptr::eq(variable.as_ref(), node) {
719                                DocumentHighlightKind::Write
720                            } else {
721                                DocumentHighlightKind::Read
722                            }
723                        }
724                        // Variables in list declarations are writes
725                        NodeKind::VariableListDeclaration { variables, .. } => {
726                            if variables.iter().any(|v| std::ptr::eq(v, node)) {
727                                DocumentHighlightKind::Write
728                            } else {
729                                DocumentHighlightKind::Read
730                            }
731                        }
732                        // Left side of assignment is write (includes compound assignments)
733                        NodeKind::Assignment { lhs, .. } => {
734                            if std::ptr::eq(lhs.as_ref(), node) {
735                                DocumentHighlightKind::Write
736                            } else {
737                                DocumentHighlightKind::Read
738                            }
739                        }
740                        // Increment/decrement operations are writes
741                        NodeKind::Unary { op, operand, .. } => {
742                            if (op == "++" || op == "--") && std::ptr::eq(operand.as_ref(), node) {
743                                DocumentHighlightKind::Write
744                            } else {
745                                DocumentHighlightKind::Read
746                            }
747                        }
748                        // Foreach loop variable is a write (iterator binding)
749                        NodeKind::Foreach { variable, .. } => {
750                            if std::ptr::eq(variable.as_ref(), node) {
751                                DocumentHighlightKind::Write
752                            } else {
753                                DocumentHighlightKind::Read
754                            }
755                        }
756                        // Signature parameters are writes (value binding on call)
757                        NodeKind::MandatoryParameter { variable }
758                        | NodeKind::SlurpyParameter { variable }
759                        | NodeKind::NamedParameter { variable } => {
760                            if std::ptr::eq(variable.as_ref(), node) {
761                                DocumentHighlightKind::Write
762                            } else {
763                                DocumentHighlightKind::Read
764                            }
765                        }
766                        NodeKind::OptionalParameter { variable, .. } => {
767                            if std::ptr::eq(variable.as_ref(), node) {
768                                DocumentHighlightKind::Write
769                            } else {
770                                DocumentHighlightKind::Read
771                            }
772                        }
773                        // Default to read for other contexts
774                        _ => DocumentHighlightKind::Read,
775                    }
776                } else {
777                    // If we don't have parent context, default to read
778                    DocumentHighlightKind::Read
779                }
780            }
781            _ => DocumentHighlightKind::Read,
782        }
783    }
784}
785
786// Internal SymbolInfo structure
787struct SymbolInfo {
788    name: String,
789    sigil: Option<String>,
790    is_method: bool,
791    is_function: bool,
792}