robotcode-analyze 2.4.0__tar.gz → 2.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (17) hide show
  1. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/.gitignore +5 -1
  2. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/PKG-INFO +1 -1
  3. robotcode_analyze-2.5.1/src/robotcode/analyze/__version__.py +1 -0
  4. robotcode_analyze-2.5.1/src/robotcode/analyze/cache/cli.py +421 -0
  5. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/src/robotcode/analyze/cli.py +2 -0
  6. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/src/robotcode/analyze/code/cli.py +30 -1
  7. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/src/robotcode/analyze/code/code_analyzer.py +6 -0
  8. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/src/robotcode/analyze/code/diagnostics_context.py +4 -0
  9. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/src/robotcode/analyze/code/robot_framework_language_provider.py +15 -27
  10. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/src/robotcode/analyze/config.py +30 -0
  11. robotcode_analyze-2.4.0/src/robotcode/analyze/__version__.py +0 -1
  12. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/README.md +0 -0
  13. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/pyproject.toml +0 -0
  14. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/src/robotcode/analyze/__init__.py +0 -0
  15. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/src/robotcode/analyze/code/language_provider.py +0 -0
  16. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/src/robotcode/analyze/hooks.py +0 -0
  17. {robotcode_analyze-2.4.0 → robotcode_analyze-2.5.1}/src/robotcode/analyze/py.typed +0 -0
@@ -334,4 +334,8 @@ bundled/libs
334
334
  results/
335
335
 
336
336
  # kilocode
337
- .kilocode/
337
+ .kilocode/
338
+
339
+ # .agents
340
+ .agents/
341
+ skills-lock.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotcode-analyze
3
- Version: 2.4.0
3
+ Version: 2.5.1
4
4
  Summary: RobotCode analyze plugin for Robot Framework
5
5
  Project-URL: Homepage, https://robotcode.io
6
6
  Project-URL: Donate, https://opencollective.com/robotcode
