robotcode-robot 2.6.2__py3-none-any.whl → 2.8.0.dev548__py3-none-any.whl

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 (32) hide show
  1. robotcode/robot/__version__.py +1 -1
  2. robotcode/robot/config/loader.py +18 -5
  3. robotcode/robot/config/model.py +258 -61
  4. robotcode/robot/diagnostics/analyzer_result.py +1 -0
  5. robotcode/robot/diagnostics/data_cache.py +12 -1
  6. robotcode/robot/diagnostics/diagnostic_rules.py +75 -0
  7. robotcode/robot/diagnostics/diagnostics_modifier.py +83 -43
  8. robotcode/robot/diagnostics/document_cache_helper.py +158 -17
  9. robotcode/robot/diagnostics/errors.py +7 -0
  10. robotcode/robot/diagnostics/import_resolver.py +178 -12
  11. robotcode/robot/diagnostics/imports_manager.py +556 -220
  12. robotcode/robot/diagnostics/library_doc.py +874 -162
  13. robotcode/robot/diagnostics/namespace.py +84 -18
  14. robotcode/robot/diagnostics/namespace_analyzer.py +59 -16
  15. robotcode/robot/diagnostics/project_index.py +88 -13
  16. robotcode/robot/diagnostics/scope_tree.py +2 -2
  17. robotcode/robot/diagnostics/semantic_analyzer/analyzer.py +790 -201
  18. robotcode/robot/diagnostics/semantic_analyzer/enums.py +39 -2
  19. robotcode/robot/diagnostics/semantic_analyzer/json_dump.py +364 -0
  20. robotcode/robot/diagnostics/semantic_analyzer/model.py +72 -11
  21. robotcode/robot/diagnostics/semantic_analyzer/nodes.py +7 -1
  22. robotcode/robot/diagnostics/semantic_analyzer/variable_tokenizer.py +218 -112
  23. robotcode/robot/diagnostics/workspace_config.py +3 -0
  24. robotcode/robot/utils/markdown_docs.py +733 -0
  25. robotcode/robot/utils/markdownformatter.py +57 -12
  26. robotcode/robot/utils/match.py +13 -0
  27. robotcode/robot/utils/robot_patching.py +4 -0
  28. robotcode/robot/utils/variables.py +3 -0
  29. {robotcode_robot-2.6.2.dist-info → robotcode_robot-2.8.0.dev548.dist-info}/METADATA +4 -3
  30. robotcode_robot-2.8.0.dev548.dist-info/RECORD +48 -0
  31. {robotcode_robot-2.6.2.dist-info → robotcode_robot-2.8.0.dev548.dist-info}/WHEEL +1 -1
  32. robotcode_robot-2.6.2.dist-info/RECORD +0 -46
@@ -7,7 +7,7 @@ from contextlib import contextmanager
7
7
  from dataclasses import dataclass
8
8
  from enum import Enum
9
9
  from pathlib import Path
10
- from typing import Any, Callable, Generic, Iterator, List, Optional, Tuple, Type, TypeVar, Union
10
+ from typing import Any, Callable, Generic, Iterable, Iterator, List, Optional, Tuple, Type, TypeVar, Union
11
11
 
12
12
  from robotcode.core.utils.logging import LoggingDescriptor
13
13
 
@@ -495,6 +495,17 @@ class SqliteDataCache:
495
495
  for r in rows
496
496
  ]
497
497
 
498
+ def remove_entries(self, section: CacheSection, entry_names: Iterable[str]) -> int:
499
+ """Remove the entries with these names from a section, and return how many of them there were."""
500
+ rows = [(name,) for name in entry_names]
501
+
502
+ def op() -> int:
503
+ cursor = self._conn.executemany(f"DELETE FROM {section.value} WHERE entry_name = ?", rows)
504
+ self._conn.commit()
505
+ return cursor.rowcount
506
+
507
+ return self._run(op)
508
+
498
509
  def clear_section(self, section: CacheSection) -> int:
499
510
  def op() -> int:
500
511
  cursor = self._conn.execute(f"DELETE FROM {section.value}")
@@ -1,6 +1,14 @@
1
1
  """Shared diagnostic rule helpers."""
2
2
 
3
+ import re
4
+ from itertools import groupby
5
+ from typing import List, Optional
6
+
7
+ from robot.parsing.lexer.tokens import Token
8
+ from robot.parsing.model.statements import Statement
9
+
3
10
  from .entities import VariableDefinition
