Skip to content

Commit 59e0314

Browse files
committed
perf(language-server): add reverse index for keyword/variable references
Optimize workspace-wide reference operations from O(D) to O(k) where D = total documents and k = documents actually referencing the target. Changes: - Add reverse index data structures in DocumentsCacheHelper to track which documents reference each keyword/variable - Use stable (source, name) tuple keys resilient to cache invalidation - Implement diff-based updates to handle removed references after edits - Add get_keyword_ref_users() and get_variable_ref_users() for O(1) lookup - Update Find References to use reverse index with workspace scan fallback - Update unused keyword/variable detection to use reverse index
1 parent ec8f008 commit 59e0314

File tree

3 files changed

+154
-28
lines changed

3 files changed

+154
-28
lines changed

packages/language_server/src/robotcode/language_server/robotframework/parts/diagnostics.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
EnvironmentVariableDefinition,
1919
GlobalVariableDefinition,
2020
LibraryArgumentDefinition,
21+
VariableDefinition,
2122
)
22-
from robotcode.robot.diagnostics.library_doc import LibraryDoc
23+
from robotcode.robot.diagnostics.library_doc import KeywordDoc, LibraryDoc
2324
from robotcode.robot.diagnostics.namespace import Namespace
2425

2526
from ...common.parts.diagnostics import DiagnosticsCollectType, DiagnosticsResult
@@ -138,6 +139,40 @@ def collect_namespace_diagnostics(
138139
],
139140
)
140141