@@ -0,0 +1 @@
1
+ __version__ = "2.5.1"
@@ -0,0 +1,421 @@
1
+ from pathlib import Path
2
+ from typing import Optional, Tuple
3
+
4
+ import click
5
+
6
+ from robotcode.plugin import Application, OutputFormat, pass_application
7
+ from robotcode.robot.config.loader import load_robot_config_from_path
8
+ from robotcode.robot.config.utils import get_config_files
9
+ from robotcode.robot.diagnostics.data_cache import (
10
+ _LOCK_FILE_NAME,
11
+ CACHE_DIR_NAME,
12
+ CacheSection,
13
+ SqliteDataCache,
14
+ build_cache_dir,
15
+ exclusive_cache_lock,
16
+ resolve_cache_base_path,
17
+ )
18
+
19
+ from ..config import AnalyzeConfig
20
+
21
+ _SECTION_NAMES = {s.name.lower(): s for s in CacheSection}
22
+
23
+
24
+ def _resolve_cache(
25
+ app: Application,
26
+ paths: Tuple[Path, ...],
27
+ ) -> Tuple[Path, Optional[SqliteDataCache]]:
28
+ config_files, root_folder, _ = get_config_files(
29
+ paths,
30
+ app.config.config_files,
31
+ root_folder=app.config.root,
32
+ no_vcs=app.config.no_vcs,
33
+ verbose_callback=app.verbose,
34
+ )
35
+
36
+ robot_config = load_robot_config_from_path(
37
+ *config_files, extra_tools={"robotcode-analyze": AnalyzeConfig}, verbose_callback=app.verbose
38
+ )
39
+
40
+ analyzer_config = robot_config.tool.get("robotcode-analyze", None) if robot_config.tool is not None else None
41
+
42
+ cache_base_path = root_folder or Path.cwd()
43
+ if analyzer_config is not None and isinstance(analyzer_config, AnalyzeConfig):
44
+ if analyzer_config.cache is not None and analyzer_config.cache.cache_dir is not None:
45
+ cache_base_path = Path(analyzer_config.cache.cache_dir)
46
+
47
+ cache_base_path = resolve_cache_base_path(cache_base_path)
48
+
49
+ cache_dir = build_cache_dir(cache_base_path)
50
+
51
+ if not cache_dir.exists() or not (cache_dir / "cache.db").exists():
52
+ return cache_dir, None
53
+
54
+ from ..__version__ import __version__
55
+
56
+ return cache_dir, SqliteDataCache(cache_dir, app_version=__version__)
57
+
58
+
59
+ def _parse_sections(sections: Tuple[str, ...]) -> Optional[Tuple[CacheSection, ...]]:
60
+ if not sections:
61
+ return None
62
+
63
+ result = []
64
+ for s in sections:
65
+ s_lower = s.lower()
66
+ if s_lower not in _SECTION_NAMES:
67
+ raise click.BadParameter(
68
+ f"Unknown section '{s}'. Choose from: {', '.join(_SECTION_NAMES)}",
69
+ param_hint="'--section'",
70
+ )
71
+ result.append(_SECTION_NAMES[s_lower])
72
+ return tuple(result)
73
+
74
+
75
+ def _format_bytes(n: int) -> str:
76
+ if n < 1024:
77
+ return f"{n} B"
78
+ if n < 1024 * 1024:
79
+ return f"{n / 1024:.1f} KB"
80
+ return f"{n / (1024 * 1024):.1f} MB"
81
+
82
+
83
+ @click.group(
84
+ name="cache",
85
+ add_help_option=True,
86
+ invoke_without_command=False,
87
+ )
88
+ def cache_group() -> None:
89
+ """\
90
+ Manage the RobotCode analysis cache.
91
+
92
+ Provides subcommands to inspect, list, and clear cached data
93
+ (library docs, variables, resources, namespaces).
94
+ """
95
+
96
+
97
+ @cache_group.command(name="path")
98
+ @click.argument(
99
+ "paths", nargs=-1, type=click.Path(exists=True, dir_okay=True, file_okay=True, readable=True, path_type=Path)
100
+ )
101
+ @pass_application
102
+ def cache_path(app: Application, paths: Tuple[Path, ...]) -> None:
103
+ """\
104
+ Print the cache directory path.
105
+
106
+ Outputs the resolved cache directory for the current project
107
+ and Python/Robot Framework version combination.
108
+ """
109
+ cache_dir, db = _resolve_cache(app, paths)
110
+ try:
111
+ app.echo(str(cache_dir))
112
+ finally:
113
+ if db is not None:
114
+ db.close()
115
+
116
+
117
+ @cache_group.command(name="info")
118
+ @click.argument(
119
+ "paths", nargs=-1, type=click.Path(exists=True, dir_okay=True, file_okay=True, readable=True, path_type=Path)
120
+ )
121
+ @pass_application
122
+ def cache_info(app: Application, paths: Tuple[Path, ...]) -> None:
123
+ """\
124
+ Show cache statistics.
125
+
126
+ Displays the cache directory, database size, app version, and
127
+ per-section entry counts with timestamps.
128
+ """
129
+ cache_dir, db = _resolve_cache(app, paths)
130
+
131
+ if db is None:
132
+ app.echo(f"Cache directory: {cache_dir}")
133
+ app.echo("No cache database found.")
134
+ return
135
+
136
+ try:
137
+ db_path = db.db_path
138
+ db_size = db_path.stat().st_size if db_path.exists() else 0
139
+
140
+ section_data = []
141
+ total_entries = 0
142
+ total_bytes = 0
143
+ for section in CacheSection:
144
+ stats = db.get_section_stats(section)
145
+ total_entries += stats.entry_count
146
+ total_bytes += stats.total_blob_bytes
147
+ section_data.append(
148
+ {
149
+ "section": section.name.lower(),
150
+ "entries": stats.entry_count,
151
+ "size": stats.total_blob_bytes,
152
+ "size_formatted": _format_bytes(stats.total_blob_bytes) if stats.entry_count else "—",
153
+ "created": stats.oldest_created or None,
154
+ "modified": stats.newest_modified or None,
155
+ }
156
+ )
157
+
158
+ if app.config.output_format is None or app.config.output_format == OutputFormat.TEXT:
159
+ if app.colored and app.has_rich:
160
+ lines = [
161
+ f"- **Directory:** {cache_dir}",
162
+ f"- **Database:** {db_path.name} ({_format_bytes(db_size)})",
163
+ f"- **Version:** {db.app_version or '(unknown)'}",
164
+ "",
165
+ "| Section | Entries | Size | Created | Modified |",
166
+ "|---|---:|---:|---|---|",
167
+ ]
168
+ for s in section_data:
169
+ lines.append(
170
+ f"| {s['section']} | {s['entries']} | {s['size_formatted']}"
171
+ f" | {s['created'] or '—'} | {s['modified'] or '—'} |"
172
+ )
173
+ lines.append(f"| **Total** | **{total_entries}** | **{_format_bytes(total_bytes)}** | | |")
174
+ app.echo_as_markdown("\n".join(lines))
175
+ else:
176
+ app.echo(f" Directory: {cache_dir}")
177
+ app.echo(f" Database: {db_path.name} ({_format_bytes(db_size)})")
178
+ app.echo(f" Version: {db.app_version or '(unknown)'}")
179
+ app.echo("")
180
+ header = f" {'Section':<12} {'Entries':>7} {'Size':>10} {'Created':19} {'Modified':19}"
181
+ app.echo(header)
182
+ app.echo(f" {'─' * (len(header) - 2)}")
183
+ for s in section_data:
184
+ app.echo(
185
+ f" {s['section']:<12} {s['entries']:>7} {s['size_formatted']:>10}"
186
+ f" {(s['created'] or '—'):19} {(s['modified'] or '—'):19}"
187
+ )
188
+ app.echo(f" {'─' * (len(header) - 2)}")
189
+ app.echo(f" {'Total':<12} {total_entries:>7} {_format_bytes(total_bytes):>10}")
190
+ else:
191
+ app.print_data(
192
+ {
193
+ "directory": str(cache_dir),
194
+ "database": db_path.name,
195
+ "database_size": db_size,
196
+ "version": db.app_version or "",
197
+ "sections": [
198
+ {k: v for k, v in s.items() if k != "size_formatted" and v is not None} for s in section_data
199
+ ],
200
+ "total_entries": total_entries,
201
+ "total_size": total_bytes,
202
+ }
203
+ )
204
+ finally:
205
+ db.close()
206
+
207
+
208
+ @cache_group.command(name="list")
209
+ @click.option(
210
+ "-s",
211
+ "--section",
212
+ "sections",
213
+ multiple=True,
214
+ metavar="SECTION",
215
+ help="Filter by section (library, variables, resource, namespace). Can be specified multiple times.",
216
+ )
217
+ @click.option(
218
+ "-p",
219
+ "--pattern",
220
+ "patterns",
221
+ multiple=True,
222
+ metavar="PATTERN",
223
+ help="Filter entries by glob pattern (e.g. 'robot.*', '*BuiltIn*'). Can be specified multiple times.",
224
+ )
225
+ @click.argument(
226
+ "paths", nargs=-1, type=click.Path(exists=True, dir_okay=True, file_okay=True, readable=True, path_type=Path)
227
+ )
228
+ @pass_application
229
+ def cache_list(app: Application, sections: Tuple[str, ...], patterns: Tuple[str, ...], paths: Tuple[Path, ...]) -> None:
230
+ """\
231
+ List cached entries.
232
+
233
+ Shows all entries in the cache with their timestamps and sizes.
234
+ Use --section to filter by specific cache sections.
235
+ Use --pattern to filter entries by glob pattern.
236
+ """
237
+ from fnmatch import fnmatch
238
+
239
+ _, db = _resolve_cache(app, paths)
240
+
241
+ if db is None:
242
+ app.echo("No cache database found.")
243
+ return
244
+
245
+ try:
246
+ selected = _parse_sections(sections)
247
+ target_sections = selected if selected else tuple(CacheSection)
248
+
249
+ def _matches(name: str) -> bool:
250
+ if not patterns:
251
+ return True
252
+ return any(fnmatch(name, p) for p in patterns)
253
+
254
+ if app.config.output_format is None or app.config.output_format == OutputFormat.TEXT:
255
+ if app.colored and app.has_rich:
256
+ lines: list[str] = []
257
+ for section in target_sections:
258
+ entries = [e for e in db.list_entries(section) if _matches(e.entry_name)]
259
+ if not entries:
260
+ continue
261
+ lines.append(f"### {section.name.lower()} ({len(entries)} entries)")
262
+ lines.append("")
263
+ lines.append("| Name | Size | Created | Modified |")
264
+ lines.append("|---|---:|---|---|")
265
+ for entry in entries:
266
+ size = _format_bytes(entry.meta_bytes + entry.data_bytes)
267
+ created = entry.created_at or "—"
268
+ modified = entry.modified_at or "—"
269
+ lines.append(f"| {entry.entry_name} | {size} | {created} | {modified} |")
270
+ lines.append("")
271
+ if lines:
272
+ app.echo_as_markdown("\n".join(lines))
273
+ else:
274
+ app.echo("No entries found.")
275
+ else:
276
+ found = False
277
+ for section in target_sections:
278
+ entries = [e for e in db.list_entries(section) if _matches(e.entry_name)]
279
+ if not entries:
280
+ continue
281
+ found = True
282
+ app.echo(f"[{section.name.lower()}] ({len(entries)} entries)")
283
+ for entry in entries:
284
+ size = _format_bytes(entry.meta_bytes + entry.data_bytes)
285
+ created = entry.created_at or "—"
286
+ modified = entry.modified_at or "—"
287
+ app.echo(f" {entry.entry_name} size={size} created={created} modified={modified}")
288
+ app.echo("")
289
+ if not found:
290
+ app.echo("No entries found.")
291
+ else:
292
+ result: dict[str, list[dict[str, object]]] = {}
293
+ for section in target_sections:
294
+ entries = [e for e in db.list_entries(section) if _matches(e.entry_name)]
295
+ if entries:
296
+ result[section.name.lower()] = [
297
+ {
298
+ k: v
299
+ for k, v in {
300
+ "name": e.entry_name,
301
+ "size": e.meta_bytes + e.data_bytes,
302
+ "created": e.created_at,
303
+ "modified": e.modified_at,
304
+ }.items()
305
+ if v is not None
306
+ }
307
+ for e in entries
308
+ ]
309
+ app.print_data(result)
310
+ finally:
311
+ db.close()
312
+
313
+
314
+ @cache_group.command(name="clear")
315
+ @click.option(
316
+ "-s",
317
+ "--section",
318
+ "sections",
319
+ multiple=True,
320
+ metavar="SECTION",
321
+ help="Clear only specific sections (library, variables, resource, namespace). Can be specified multiple times.",
322
+ )
323
+ @click.argument(
324
+ "paths", nargs=-1, type=click.Path(exists=True, dir_okay=True, file_okay=True, readable=True, path_type=Path)
325
+ )
326
+ @pass_application
327
+ def cache_clear(app: Application, sections: Tuple[str, ...], paths: Tuple[Path, ...]) -> None:
328
+ """\
329
+ Clear the analysis cache.
330
+
331
+ Removes cached entries from the database. By default clears all sections.
332
+ Use --section to clear specific sections only.
333
+ """
334
+ _, db = _resolve_cache(app, paths)
335
+
336
+ if db is None:
337
+ app.echo("No cache database found.")
338
+ return
339
+
340
+ try:
341
+ selected = _parse_sections(sections)
342
+
343
+ if selected:
344
+ total = 0
345
+ for section in selected:
346
+ count = db.clear_section(section)
347
+ total += count
348
+ app.echo(f"Cleared {count} entries from {section.name.lower()}.")
349
+ else:
350
+ total = db.clear_all()
351
+
352
+ app.echo(f"Removed {total} entries total.")
353
+ finally:
354
+ db.close()
355
+
356
+
357
+ def _resolve_cache_root(
358
+ app: Application,
359
+ paths: Tuple[Path, ...],
360
+ ) -> Path:
361
+ config_files, root_folder, _ = get_config_files(
362
+ paths,
363
+ app.config.config_files,
364
+ root_folder=app.config.root,
365
+ no_vcs=app.config.no_vcs,
366
+ verbose_callback=app.verbose,
367
+ )
368
+
369
+ robot_config = load_robot_config_from_path(
370
+ *config_files, extra_tools={"robotcode-analyze": AnalyzeConfig}, verbose_callback=app.verbose
371
+ )
372
+
373
+ analyzer_config = robot_config.tool.get("robotcode-analyze", None) if robot_config.tool is not None else None
374
+
375
+ cache_base_path = root_folder or Path.cwd()
376
+ if analyzer_config is not None and isinstance(analyzer_config, AnalyzeConfig):
377
+ if analyzer_config.cache is not None and analyzer_config.cache.cache_dir is not None:
378
+ cache_base_path = Path(analyzer_config.cache.cache_dir)
379
+
380
+ cache_base_path = resolve_cache_base_path(cache_base_path)
381
+
382
+ return cache_base_path / CACHE_DIR_NAME
383
+
384
+
385
+ @cache_group.command(name="prune")
386
+ @click.option("--force", is_flag=True, help="Force prune even if cache is in use by another process.")
387
+ @click.argument(
388
+ "paths", nargs=-1, type=click.Path(exists=True, dir_okay=True, file_okay=True, readable=True, path_type=Path)
389
+ )
390
+ @pass_application
391
+ def cache_prune(app: Application, force: bool, paths: Tuple[Path, ...]) -> None:
392
+ """\
393
+ Remove the entire cache directory.
394
+
395
+ Deletes the .robotcode_cache directory and all its contents,
396
+ including caches for all Python and Robot Framework versions.
397
+ """
398
+ import shutil
399
+
400
+ cache_root = _resolve_cache_root(app, paths)
401
+
402
+ if not cache_root.exists():
403
+ app.echo(f"Cache directory does not exist: {cache_root}")
404
+ return
405
+
406
+ if not force:
407
+ locked_dirs = []
408
+ for lock_file in cache_root.rglob(_LOCK_FILE_NAME):
409
+ with exclusive_cache_lock(lock_file.parent) as acquired:
410
+ if not acquired:
411
+ locked_dirs.append(lock_file.parent)
412
+
413
+ if locked_dirs:
414
+ app.echo("Cannot prune: cache is in use by another process.")
415
+ for d in locked_dirs:
416
+ app.echo(f" Locked: {d}")
417
+ app.echo("Use --force to override.")
418
+ return
419
+
420
+ shutil.rmtree(cache_root)
421
+ app.echo(f"Removed {cache_root}")
@@ -3,6 +3,7 @@ import click
3
3
  from robotcode.plugin import Application, pass_application