11
+ from .library_doc import KeywordDoc
4
12
 
5
13
 
6
14
  def is_variable_name_intentionally_unused(variable: VariableDefinition) -> bool:
@@ -10,3 +18,70 @@ def is_variable_name_intentionally_unused(variable: VariableDefinition) -> bool:
10
18
  and bool(variable.name_token.value)
11
19
  and variable.name_token.value.startswith("_")
12
20
  )
21
+
22
+
23
+ def private_keyword_call_message(keyword: KeywordDoc, source: Optional[str]) -> Optional[str]:
24
+ """The `PrivateKeyword` message for a call of `keyword` in the file `source`, or `None` if the call is fine.
25
+
26
+ The file that contains the call decides, not the keyword that calls: Robot Framework also warns when
27
+ a test case calls a private keyword of its own suite file, which is robotframework/robotframework#5807.
28
+ """
29
+ if not keyword.is_private or keyword.source == source:
30
+ return None
31
+ if keyword.is_resource_keyword:
32
+ return f"Keyword '{keyword.longname}' is private and should only be called by keywords in the same file."
33
+ if keyword.is_library_keyword:
34
+ return f"Keyword '{keyword.longname}' is private and should not be called from Robot Framework files."
35
+ return None
36
+
37
+
38
+ # The section headers of keyword documentation, as Robot Framework 7.5 recognizes them.
39
+ _DOC_SECTION_HEADER = re.compile(
40
+ r"[*_]*(args|arguments|parameters|returns|return|yields|raises|raise|tags)[*_]*::?[*_]*(\s+.*)?",
41
+ re.IGNORECASE,
42
+ )
43
+ _TRAILING_BACKSLASH_OR_NEWLINE = re.compile(r"(\\+)n?$")
44
+
45
+
46
+ def tags_line_without_empty_row(doc: str) -> Optional[int]:
47
+ """Index of the first `Tags:` line Robot Framework 7.5 warns about, or `None`.
48
+
49
+ Robot Framework accepts a section header only where a section may start:
50
+ on the first line, after an empty line and inside a named section
51
+ (`Args:`, `Returns:`, ...), whose indented and empty lines belong to it.
52
+ A `Tags:` header anywhere else is still used but deprecated.
53
+ """
54
+ in_named_section = False
55
+ can_start = True
56
+ for index, line in enumerate(doc.splitlines()):
57
+ header = _DOC_SECTION_HEADER.fullmatch(line)
58
+ if header and can_start:
59
+ in_named_section = True
60
+ elif header and header.group(1).lower() == "tags":
61
+ return index
62
+ elif in_named_section and line and not line[:2].isspace():
63
+ in_named_section = False
64
+ can_start = in_named_section or not line.strip()
65
+ return None
66
+
67
+
68
+ def tags_row_without_empty_row(documentation: Statement) -> Optional[List[Token]]:
69
+ """The tokens of the `[Documentation]` row with a `Tags:` header Robot Framework warns about, or `None`."""
70
+ index = tags_line_without_empty_row(documentation.value)
71
+ if index is None:
72
+ return None
73
+
74
+ # `value` has one line per row, except that a row ending with a backslash or `\n` continues on the next row
75
+ line = 0
76
+ for _, row_tokens in groupby(documentation.get_tokens(Token.ARGUMENT), key=lambda t: t.lineno):
77
+ row = list(row_tokens)
78
+ if line == index:
79
+ return row
80
+ if not _has_trailing_backslash_or_newline(row[-1].value):
81
+ line += 1
82
+ return None
83
+
84
+
85
+ def _has_trailing_backslash_or_newline(value: str) -> bool:
86
+ match = _TRAILING_BACKSLASH_OR_NEWLINE.search(value)
87
+ return bool(match and len(match.group(1)) % 2 == 1)
@@ -4,7 +4,7 @@ from ast import AST
4
4
  from collections import defaultdict
5
5
  from dataclasses import dataclass, field
6
6
  from enum import Enum
7
- from typing import Dict, List, Optional, Set, Union
7
+ from typing import Dict, List, Optional, Set, Tuple, Union
8
8
 
9
9
  from robot.parsing.lexer.tokens import Token
10
10
  from robot.parsing.model.blocks import Block, File
@@ -13,7 +13,8 @@ from robotcode.core.lsp.types import Diagnostic, DiagnosticSeverity
13
13
 
