Skip to content

Commit 6ef90f1

Browse files
committed
feat(robot): add ProjectIndex for O(1) workspace-wide reference lookups
Add an incrementally maintained inverse reference index (ProjectIndex) scoped per WorkspaceFolder. On each file build, references are updated in-place instead of scanning all files. All six reference types are supported: keywords, variables, namespace entries, keyword tags, testcase tags, and metadata.
1 parent 6a372fe commit 6ef90f1

File tree

7 files changed

+828
-44
lines changed

7 files changed

+828
-44
lines changed

hatch.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies = [
2020
"pytest_asyncio>=0.23",
2121
"pytest-rerunfailures",
2222
"pytest-cov",
23+
"pytest-mock",
2324
"mypy",
2425
"ruff",
2526
"debugpy",
@@ -92,7 +93,7 @@ matrix.rf.dependencies = [
9293
]
9394

9495
[envs.hatch-test]
95-
dependencies = ["pytest", "pytest-html", "pytest_asyncio>=0.23", "pyyaml"]
96+
dependencies = ["pytest", "pytest-html", "pytest-mock", "pytest_asyncio>=0.23", "pyyaml"]
9697
pre-install-commands = ["python ./scripts/install_packages.py"]
9798

9899
[[envs.test.matrix]]

packages/analyze/src/robotcode/analyze/code/robot_framework_language_provider.py

Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ def __init__(self, diagnostics_context: DiagnosticsContext) -> None:
5757
self.diagnostics_context.diagnostics.folder_analyzers.add(self.analyze_folder)
5858
self.diagnostics_context.diagnostics.document_analyzers.add(self.analyze_document)
5959
self.diagnostics_context.diagnostics.document_collectors.add(self.collect_diagnostics)
60-
# self.diagnostics_context.diagnostics.document_collectors.add(self.collect_unused_keywords)
61-
# self.diagnostics_context.diagnostics.document_collectors.add(self.collect_unused_variables)
60+
self.diagnostics_context.diagnostics.document_collectors.add(self.collect_unused_keywords)
61+
self.diagnostics_context.diagnostics.document_collectors.add(self.collect_unused_variables)
6262

6363
def _update_python_path(self) -> None:
6464
root_path = (
@@ -126,22 +126,12 @@ def collect_diagnostics(self, sender: Any, document: TextDocument) -> Optional[L
126126
def collect_unused_keywords(self, sender: Any, document: TextDocument) -> Optional[List[Diagnostic]]:
127127
namespace = self._document_cache.get_namespace(document)
128128

129-
documents = (
130-
[document]
131-
if self._document_cache.get_document_type(document) != DocumentType.RESOURCE
132-
else self.diagnostics_context.workspace.documents.documents
133-
)
129+
project_index = self._document_cache.get_project_index(document)
134130

135131
result: List[Diagnostic] = []
136132

137133
for kw in namespace.library_doc.keywords.values():
138-
has_reference = False
139-
for doc in documents:
140-
refs = self._document_cache.get_namespace(doc).keyword_references
141-
if refs.get(kw):
142-
has_reference = True
143-
break
144-
if not has_reference:
134+
if not project_index.find_keyword_references(kw):
145135
result.append(
146136
Diagnostic(
147137
range=kw.name_range,
@@ -159,6 +149,7 @@ def collect_unused_variables(self, sender: Any, document: TextDocument) -> Optio
159149
result: List[Diagnostic] = []
160150

161151
namespace = self._document_cache.get_namespace(document)
152+
project_index = self._document_cache.get_project_index(document)
162153

163154
for var, locations in namespace.variable_references.items():
164155
if var.type in (
@@ -182,16 +173,7 @@ def collect_unused_variables(self, sender: Any, document: TextDocument) -> Optio
182173
)
183174
and self._document_cache.get_document_type(document) == DocumentType.RESOURCE
184175
):
185-
if self.verbose_callback is not None:
186-
self.verbose_callback(f"Checking variable '{var.name}' {var.type} for usage. {document.uri}")
187-
self.verbose_callback(
188-
f"Searching references in {len(self.diagnostics_context.workspace.documents)} documents."
189-
)
190-
191-
has_reference = any(
192-
len(self._document_cache.get_namespace(doc).variable_references.get(var, set())) > 0
193-
for doc in self.diagnostics_context.workspace.documents.documents
194-
)
176+
has_reference = bool(project_index.find_variable_references(var))
195177

196178
if not has_reference:
197179
result.append(

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

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -232,15 +232,11 @@ def _find_variable_references(
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,
242-
)
243-
)
235+
refs = self.parent.documents_cache.get_project_index(document).find_variable_references(variable)
236+
if stop_at_first and refs:
237+
result.append(next(iter(refs)))
238+
else:
239+
result.extend(refs)
244240
return result
245241

246242
@_logger.call
@@ -322,15 +318,11 @@ def _find_keyword_references(
322318
if include_declaration and kw_doc.source:
323319
result.append(Location(str(Uri.from_path(kw_doc.source)), kw_doc.range))
324320

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-
)
333-
)
321+
refs = self.parent.documents_cache.get_project_index(document).find_keyword_references(kw_doc)
322+
if stop_at_first and refs:
323+
result.append(next(iter(refs)))
324+
else:
325+
result.extend(refs)
334326

335327
return result
336328

@@ -412,7 +404,7 @@ def references_LibraryImport( # noqa: N802
412404
if entry is None:
413405
return None
414406

415-
result = self._find_references_in_workspace(document, False, self._find_library_alias_in_file, entry)
407+
result = list(self.parent.documents_cache.get_project_index(document).find_namespace_references(entry))
416408

417409
if context.include_declaration and entry.import_source:
418410
result.append(Location(str(Uri.from_path(entry.import_source)), entry.alias_range))

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from .imports_manager import ImportsManager
3838
from .library_doc import LibraryDoc
3939
from .namespace import DocumentType, Namespace, NamespaceBuilder
40+
from .project_index import ProjectIndex
4041
from .workspace_config import (
4142
AnalysisDiagnosticModifiersConfig,
4243
AnalysisRobotConfig,
@@ -81,6 +82,33 @@ def __init__(
8182
self._workspace_languages: weakref.WeakKeyDictionary[WorkspaceFolder, Optional[Languages]] = (
8283
weakref.WeakKeyDictionary()
8384
)
85+
self._project_indexes_lock = threading.RLock()
86+
self._project_indexes: weakref.WeakKeyDictionary[WorkspaceFolder, ProjectIndex] = weakref.WeakKeyDictionary()
87+
self._default_project_index: Optional[ProjectIndex] = None
88+
89+
def get_project_index(self, document: TextDocument) -> ProjectIndex:
90+
return self.get_project_index_for_uri(document.uri)
91+
92+
def get_project_index_for_uri(self, uri: Uri) -> ProjectIndex:
93+
return self.get_project_index_for_workspace_folder(self.workspace.get_workspace_folder(uri))
94+
95+
def get_project_index_for_workspace_folder(self, folder: Optional[WorkspaceFolder]) -> ProjectIndex:
96+
if folder is None:
97+
if len(self.workspace.workspace_folders) == 1:
98+
folder = self.workspace.workspace_folders[0]
99+
else:
100+
return self.default_project_index()
101+
102+
with self._project_indexes_lock:
103+
if folder not in self._project_indexes:
104+
self._project_indexes[folder] = ProjectIndex()
105+
return self._project_indexes[folder]
106+
107+
def default_project_index(self) -> ProjectIndex:
108+
with self._project_indexes_lock:
109+
if self._default_project_index is None:
110+
self._default_project_index = ProjectIndex()
111+
return self._default_project_index
84112

85113
def get_languages_for_document(self, document_or_uri: Union[TextDocument, Uri, str]) -> Optional[Languages]:
86114
if get_robot_version() < (6, 0):
@@ -352,6 +380,9 @@ def namespace_initialized(sender, namespace: Namespace) -> None: ...
352380
def namespace_invalidated(sender, namespace: Namespace) -> None: ...
353381

354382
def _invalidate_namespace(self, sender: Namespace) -> None:
383+
if sender.document is not None:
384+
self.get_project_index(sender.document).remove_file(sender.source)
385+
355386
document = sender.document
356387
if document is not None:
357388
document.remove_cache_entry(self.__get_general_namespace)
@@ -403,6 +434,9 @@ def __get_namespace_for_document_type(
403434

404435
result = builder.build()
405436

437+
# Update the folder-scoped reference index
438+
self.get_project_index(document).update_file(result.source, result)
439+
406440
# When the namespace detects dependency changes, evict the
407441
# document cache entry so it gets rebuilt on next access.
408442
result.invalidated.add(self._invalidate_namespace)
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
from __future__ import annotations
2+
3+
import threading
4+
from collections import defaultdict
5+
from dataclasses import dataclass, field
6+
from typing import (
7+
TYPE_CHECKING,
8+
Dict,
9+
Set,
10+
TypeVar,
11+
)
12+
13+
from robotcode.core.lsp.types import Location
14+
15+
from .entities import LibraryEntry, VariableDefinition
16+
from .library_doc import KeywordDoc
17+
18+
if TYPE_CHECKING:
19+
from .namespace import Namespace
20+
21+
22+
_K = TypeVar("_K")
23+
24+
25+
@dataclass
26+
class _FileRefs:
27+
"""Tracks which references a single file contributed to the global index."""
28+
29+
keyword_references: Dict[KeywordDoc, Set[Location]] = field(default_factory=dict)
30+
variable_references: Dict[VariableDefinition, Set[Location]] = field(default_factory=dict)
31+
namespace_references: Dict[LibraryEntry, Set[Location]] = field(default_factory=dict)
32+
keyword_tag_references: Dict[str, Set[Location]] = field(default_factory=dict)
33+
testcase_tag_references: Dict[str, Set[Location]] = field(default_factory=dict)
34+
metadata_references: Dict[str, Set[Location]] = field(default_factory=dict)
35+
36+
37+
class ProjectIndex:
38+
"""Workspace-wide inverse reference index.
39+
40+
Incrementally maintained: on file change only the affected file is
41+
removed and re-inserted. All lookups are O(1).
42+
43+
Thread-safety: An RLock protects all mutation operations (update_file,
44+
remove_file). Reads use the same lock — since writes are rare (only on
45+
file changes) and short, they block reads minimally.
46+
"""
47+
48+
def __init__(self) -> None:
49+
self._lock = threading.RLock()
50+
51+
self._keyword_references: Dict[KeywordDoc, Set[Location]] = defaultdict(set)
52+
self._variable_references: Dict[VariableDefinition, Set[Location]] = defaultdict(set)
53+
self._namespace_references: Dict[LibraryEntry, Set[Location]] = defaultdict(set)
54+
self._keyword_tag_references: Dict[str, Set[Location]] = defaultdict(set)
55+
self._testcase_tag_references: Dict[str, Set[Location]] = defaultdict(set)
56+
self._metadata_references: Dict[str, Set[Location]] = defaultdict(set)
57+
58+
self._refs_by_file: Dict[str, _FileRefs] = {}
59+
60+
def update_file(self, source: str, namespace: Namespace) -> None:
61+
"""After NamespaceBuilder.build(): update this file's references."""
62+
with self._lock:
63+
self._remove_file_unlocked(source)
64+
65+
file_refs = _FileRefs()
66+
67+
self._merge_refs(
68+
namespace.keyword_references,
69+
self._keyword_references,
70+
file_refs.keyword_references,
71+
)
72+
self._merge_refs(
73+
namespace.variable_references,
74+
self._variable_references,
75+
file_refs.variable_references,
76+
)
77+
self._merge_refs(
78+
namespace.namespace_references,
79+
self._namespace_references,
80+
file_refs.namespace_references,
81+
)
82+
self._merge_refs(
83+
namespace.keyword_tag_references,
84+
self._keyword_tag_references,
85+
file_refs.keyword_tag_references,
86+
)
87+
self._merge_refs(
88+
namespace.testcase_tag_references,
89+
self._testcase_tag_references,
90+
file_refs.testcase_tag_references,
91+
)
92+
self._merge_refs(
93+
namespace.metadata_references,
94+
self._metadata_references,
95+
file_refs.metadata_references,
96+
)
97+
98+
self._refs_by_file[source] = file_refs
99+
100+
def remove_file(self, source: str) -> None:
101+
"""File deleted or invalidated: remove all its references."""
102+
with self._lock:
103+
self._remove_file_unlocked(source)
104+
105+
def _remove_file_unlocked(self, source: str) -> None:
106+
file_refs = self._refs_by_file.pop(source, None)
107+
if file_refs is None:
108+
return
109+
110+
self._subtract_refs(file_refs.keyword_references, self._keyword_references)
111+
self._subtract_refs(file_refs.variable_references, self._variable_references)
112+
self._subtract_refs(file_refs.namespace_references, self._namespace_references)
113+
self._subtract_refs(file_refs.keyword_tag_references, self._keyword_tag_references)
114+
self._subtract_refs(file_refs.testcase_tag_references, self._testcase_tag_references)
115+
self._subtract_refs(file_refs.metadata_references, self._metadata_references)
116+
117+
@staticmethod
118+
def _merge_refs(
119+
source_refs: Dict[_K, Set[Location]],
120+
global_refs: Dict[_K, Set[Location]],
121+
file_refs: Dict[_K, Set[Location]],
122+
) -> None:
123+
for key, locations in source_refs.items():
124+
if locations:
125+
copied = set(locations)
126+
global_refs[key].update(copied)
127+
file_refs[key] = copied
128+
129+
@staticmethod
130+
def _subtract_refs(
131+
file_refs: Dict[_K, Set[Location]],
132+
global_refs: Dict[_K, Set[Location]],
133+
) -> None:
134+
for key, locations in file_refs.items():
135+
bucket = global_refs.get(key)
136+
if bucket is not None:
137+
bucket -= locations
138+
if not bucket:
139+
del global_refs[key]
140+
141+
def find_keyword_references(self, kw: KeywordDoc) -> Set[Location]:
142+
"""O(1) lookup instead of O(N) workspace scan."""
143+
with self._lock:
144+
return set(self._keyword_references.get(kw, ()))
145+
146+
def find_variable_references(self, var: VariableDefinition) -> Set[Location]:
147+
"""O(1) lookup instead of O(N) workspace scan."""
148+
with self._lock:
149+
return set(self._variable_references.get(var, ()))
150+
151+
def find_namespace_references(self, entry: LibraryEntry) -> Set[Location]:
152+
"""O(1) lookup instead of O(N) workspace scan."""
153+
with self._lock:
154+
return set(self._namespace_references.get(entry, ()))
155+
156+
def find_keyword_tag_references(self, tag: str) -> Set[Location]:
157+
with self._lock:
158+
return set(self._keyword_tag_references.get(tag, ()))
159+
160+
def find_testcase_tag_references(self, tag: str) -> Set[Location]:
161+
with self._lock:
162+
return set(self._testcase_tag_references.get(tag, ()))
163+
164+
def find_metadata_references(self, key: str) -> Set[Location]:
165+
with self._lock:
166+
return set(self._metadata_references.get(key, ()))
167+
168+
@property
169+
def keyword_references(self) -> Dict[KeywordDoc, Set[Location]]:
170+
with self._lock:
171+
return dict(self._keyword_references)
172+
173+
@property
174+
def variable_references(self) -> Dict[VariableDefinition, Set[Location]]:
175+
with self._lock:
176+
return dict(self._variable_references)
177+
178+
@property
179+
def namespace_references(self) -> Dict[LibraryEntry, Set[Location]]:
180+
with self._lock:
181+
return dict(self._namespace_references)
182+
183+
def clear(self) -> None:
184+
with self._lock:
185+
self._keyword_references.clear()
186+
self._variable_references.clear()
187+
self._namespace_references.clear()
188+
self._keyword_tag_references.clear()
189+
self._testcase_tag_references.clear()
190+
self._metadata_references.clear()
191+
self._refs_by_file.clear()

0 commit comments

Comments
 (0)