4
4
 
5
5
  from .__version__ import __version__
6
+ from .cache.cli import cache_group
6
7
  from .code.cli import code
7
8
 
8
9
 
@@ -23,4 +24,5 @@ def analyze(app: Application) -> None:
23
24
  """
24
25
 
25
26
 
27
+ analyze.add_command(cache_group)
26
28
  analyze.add_command(code)
@@ -19,7 +19,7 @@ from robotcode.robot.config.loader import (
19
19
  from robotcode.robot.config.utils import get_config_files
20
20
 
21
21
  from ..__version__ import __version__
22
- from ..config import AnalyzeConfig, ExitCodeMask, ModifiersConfig
22
+ from ..config import AnalyzeConfig, CacheConfig, CodeConfig, ExitCodeMask, ModifiersConfig
23
23
  from .code_analyzer import CodeAnalyzer, DocumentDiagnosticReport, FolderDiagnosticReport
24
24
 
25
25
  SEVERITY_COLORS = {
@@ -270,6 +270,18 @@ def _validate_load_library_timeout(ctx: click.Context, param: click.Option, valu
270
270
  "Must be > 0. Overrides config file and environment variable when set."
271
271
  ),
272
272
  )
273
+ @click.option(
274
+ "--collect-unused/--no-collect-unused",
275
+ default=None,
276
+ help="Enable or disable collection of unused keyword and unused variable diagnostics. "
277
+ "Overrides the config file setting when specified.",
278
+ )
279
+ @click.option(
280
+ "--cache-namespaces/--no-cache-namespaces",
281
+ default=None,
282
+ help="Enable or disable caching of fully analyzed namespace data to disk. "
283
+ "Can speed up startup for large projects by skipping re-analysis of unchanged files.",
284
+ )
273
285
  @click.argument(
274
286
  "paths", nargs=-1, type=click.Path(exists=True, dir_okay=True, file_okay=True, readable=True, path_type=Path)
275
287
  )
@@ -289,6 +301,8 @@ def code(
289
301
  extend_exit_code_mask: ExitCodeMask,
290
302
  paths: Tuple[Path],
291
303
  load_library_timeout: Optional[int],
304
+ collect_unused: Optional[bool],
305
+ cache_namespaces: Optional[bool],
292
306
  ) -> None:
293
307
  """\