14
14
  from ..utils.visitor import Visitor
15
15
 
16
- ACTIONS = ["ignore", "error", "warn", "warning", "info", "information", "hint", "reset"]
16
+ # The long names come before their short forms, the pattern takes the first alternative that matches.
17
+ ACTIONS = ["ignore", "error", "warning", "warn", "information", "info", "hint", "reset"]
17
18
 
18
19
  ROBOTCODE_ACTION_AND_CODES_PATTERN = re.compile(rf"(?P<action>{'|'.join(ACTIONS)})(\[(?P<codes>[^\]]*?)\])?")
19
20
 
@@ -35,10 +36,22 @@ class ModifierAction(Enum):
35
36
  return cls(value)
36
37
 
37
38
 
39
+ # How near a modifier is to the line it affects: an inline modifier at the end of the line, an indented comment in
40
+ # a block, or a top-level comment of the file.
41
+ _FILE_LEVEL = 0
42
+ _BLOCK_LEVEL = 1
43
+ _INLINE_LEVEL = 2
44
+
45
+ # The rank of a modifier: its level, then the line of its comment, then its position in the comment. The higher rank
46
+ # wins, also between a modifier for a code and one without a list (`*`).
47
+ Rank = Tuple[int, int, int]
48
+
49
+
38
50
  @dataclass
39
51
  class RulesAndCodes:
40
52
  codes: Dict[Union[str, int], Set[int]]
41
53
  actions: Dict[int, Dict[Union[str, int], ModifierAction]]
54
+ ranks: Dict[int, Dict[Union[str, int], Rank]] = field(default_factory=lambda: defaultdict(dict))
42
55
 
43
56
 
44
57
  _translation_table = str.maketrans("", "", "_- ")
@@ -79,15 +92,19 @@ class ModifiersVisitor(Visitor):
79
92
  self.generic_visit(node)
80
93
 
81
94
  def visit_Block(self, node: Block) -> None: # noqa: N802
95
+ # Restored afterwards, so that a modifier after a nested block (IF, FOR, ...) belongs to the outer block.
96
+ parent_block = self.current_block
82
97
  self.current_block = node
83
98
  self.generic_visit(node)
99
+ self.current_block = parent_block
84
100
 
85
101
  def visit_Statement(self, node: Statement) -> None: # noqa: N802
86
102
  self._handle_statement_comments(node)
87
103
  self.generic_visit(node)
88
104
 
89
- def _parse_robotcode_disabler(self, comment: str) -> Dict[ModifierAction, List[str]]:
90
- result: Dict[ModifierAction, List[str]] = {}
105
+ def _parse_actions(self, comment: str) -> List[Tuple[ModifierAction, str]]:
106
+ """The actions of a modifier comment and their codes, in the order they are written."""
107
+ result: List[Tuple[ModifierAction, str]] = []
91
108
 
92
109
  comment = comment.strip()
93
110
  m = ROBOTCODE_ACTION_AND_CODES_PATTERN.match(comment)
@@ -98,17 +115,35 @@ class ModifiersVisitor(Visitor):
98
115
  action_str = m.group("action")
99
116
  action = ModifierAction.from_str(action_str)
100
117
  messages = m.group("codes")