142+
def _is_keyword_used_anywhere(
143+
self,
144+
document: TextDocument,
145+
kw: KeywordDoc,
146+
namespace: Namespace,
147+
) -> bool:
148+
"""Check if keyword is used anywhere, using index with safe fallback."""
149+
if self.parent.documents_cache.get_keyword_ref_users(kw):
150+
return True
151+
152+
if namespace.get_keyword_references().get(kw):
153+
return True
154+
155+
# Safe fallback: workspace scan if index might be incomplete
156+
refs = self.parent.robot_references.find_keyword_references(document, kw, False, True)
157+
return bool(refs)
158+
159+
def _is_variable_used_anywhere(
160+
self,
161+
document: TextDocument,
162+
var: VariableDefinition,
163+
namespace: Namespace,
164+
) -> bool:
165+
"""Check if variable is used anywhere, using index with safe fallback."""
166+
if self.parent.documents_cache.get_variable_ref_users(var):
167+
return True
168+
169+
if namespace.get_variable_references().get(var):
170+
return True
171+
172+
# Safe fallback: workspace scan if index might be incomplete
173+
refs = self.parent.robot_references.find_variable_references(document, var, False, True)
174+
return bool(refs)
175+
141176
@language_id("robotframework")
142177
@_logger.call
143178
def collect_unused_keyword_references(
@@ -166,10 +201,7 @@ def _collect_unused_keyword_references(
166201
for kw in (namespace.get_library_doc()).keywords.values():
167202
check_current_task_canceled()
168203

169-
references = self.parent.robot_references.find_keyword_references(
170-
document, kw, False, True
171-
)
172-
if not references:
204+
if not self._is_keyword_used_anywhere(document, kw, namespace):
173205
result.append(
174206
Diagnostic(
175207
range=kw.name_range,
@@ -255,10 +287,7 @@ def _collect_unused_variable_references(
255287
):
256288
continue
257289

258-
references = self.parent.robot_references.find_variable_references(
259-
document, var, False, True
260-
)
261-
if not references:
290+
if not self._is_variable_used_anywhere(document, var, namespace):
262291
result.append(
263292
Diagnostic(
264293
range=var.name_range,

packages/language_server/src/robotcode/language_server/robotframework/parts/references.py

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -224,23 +224,30 @@ def _find_variable_references(
224224
include_declaration: bool = True,
225225
stop_at_first: bool = False,
226226
) -> List[Location]:
227-
result = []
227+
result: List[Location] = []
228228

229229
if include_declaration and variable.source:
230230
result.append(Location(str(Uri.from_path(variable.source)), variable.name_range))
231231

232232
if variable.type == VariableDefinitionType.LOCAL_VARIABLE:
233233
result.extend(self.find_variable_references_in_file(document, variable, False))
234234
else:
235-
result.extend(
236-
self._find_references_in_workspace(
237-
document,
238-
stop_at_first,
239-
self.find_variable_references_in_file,
240-
variable,
241-
False,
235+
# Use reverse index for lookup instead of workspace scan
236+
docs_to_search = self.parent.documents_cache.get_variable_ref_users(variable)
237+
if docs_to_search:
238+
for doc in docs_to_search:
239+
check_current_task_canceled()
240+
result.extend(self.find_variable_references_in_file(doc, variable, False))
241+
if result and stop_at_first:
242+
break
243+
else:
244+
# Fallback to workspace scan if index is empty
245+
result.extend(
246+
self._find_references_in_workspace(
247+
document, stop_at_first, self.find_variable_references_in_file, variable, False
248+
)
242249
)
243-
)
250+
244251
return result
245252

246253
@_logger.call
@@ -317,20 +324,26 @@ def _find_keyword_references(
317324
include_declaration: bool = True,
318325
stop_at_first: bool = False,
319326
) -> List[Location]:
320-
result = []
327+
result: List[Location] = []
321328

322329
if include_declaration and kw_doc.source:
323330
result.append(Location(str(Uri.from_path(kw_doc.source)), kw_doc.range))
324331

325-
result.extend(
326-
self._find_references_in_workspace(
327-
document,
328-
stop_at_first,
329-
self.find_keyword_references_in_file,
330-
kw_doc,
331-
False,
332+
# Use reverse index for lookup instead of workspace scan
333+
docs_to_search = self.parent.documents_cache.get_keyword_ref_users(kw_doc)
334+
if docs_to_search:
335+
for doc in docs_to_search:
336+
check_current_task_canceled()
337+
result.extend(self.find_keyword_references_in_file(doc, kw_doc, False))
338+
if result and stop_at_first:
339+
break
340+
else:
341+
# Fallback to workspace scan if index is empty
342+
result.extend(
343+
self._find_references_in_workspace(
344+
document, stop_at_first, self.find_keyword_references_in_file, kw_doc, False
345+
)
332346
)
333-
)
334347

335348
return result
336349

packages/robot/src/robotcode/robot/diagnostics/document_cache_helper.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@
3030
from ..utils import get_robot_version
3131
from ..utils.stubs import Languages
3232
from .data_cache import CacheSection
33+
from .entities import VariableDefinition
3334
from .imports_manager import ImportsManager
34-
from .library_doc import LibraryDoc
35+
from .library_doc import KeywordDoc, LibraryDoc
3536
from .namespace import DocumentType, Namespace, NamespaceCacheData, NamespaceMetaData
3637
from .workspace_config import (
3738
AnalysisDiagnosticModifiersConfig,
@@ -99,6 +100,18 @@ def __init__(
99100
self._variables_users_lock = threading.RLock()
100101
self._variables_users: dict[str, weakref.WeakSet[TextDocument]] = {}
101102

103+
# Reference tracking for O(1) lookup of keyword/variable usages
104+
# Uses (source, name) tuples as keys for stability across cache invalidation
105+
self._ref_tracking_lock = threading.RLock()
106+
self._keyword_ref_users: dict[tuple[str, str], weakref.WeakSet[TextDocument]] = {}
107+
self._variable_ref_users: dict[tuple[str, str], weakref.WeakSet[TextDocument]] = {}
108+
self._doc_keyword_refs: weakref.WeakKeyDictionary[
109+
TextDocument, set[tuple[str, str]]
110+
] = weakref.WeakKeyDictionary()
111+
self._doc_variable_refs: weakref.WeakKeyDictionary[
112+
TextDocument, set[tuple[str, str]]
113+
] = weakref.WeakKeyDictionary()
114+
102115
# Counter for periodic cleanup of stale dependency map entries
103116
self._track_count = 0
104117

@@ -520,6 +533,8 @@ def __namespace_initialized(self, sender: Namespace) -> None:
520533
def __namespace_analysed(self, sender: Namespace) -> None:
521534
"""Re-save namespace to cache after analysis to include diagnostics and analysis results."""
522535
if sender.document is not None:
536+
self._track_references(sender.document, sender)
537+
523538
imports_manager = self.get_imports_manager(sender.document)
524539
self._save_namespace_to_cache(sender, imports_manager)
525540

@@ -585,6 +600,22 @@ def get_variables_users(self, variables_doc: LibraryDoc) -> list[TextDocument]:
585600
return list(self._variables_users[var_key])
586601
return []
587602

603+
def get_keyword_ref_users(self, kw_doc: KeywordDoc) -> list[TextDocument]:
604+
"""Get documents that reference a keyword."""
605+
with self._ref_tracking_lock:
606+
key = (kw_doc.source or "", kw_doc.name)
607+
if key in self._keyword_ref_users:
608+
return list(self._keyword_ref_users[key])
609+
return []
610+
611+
def get_variable_ref_users(self, var_def: VariableDefinition) -> list[TextDocument]:
612+
"""Get documents that reference a variable."""
613+
with self._ref_tracking_lock:
614+
key = (var_def.source or "", var_def.name)
615+
if key in self._variable_ref_users:
616+
return list(self._variable_ref_users[key])
617+
return []
618+
588619
def _cleanup_stale_dependency_maps(self) -> None:
589620
"""Remove entries with empty WeakSets from dependency maps.
590621
@@ -606,6 +637,59 @@ def _cleanup_stale_dependency_maps(self) -> None:
606637
for var_key in stale_var_keys:
607638
del self._variables_users[var_key]
608639

640+
with self._ref_tracking_lock:
641+
stale_kw_ref_keys = [k for k, v in self._keyword_ref_users.items() if len(v) == 0]
642+
for kw_ref_key in stale_kw_ref_keys:
643+
del self._keyword_ref_users[kw_ref_key]
644+
645+
stale_var_ref_keys = [k for k, v in self._variable_ref_users.items() if len(v) == 0]
646+
for var_ref_key in stale_var_ref_keys:
647+
del self._variable_ref_users[var_ref_key]
648+
649+
def _track_references(self, document: TextDocument, namespace: Namespace) -> None:
650+
"""Track keyword/variable references.
651+
652+
Uses diff-based updates: compares current references against previous
653+
to handle documents that stop referencing items after edits.
654+
"""
655+
with self._ref_tracking_lock:
656+
self._update_keyword_refs(document, namespace)
657+
self._update_variable_refs(document, namespace)
658+
659+
def _update_keyword_refs(self, document: TextDocument, namespace: Namespace) -> None:
660+
"""Update reverse index for keyword references."""
661+
keyword_refs = namespace.get_keyword_references()
662+
new_keys = {(kw.source or "", kw.name) for kw in keyword_refs}
663+
old_keys = self._doc_keyword_refs.get(document, set())
664+
665+
for key in old_keys - new_keys:
666+
if key in self._keyword_ref_users:
667+
self._keyword_ref_users[key].discard(document)
668+
669+
for key in new_keys - old_keys:
670+
if key not in self._keyword_ref_users:
671+
self._keyword_ref_users[key] = weakref.WeakSet()
672+
self._keyword_ref_users[key].add(document)
673+
674+
self._doc_keyword_refs[document] = new_keys
675+
676+
def _update_variable_refs(self, document: TextDocument, namespace: Namespace) -> None:
677+
"""Update reverse index for variable references."""
678+
variable_refs = namespace.get_variable_references()
679+
new_keys = {(var.source or "", var.name) for var in variable_refs}
680+
old_keys = self._doc_variable_refs.get(document, set())
681+
682+
for key in old_keys - new_keys:
683+
if key in self._variable_ref_users:
684+
self._variable_ref_users[key].discard(document)
685+
686+
for key in new_keys - old_keys:
687+
if key not in self._variable_ref_users:
688+
self._variable_ref_users[key] = weakref.WeakSet()
689+
self._variable_ref_users[key].add(document)
690+
691+
self._doc_variable_refs[document] = new_keys
692+
609693
def get_initialized_namespace(self, document: TextDocument) -> Namespace:
610694
result: Namespace | None = document.get_data(self.INITIALIZED_NAMESPACE)
611695
if result is None:

0 commit comments

Comments
 (0)