294
308
  Performs static code analysis to identify potential issues in the specified *PATHS*. The analysis detects syntax
@@ -393,6 +407,16 @@ def code(
393
407
  if load_library_timeout is not None:
394
408
  analyzer_config.load_library_timeout = load_library_timeout
395
409
 
410
+ if collect_unused is not None:
411
+ if analyzer_config.code is None:
412
+ analyzer_config.code = CodeConfig()
413
+ analyzer_config.code.collect_unused = collect_unused
414
+
415
+ if cache_namespaces is not None:
416
+ if analyzer_config.cache is None:
417
+ analyzer_config.cache = CacheConfig()
418
+ analyzer_config.cache.cache_namespaces = cache_namespaces
419
+
396
420
  app.verbose(f"Using analyzer_config: {analyzer_config}")
397
421
  app.verbose(f"Using exit code mask: {mask}")
398
422
 
@@ -403,6 +427,11 @@ def code(
403
427
  analysis_config=analyzer_config.to_workspace_analysis_config(),
404
428
  robot_profile=robot_profile,
405
429
  root_folder=root_folder,
430
+ collect_unused=bool(
431
+ analyzer_config.code.collect_unused
432
+ if analyzer_config.code is not None and analyzer_config.code.collect_unused is not None
433
+ else False
434
+ ),
406
435
  )
407
436
  try:
408
437
  for e in analyzer.run(paths=paths, filter=filter):
@@ -35,9 +35,11 @@ class CodeAnalyzer(DiagnosticsContext):
35
35
  analysis_config: WorkspaceAnalysisConfig,
36
36
  robot_profile: RobotBaseProfile,
37
37
  root_folder: Optional[Path],
38
+ collect_unused: bool = False,
38
39
  ):