101
- result[action] = (
118
+ codes = (
102
119
  [m.strip().translate(_translation_table).lower() for m in messages.split(",")]
103
120
  if messages is not None
104
121
  else ["*"]
105
122
  )
123
+ for code in codes:
124
+ # A code belongs to the rightmost action that names it, also when an action appears more than once.
125
+ result = [entry for entry in result if entry[1] != code]
126
+ result.append((action, code))
106
127
 
107
128
  return result
108
129
 
130
+ def _parse_robotcode_disabler(self, comment: str) -> Dict[ModifierAction, List[str]]:
131
+ result: Dict[ModifierAction, List[str]] = {}
132
+ for action, code in self._parse_actions(comment):
133
+ result.setdefault(action, []).append(code)
134
+ return result
135
+
136
+ def _add_rule(self, lineno: int, code: str, action: ModifierAction, rank: Rank) -> None:
137
+ self.rules_and_codes.codes[code].add(lineno)
138
+ current = self.rules_and_codes.ranks[lineno].get(code)
139
+ if current is None or rank >= current:
140
+ self.rules_and_codes.actions[lineno][code] = action
141
+ self.rules_and_codes.ranks[lineno][code] = rank
142
+
109
143
  def _handle_statement_comments(self, node: Statement) -> None:
110
144
  first_comment = True
111
145
  has_marker = False
146
+ position = 0
112
147
 
113
148
  for token in node.get_tokens(Token.COMMENT):
114
149
  value = token.value.strip()
@@ -127,15 +162,15 @@ class ModifiersVisitor(Visitor):
127
162
  continue
128
163
 
129
164
  if has_marker:
130
- actions = self._parse_robotcode_disabler(value)
165
+ actions = self._parse_actions(value)
131
166
 
132
167
  if not actions:
133
168
  break
134
169
 
135
- for action, codes in actions.items():
136
- for code in codes:
137
- self.rules_and_codes.codes[code].add(token.lineno - 1)
138
- self.rules_and_codes.actions[token.lineno - 1][code] = action
170
+ lineno = token.lineno - 1
171
+ for action, code in actions:
172
+ self._add_rule(lineno, code, action, (_INLINE_LEVEL, lineno, position))
173
+ position += 1
139
174
 
140
175
  def _handle_comment(self, node: Comment) -> None:
141
176
  first_comment = True
@@ -143,9 +178,12 @@ class ModifiersVisitor(Visitor):
143
178
 
144
179
  start_lineno = node.lineno - 1
145
180
  end_lineno = self.file_end_lineno
181
+ level = _FILE_LEVEL
182
+ position = 0
146
183
 
147
184
  if node.tokens[0].type == Token.SEPARATOR and self.current_block is not None:
148
185
  end_lineno = self.current_block.end_lineno - 1
186
+ level = _BLOCK_LEVEL
149
187
  for token in node.get_tokens(Token.COMMENT):
150
188
  value = token.value.strip()
151
189
  if first_comment and value.startswith("#"):
@@ -163,16 +201,16 @@ class ModifiersVisitor(Visitor):
163
201
  continue
164
202
 
165
203
  if has_marker:
166
- actions = self._parse_robotcode_disabler(value)
204
+ actions = self._parse_actions(value)
167
205
 
168
206
  if not actions:
169
207
  break
170
208
 
171
- for action, codes in actions.items():
172
- for code in codes:
173
- self.rules_and_codes.codes[code].update(range(start_lineno, end_lineno + 1))
174
- for i in range(start_lineno, end_lineno + 1):
175
- self.rules_and_codes.actions[i][code] = action
209
+ for action, code in actions:
210
+ rank = (level, start_lineno, position)
211
+ position += 1
212
+ for i in range(start_lineno, end_lineno + 1):
213
+ self._add_rule(i, code, action, rank)
176
214
 
177
215
 
178
216
  @dataclass
@@ -211,34 +249,25 @@ class DiagnosticsModifier:
211
249
  else "unknowncode"
212
250
  )
213
251
 
214
- lines = self.rules_and_codes.codes.get(code)
215
-
216
- if lines is None or (lines is not None and diagnostic.range.start.line not in lines):
217
- code = "*"
218
- lines = self.rules_and_codes.codes.get(code)
219
-
220
252
  modified = False
221
- if lines is not None and diagnostic.range.start.line in lines:
222
- actions = self.rules_and_codes.actions.get(diagnostic.range.start.line)
223
- if actions is not None:
224
- action = actions.get(code)
225
- if action is not None:
226
- if action == ModifierAction.IGNORE:
227
- return None
228
- if action == ModifierAction.RESET:
229
- pass
230
- elif action == ModifierAction.ERROR:
231
- modified = True
232
- diagnostic.severity = DiagnosticSeverity.ERROR
233
- elif action == ModifierAction.WARNING:
234
- modified = True
235
- diagnostic.severity = DiagnosticSeverity.WARNING
236
- elif action == ModifierAction.INFORMATION:
237
- modified = True
238
- diagnostic.severity = DiagnosticSeverity.INFORMATION
239
- elif action == ModifierAction.HINT:
240
- modified = True
241
- diagnostic.severity = DiagnosticSeverity.HINT
253
+ action = self._action_for(diagnostic.range.start.line, code)
254
+ if action is not None:
255
+ if action == ModifierAction.IGNORE:
256
+ return None
257
+ if action == ModifierAction.RESET:
258
+ pass
259
+ elif action == ModifierAction.ERROR:
260
+ modified = True
261
+ diagnostic.severity = DiagnosticSeverity.ERROR
262
+ elif action == ModifierAction.WARNING:
263
+ modified = True
264
+ diagnostic.severity = DiagnosticSeverity.WARNING
265
+ elif action == ModifierAction.INFORMATION:
266
+ modified = True
267
+ diagnostic.severity = DiagnosticSeverity.INFORMATION
268
+ elif action == ModifierAction.HINT:
269
+ modified = True
270
+ diagnostic.severity = DiagnosticSeverity.HINT
242
271
 
