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.
- robotcode/robot/__version__.py +1 -1
- robotcode/robot/config/loader.py +18 -5
- robotcode/robot/config/model.py +258 -61
- robotcode/robot/diagnostics/analyzer_result.py +1 -0
- robotcode/robot/diagnostics/data_cache.py +12 -1
- robotcode/robot/diagnostics/diagnostic_rules.py +75 -0
- robotcode/robot/diagnostics/diagnostics_modifier.py +83 -43
- robotcode/robot/diagnostics/document_cache_helper.py +158 -17
- robotcode/robot/diagnostics/errors.py +7 -0
- robotcode/robot/diagnostics/import_resolver.py +178 -12
- robotcode/robot/diagnostics/imports_manager.py +556 -220
- robotcode/robot/diagnostics/library_doc.py +874 -162
- robotcode/robot/diagnostics/namespace.py +84 -18
- robotcode/robot/diagnostics/namespace_analyzer.py +59 -16
- robotcode/robot/diagnostics/project_index.py +88 -13
- robotcode/robot/diagnostics/scope_tree.py +2 -2
- robotcode/robot/diagnostics/semantic_analyzer/analyzer.py +790 -201
- robotcode/robot/diagnostics/semantic_analyzer/enums.py +39 -2
- robotcode/robot/diagnostics/semantic_analyzer/json_dump.py +364 -0
- robotcode/robot/diagnostics/semantic_analyzer/model.py +72 -11
- robotcode/robot/diagnostics/semantic_analyzer/nodes.py +7 -1
- robotcode/robot/diagnostics/semantic_analyzer/variable_tokenizer.py +218 -112
- robotcode/robot/diagnostics/workspace_config.py +3 -0
- robotcode/robot/utils/markdown_docs.py +733 -0
- robotcode/robot/utils/markdownformatter.py +57 -12
- robotcode/robot/utils/match.py +13 -0
- robotcode/robot/utils/robot_patching.py +4 -0
- robotcode/robot/utils/variables.py +3 -0
- {robotcode_robot-2.6.2.dist-info → robotcode_robot-2.8.0.dev548.dist-info}/METADATA +4 -3
- robotcode_robot-2.8.0.dev548.dist-info/RECORD +48 -0
- {robotcode_robot-2.6.2.dist-info → robotcode_robot-2.8.0.dev548.dist-info}/WHEEL +1 -1
- 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
|
-
|
|
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
|
|
90
|
-
|
|
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
|
-
|
|
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.
|
|
165
|
+
actions = self._parse_actions(value)
|
|
131
166
|
|
|
132
167
|
if not actions:
|
|
133
168
|
break
|
|
134
169
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
204
|
+
actions = self._parse_actions(value)
|
|
167
205
|
|
|
168
206
|
if not actions:
|
|
169
207
|
break
|
|
170
208
|
|
|
171
|
-
for action,
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
if
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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] =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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"
|