39
40
  self.app = app
40
41
  self._analysis_config = analysis_config or WorkspaceAnalysisConfig()
42
+ self._collect_unused = collect_unused
41
43
 
42
44
  self._robot_profile = robot_profile
43
45
  self._root_folder = root_folder if root_folder is not None else Path.cwd()
@@ -62,6 +64,10 @@ class CodeAnalyzer(DiagnosticsContext):
62
64
  def analysis_config(self) -> WorkspaceAnalysisConfig:
63
65
  return self._analysis_config
64
66
 
67
+ @property
68
+ def collect_unused(self) -> bool:
69
+ return self._collect_unused
70
+
65
71
  @property
66
72
  def profile(self) -> RobotBaseProfile:
67
73
  return self._robot_profile
@@ -48,6 +48,10 @@ class DiagnosticsContext(ABC):
48
48
  @abstractmethod
49
49
  def analysis_config(self) -> WorkspaceAnalysisConfig: ...
50
50
 
51
+ @property
52
+ @abstractmethod
53
+ def collect_unused(self) -> bool: ...
54
+
51
55
  @property
52
56
  @abstractmethod
53
57
  def profile(self) -> RobotBaseProfile: ...
@@ -57,8 +57,8 @@ class RobotFrameworkLanguageProvider(LanguageProvider):
57
57
  self.diagnostics_context.diagnostics.folder_analyzers.add(self.analyze_folder)