243
272
  if not modified:
244
273
  if orig_code in self.config.ignore:
@@ -264,5 +293,16 @@ class DiagnosticsModifier:
264
293
 
265
294
  return diagnostic
266
295
 
296
+ def _action_for(self, line: int, code: str) -> Optional[ModifierAction]:
297
+ """The action of the nearest modifier on `line` for `code`: one that names the code, or one without a list."""
298
+ actions = self.rules_and_codes.actions.get(line)
299
+ if not actions:
300
+ return None
301
+ ranks = self.rules_and_codes.ranks.get(line, {})
302
+ keys = [key for key in (code, "*") if key in actions]
303
+ if not keys:
304
+ return None
305
+ return actions[max(keys, key=lambda key: ranks.get(key, (-1, -1, -1)))]
306
+
267
307
  def modify_diagnostics(self, diagnostics: List[Diagnostic]) -> List[Diagnostic]:
268
308
  return [d for d in map(self.modify_diagnostic, diagnostics) if d is not None]
@@ -8,6 +8,7 @@ from logging import CRITICAL
8
8
  from pathlib import Path
9
9
  from typing import (
10
10
  Any,
11
+ Dict,
11
12
  Iterable,
12
13
  Iterator,
13
14
  List,
@@ -24,6 +25,7 @@ from robotcode.core.filewatcher import FileWatcherManagerBase
24
25
  from robotcode.core.text_document import TextDocument
25
26
  from robotcode.core.uri import Uri
26
27
  from robotcode.core.utils.logging import LoggingDescriptor
28
+ from robotcode.core.utils.path import DiskInfo, normalized_path
27
29
  from robotcode.core.workspace import Workspace, WorkspaceFolder
28
30
  from robotcode.robot.diagnostics.diagnostics_modifier import (
29
31
  DiagnosticModifiersConfig,
@@ -58,6 +60,23 @@ class UnknownFileTypeError(Exception):
58
60
  pass
59
61
 
60
62
 
63
+ def _namespace_cache_key(source: str, document_type: Optional[DocumentType]) -> str:
64
+ """Disk-cache entry name for a namespace.
65
+
66
+ Includes the document type because the same file can be analyzed both as
67
+ a suite (GENERAL) and as an imported resource (RESOURCE) — the results
68
+ must not overwrite each other. "\\n" as separator is impossible in Windows
69
+ paths and never appears in real-world POSIX paths (where it is technically
70
+ legal); the key is parsed back only to tell the source of an entry.
71
+ """
72
+ return f"{source}\n{document_type.value if document_type is not None else ''}"
73
+
74
+
75
+ def _namespace_cache_source(entry_name: str) -> str:
76
+ """The source of a namespace entry in the disk cache, see `_namespace_cache_key`."""
77
+ return entry_name.split("\n", 1)[0]
78
+
79
+
61
80
  class _CacheEntry:
62
81
  pass
63
82
 
@@ -108,15 +127,30 @@ class DocumentsCacheHelper:
108
127
 
109
128
  with self._project_indexes_lock:
110
129
  if folder not in self._project_indexes:
111
- self._project_indexes[folder] = ProjectIndex()
130
+ self._project_indexes[folder] = self._create_project_index()
112
131
  return self._project_indexes[folder]
113
132
 
114
133
  def default_project_index(self) -> ProjectIndex:
115
134
  with self._project_indexes_lock:
116
135
  if self._default_project_index is None:
117
- self._default_project_index = ProjectIndex()
136
+ self._default_project_index = self._create_project_index()
118
137
  return self._default_project_index
119
138
 
139
+ def _create_project_index(self) -> ProjectIndex:
140
+ documents_manager = self.documents_manager
141
+ # converting a path is most of the cost of a lookup, and the URI of a file does not change
142
+ uris: Dict[str, Uri] = {}
143
+
144
+ # only the files the documents manager still holds count; the check refers to the documents manager and
145
+ # not to this helper, which would make the helper and its indexes a reference cycle
146
+ def is_known_source(source: str) -> bool:
147
+ uri = uris.get(source)
148
+ if uri is None:
149
+ uri = uris[source] = Uri.from_path(source).normalized()
150
+ return documents_manager.get(uri) is not None
151
+
152
+ return ProjectIndex(is_known_source)
153
+
120
154
  def get_languages_for_document(self, document_or_uri: Union[TextDocument, Uri, str]) -> Optional[Languages]:
121
155
  if RF_VERSION < (6, 0):
122
156
  return None
@@ -405,9 +439,7 @@ class DocumentsCacheHelper:
405
439
  def namespace_invalidated(sender, namespace: Namespace) -> None: ...
406
440
 
407
441
  def _invalidate_namespace(self, sender: Namespace) -> None:
408
- if sender.document is not None:
409
- self.get_project_index(sender.document).remove_file(sender.source)
410
-
442
+ # the file's references stay in the index until its next analysis replaces them
411
443
  document = sender.document
412
444
  if document is not None:
413
445
  document.remove_cache_entry(self.__get_general_namespace)
@@ -438,11 +470,16 @@ class DocumentsCacheHelper:
438
470
  imports_manager = self.get_imports_manager(document)
439
471
  semantic_model_enabled = self._is_semantic_model_enabled(document)
440
472
 
473
+ # Capture the disk state before the model is parsed: this snapshot can
474
+ # only be older than the analyzed content, never newer, so anything
475
+ # persisted under it self-heals with a cache miss.
476
+ disk_info = document.disk_info
477
+
441
478
  # --- Try disk cache (cold-start acceleration) ---
442
479
  cache_namespaces = self.analysis_config.cache.cache_namespaces
443
- if cache_namespaces and document.version is None:
480
+ if cache_namespaces and document.version is None and disk_info is not None:
444
481
  result = self._try_load_cached_namespace(
445
- source, document, document_type, imports_manager, semantic_model_enabled
482
+ source, document, document_type, imports_manager, semantic_model_enabled, disk_info
446
483
  )
447
484
  if result is not None:
448
485
  return result
@@ -457,6 +494,13 @@ class DocumentsCacheHelper:
457
494
  else:
458
495
  model = self.get_model(document)
459
496
 
497
+ # The document may have been re-read from disk while the model was
498
+ # parsed; the captured snapshot then no longer describes the parsed
499
+ # content and must not label anything written during the build (the
500
+ # builder writes RESOURCE cache entries under this snapshot).
501
+ if disk_info is not None and document.disk_info != disk_info:
502
+ disk_info = None
503
+
460
504
  languages, workspace_languages = self.build_languages_from_model(document, model)
461
505
  builder = NamespaceBuilder(
462
506
  imports_manager,
@@ -466,14 +510,20 @@ class DocumentsCacheHelper:
466
510
  document_type,
467
511
  languages,
468
512
  workspace_languages,
513
+ disk_info=disk_info,
469
514
  )
470
515
  builder.set_semantic_model_enabled(semantic_model_enabled)
471
516
 
472
517
  result = builder.build()
473
518
 
474
- # Save to disk cache
475
- if cache_namespaces:
476
- self._save_namespace_to_cache(source, result, imports_manager, semantic_model_enabled)
519
+ # Save to disk cache — the re-checks in _namespace_is_cacheable catch
520
+ # a watcher re-read (disk_info) and a didOpen on another thread
521
+ # (version) that happened during the build.
522
+ if cache_namespaces and self._namespace_is_cacheable(document, disk_info):
523
+ assert disk_info is not None
524
+ self._save_namespace_to_cache(
525
+ source, document, document_type, result, imports_manager, disk_info, semantic_model_enabled
526
+ )
477
527
 
478
528
  # Update the folder-scoped reference index
479
529
  self.get_project_index(document).update_file(result.source, result)
@@ -491,6 +541,18 @@ class DocumentsCacheHelper:
491
541
  experimental_config = self.workspace.get_configuration(ExperimentalConfig, document.uri)
492
542
  return self.analysis_config.semantic_model or experimental_config.semantic_model
493
543
 
544
+ @staticmethod
545
+ def _namespace_is_cacheable(document: TextDocument, disk_info: Optional[DiskInfo]) -> bool:
546
+ """Whether a namespace built from this document may be persisted.
547
+
548
+ Only when the analyzed content is the on-disk state: not an editor
549
+ buffer (version), backed by a trusted snapshot, and the document still
550
+ holds exactly that snapshot.
551
+ """
552
+ return (
553
+ document.version is None and disk_info is not None and disk_info.trusted and document.disk_info == disk_info
554
+ )
555
+
494
556
  def _try_load_cached_namespace(
495
557
  self,
496
558
  source: str,
@@ -498,10 +560,14 @@ class DocumentsCacheHelper:
498
560
  document_type: Optional[DocumentType],
499
561
  imports_manager: ImportsManager,
500
562
  semantic_model_enabled: bool,
563
+ disk_info: DiskInfo,
501
564
  ) -> Optional[Namespace]:
502
565
  """Attempt to load a Namespace from the disk cache.
503
566
 
504
- Returns None on cache miss or validation failure.
567
+ ``disk_info`` is the snapshot of the document content the namespace
568
+ will represent; the cached entry is validated against it, not against
569
+ the current disk state. Returns None on cache miss or validation
570
+ failure.
505
571
  """
506
572
  data_cache = imports_manager.data_cache
507
573
 
@@ -510,7 +576,9 @@ class DocumentsCacheHelper:
510
576
  return None
511
577
 
512
578
  try:
513
- entry = data_cache.read_entry(CacheSection.NAMESPACE, source, NamespaceMetaData, NamespaceData)
579
+ entry = data_cache.read_entry(
580
+ CacheSection.NAMESPACE, _namespace_cache_key(source, document_type), NamespaceMetaData, NamespaceData
581
+ )
514
582
  except (SystemExit, KeyboardInterrupt):
515
583
  raise
516
584
  except BaseException as e:
@@ -536,7 +604,7 @@ class DocumentsCacheHelper:
536
604
  )
537
605
  return None
538
606
 
539
- if not imports_manager.validate_namespace_meta(meta):
607
+ if not imports_manager.validate_namespace_meta(meta, disk_info):
540
608
  self._logger.debug(
541
609
  lambda: f"Cache miss for {source}: cached entry is stale, re-analyzing",
542
610
  context_name="cache",
@@ -585,22 +653,93 @@ class DocumentsCacheHelper:
585
653
 
586
654
  return result
587
655
 
656
+ def remove_cached_analysis(self, path: Path) -> None:
657
+ """Remove the entries of a Robot Framework file from the analysis cache, as for a file that was deleted."""
658
+ uri = Uri.from_path(path).normalized()
659
+ source = str(uri.to_path())
660
+ try:
661
+ imports_manager = self.get_imports_manager_for_uri(uri)
662
+ imports_manager.data_cache.remove_entries(
663
+ CacheSection.NAMESPACE, [_namespace_cache_key(source, document_type) for document_type in DocumentType]
664
+ )
665
+ imports_manager.remove_cached_resource_docs([str(normalized_path(source))])
666
+ except (SystemExit, KeyboardInterrupt):
667
+ raise
668
+ except BaseException as e:
669
+ ex = e
670
+ self._logger.debug(
671
+ lambda: f"Failed to remove the analysis cache entries of {source}: {ex}",
672
+ context_name="cache",
673
+ )
674
+
675
+ def remove_cached_analyses_of_missing_files(self, folder: WorkspaceFolder) -> None:
676
+ """Remove the entries of the files that do not exist from the analysis cache of a workspace folder."""
677
+ imports_manager = self.get_imports_manager_for_workspace_folder(folder)
678
+ data_cache = imports_manager.data_cache
679
+ try:
680
+ for section, source_of in (
681
+ (CacheSection.NAMESPACE, _namespace_cache_source),
682
+ (CacheSection.RESOURCE, str),
683
+ ):
684
+ missing = [
685
+ entry.entry_name
686
+ for entry in data_cache.list_entries(section)
687
+ if not Path(source_of(entry.entry_name)).exists()
688
+ ]
689
+ if not missing:
690
+ continue
691
+ if section == CacheSection.RESOURCE:
692
+ imports_manager.remove_cached_resource_docs(missing)
693
+ else:
694
+ data_cache.remove_entries(section, missing)
695
+ except (SystemExit, KeyboardInterrupt):
696
+ raise
697
+ except BaseException as e:
698
+ ex = e
699
+ self._logger.debug(
700
+ lambda: f"Failed to remove the cache entries of missing files: {ex}", context_name="cache"
701
+ )
702
+
588
703
  def _save_namespace_to_cache(
589
704
  self,
590
705
  source: str,
706
+ document: TextDocument,
707
+ document_type: Optional[DocumentType],
591
708
  namespace: Namespace,
592
709
  imports_manager: ImportsManager,
710
+ disk_info: DiskInfo,
593
711
  semantic_model_enabled: bool,
594
712
  ) -> None:
595
- """Save a Namespace to the disk cache."""
713
+ """Save a Namespace to the disk cache.
714
+
715
+ ``disk_info`` must be the snapshot captured before the namespace was
716
+ built; the entry is skipped when the namespace's state is not fully
717
+ trustworthy or the document changed in the meantime.
718
+ """
596
719
  try:
597
720
  meta = imports_manager.build_namespace_meta(
598
- source, namespace, semantic_model_enabled=semantic_model_enabled
721
+ source, namespace, disk_info, semantic_model_enabled=semantic_model_enabled
599
722
  )
723
+ if meta is None:
724
+ self._logger.debug(
725
+ lambda: f"Skip caching namespace for {source}: state not trustworthy",
726
+ context_name="cache",
727
+ )
728
+ return
729
+
600
730
  data = namespace.to_data()
601
731
 
732
+ # to_data() takes time — re-check that the document still holds
733
+ # exactly the content this namespace was built from.
734
+ if not self._namespace_is_cacheable(document, disk_info):
735
+ self._logger.debug(
736
+ lambda: f"Skip caching namespace for {source}: document changed during save",
737
+ context_name="cache",
738
+ )
739
+ return
740
+
602
741
  data_cache = imports_manager.data_cache
603
- data_cache.save_entry(CacheSection.NAMESPACE, source, meta, data)
742
+ data_cache.save_entry(CacheSection.NAMESPACE, _namespace_cache_key(source, document_type), meta, data)
604
743
  except (SystemExit, KeyboardInterrupt):
605
744
  raise
606
745
  except BaseException as e:
@@ -707,7 +846,9 @@ class DocumentsCacheHelper:
707
846
  def calc_cache_path(self, folder_uri: Uri) -> Path:
708
847
  from .data_cache import resolve_cache_base_path
709
848
 
710
- return resolve_cache_base_path(folder_uri.to_path())
849
+ folder = folder_uri.to_path()
850
+ cache_dir = self.analysis_config.cache_dir
851
+ return resolve_cache_base_path(folder / cache_dir if cache_dir else folder)
711
852
 
712
853
  def get_diagnostic_modifier(self, document: TextDocument) -> DiagnosticsModifier:
713
854
  return document.get_cache(self.__get_diagnostic_modifier)
@@ -20,6 +20,12 @@ class Error:
20
20
  VARIABLES_ALREADY_IMPORTED = "VariablesAlreadyImported"
21
21
  LIBRARY_ALREADY_IMPORTED = "LibraryAlreadyImported"
22
22
  LIBRARY_OVERRIDES_BUILTIN = "LibraryOverridesBuiltIn"
23
+ LIBRARY_IMPORT_IGNORED = "LibraryImportIgnored"
24
+ LIBRARY_LOADED_WITHOUT_ARGUMENTS = "LibraryLoadedWithoutArguments"
25
+ LIBRARY_TIMEOUT_ERROR = "LibraryTimeoutError"
26
+ VARIABLES_TIMEOUT_ERROR = "VariablesTimeoutError"
27
+ LIBRARY_EXIT_ERROR = "LibraryExitError"
28
+ VARIABLES_EXIT_ERROR = "VariablesExitError"
23
29
  DEPRECATED_KEYWORD = "DeprecatedKeyword"
24
30
  KEYWORD_CONTAINS_ERRORS = "KeywordContainsErrors"
25
31
  RESERVED_KEYWORD = "ReservedKeyword"
@@ -32,6 +38,7 @@ class Error:
32
38
  DEPRECATED_HYPHEN_TAG = "DeprecatedHyphenTag"
33
39
  DEPRECATED_RETURN_SETTING = "DeprecatedReturnSetting"
34
40
  DEPRECATED_FORCE_TAG = "DeprecatedForceTag"
41
+ TAGS_WITHOUT_EMPTY_ROW = "TagsWithoutEmptyRow"
35
42
  IMPORT_REQUIRES_VALUE = "ImportRequiresValue"
36
43
  KEYWORD_ERROR = "KeywordError"
37
44
  MULTIPLE_KEYWORDS = "MultipleKeywords"