58
58
  self.diagnostics_context.diagnostics.document_analyzers.add(self.analyze_document)
59
59
  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)
62
62
 
63
63
  def _update_python_path(self) -> None:
64
64
  root_path = (
@@ -121,27 +121,20 @@ class RobotFrameworkLanguageProvider(LanguageProvider):
121
121
  def collect_diagnostics(self, sender: Any, document: TextDocument) -> Optional[List[Diagnostic]]:
122
122
  namespace = self._document_cache.get_namespace(document)
123
123
 
124
- return self._document_cache.get_diagnostic_modifier(document).modify_diagnostics(namespace.get_diagnostics())
124
+ return self._document_cache.get_diagnostic_modifier(document).modify_diagnostics(namespace.diagnostics)
125
125
 
126
126
  def collect_unused_keywords(self, sender: Any, document: TextDocument) -> Optional[List[Diagnostic]]:
127
+ if not self.diagnostics_context.collect_unused:
128
+ return None
129
+
127
130
  namespace = self._document_cache.get_namespace(document)
128
131
 
129
- documents = (
130
- [document]
131
- if self._document_cache.get_document_type(document) != DocumentType.RESOURCE
132
- else self.diagnostics_context.workspace.documents.documents
133
- )
132
+ project_index = self._document_cache.get_project_index(document)
134
133
 
135
134
  result: List[Diagnostic] = []
136
135
 
137
- for kw in (namespace.get_library_doc()).keywords.values():
138
- has_reference = False
139
- for doc in documents:
140
- refs = self._document_cache.get_namespace(doc).get_keyword_references()
141
- if refs.get(kw):
142
- has_reference = True
143
- break
144
- if not has_reference:
136
+ for kw in namespace.library_doc.keywords.values():
137
+ if not project_index.find_keyword_references(kw):
145
138
  result.append(
146
139
  Diagnostic(
147
140
  range=kw.name_range,
@@ -156,11 +149,15 @@ class RobotFrameworkLanguageProvider(LanguageProvider):
156
149
  return result
157
150
 
158
151
  def collect_unused_variables(self, sender: Any, document: TextDocument) -> Optional[List[Diagnostic]]:
152
+ if not self.diagnostics_context.collect_unused:
153
+ return None
154
+
159
155
  result: List[Diagnostic] = []
160
156
 
161
157
  namespace = self._document_cache.get_namespace(document)
158
+ project_index = self._document_cache.get_project_index(document)
162
159
 
163
- for var, locations in (namespace.get_variable_references()).items():
160
+ for var, locations in namespace.variable_references.items():
164
161
  if var.type in (
165
162
  VariableDefinitionType.LIBRARY_ARGUMENT,
166
163
  VariableDefinitionType.ENVIRONMENT_VARIABLE,
@@ -182,16 +179,7 @@ class RobotFrameworkLanguageProvider(LanguageProvider):
182
179
  )
183
180
  and self._document_cache.get_document_type(document) == DocumentType.RESOURCE
184
181
  ):
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).get_variable_references().get(var, set())) > 0
193
- for doc in self.diagnostics_context.workspace.documents.documents
194
- )
182
+ has_reference = bool(project_index.find_variable_references(var))
195
183
 
196
184
  if not has_reference:
197
185
  result.append(
@@ -197,6 +197,21 @@ class CacheConfig(BaseOptions):
197
197
  description="Extend the ignore arguments for library settings."
198
198
  )
199
199
 
200
+ cache_namespaces: Optional[bool] = field(
201
+ description="""\
202
+ Enable or disable caching of fully analyzed namespace data to disk.
203
+ Can speed up startup for large projects by skipping re-analysis of unchanged files.
204
+ Defaults to enabled.
205
+
206
+ Examples:
207
+
208
+ ```toml
209
+ [tool.robotcode-analyze.cache]
210
+ cache_namespaces = false
211
+ ```
212
+ """,
213
+ )
214
+
200
215
 
201
216
  class ExitCodeMask(IntFlag):
202
217
  NONE = 0
@@ -247,6 +262,20 @@ class CodeConfig(BaseOptions):
247
262
  )
248
263
  extend_exit_code_mask: Optional[ExitCodeMaskList] = field(description="Extend the exit code mask setting.")
249
264
 
265
+ collect_unused: Optional[bool] = field(
266
+ description="""\
267
+ Enables collection of unused keyword and unused variable diagnostics.
268
+ By default this is disabled. Set to `true` to report unused keywords and variables.
269
+
270
+ Examples:
271
+
272
+ ```toml
273
+ [tool.robotcode-analyze.code]
274
+ collect_unused = true
275
+ ```
276
+ """,
277
+ )
278
+
250
279
 
251
280
  @dataclass
252
281
  class AnalyzeConfig(BaseOptions):
@@ -358,6 +387,7 @@ class AnalyzeConfig(BaseOptions):
358
387
  ignored_libraries=self.cache.ignored_libraries or [],
359
388
  ignored_variables=self.cache.ignored_variables or [],
360
389
  ignore_arguments_for_library=self.cache.ignore_arguments_for_library or [],
390
+ cache_namespaces=(self.cache.cache_namespaces if self.cache.cache_namespaces is not None else True),
361
391
  )
362
392
  if self.cache is not None
363
393
  else WorkspaceCacheConfig()
@@ -1 +0,0 @@
1
- __version__ = "2.4.0"