robotcode-language-server 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/language_server/__version__.py +1 -1
- robotcode/language_server/common/parts/diagnostics.py +50 -17
- robotcode/language_server/common/parts/documents.py +34 -31
- robotcode/language_server/common/parts/workspace.py +57 -55
- robotcode/language_server/common/protocol.py +3 -1
- robotcode/language_server/robotframework/configuration.py +3 -0
- robotcode/language_server/robotframework/parts/code_action_documentation.py +440 -129
- robotcode/language_server/robotframework/parts/code_action_helper_mixin.py +17 -2
- robotcode/language_server/robotframework/parts/code_action_quick_fixes.py +6 -25
- robotcode/language_server/robotframework/parts/code_action_refactor.py +8 -6
- robotcode/language_server/robotframework/parts/code_lens.py +3 -4
- robotcode/language_server/robotframework/parts/completion.py +193 -21
- robotcode/language_server/robotframework/parts/debugging_utils.py +75 -6
- robotcode/language_server/robotframework/parts/diagnostics.py +15 -3
- robotcode/language_server/robotframework/parts/documents_cache.py +5 -1
- robotcode/language_server/robotframework/parts/hover.py +103 -21
- robotcode/language_server/robotframework/parts/http_server.py +17 -7
- robotcode/language_server/robotframework/parts/inline_value.py +69 -7
- robotcode/language_server/robotframework/parts/keywords_treeview.py +151 -53
- robotcode/language_server/robotframework/parts/model_variables.py +331 -0
- robotcode/language_server/robotframework/parts/references.py +47 -31
- robotcode/language_server/robotframework/parts/rename.py +1 -2
- robotcode/language_server/robotframework/parts/robot_workspace.py +229 -25
- robotcode/language_server/robotframework/parts/selection_range.py +26 -13
- robotcode/language_server/robotframework/parts/semantic_tokens.py +414 -113
- robotcode/language_server/robotframework/parts/signature_help.py +162 -70
- robotcode/language_server/robotframework/protocol.py +1 -0
- {robotcode_language_server-2.6.2.dist-info → robotcode_language_server-2.8.0.dev548.dist-info}/METADATA +7 -6
- {robotcode_language_server-2.6.2.dist-info → robotcode_language_server-2.8.0.dev548.dist-info}/RECORD +31 -30
- {robotcode_language_server-2.6.2.dist-info → robotcode_language_server-2.8.0.dev548.dist-info}/WHEEL +1 -1
- {robotcode_language_server-2.6.2.dist-info → robotcode_language_server-2.8.0.dev548.dist-info}/entry_points.txt +0 -0
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import re
|
|
1
2
|
import urllib.parse
|
|
2
|
-
from dataclasses import dataclass
|
|
3
|
-
from
|
|
3
|
+
from dataclasses import dataclass, field, replace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Mapping, Optional, Set, Tuple, Union, cast
|
|
4
6
|
|
|
5
7
|
from robot.parsing.lexer.tokens import Token
|
|
8
|
+
from robot.parsing.model.blocks import File, TestCaseSection
|
|
9
|
+
from robot.utils.escaping import unescape
|
|
6
10
|
|
|
7
11
|
from robotcode.core.language import language_id
|
|
8
12
|
from robotcode.core.lsp.types import (
|
|
@@ -15,13 +19,18 @@ from robotcode.core.lsp.types import (
|
|
|
15
19
|
)
|
|
16
20
|
from robotcode.core.text_document import TextDocument
|
|
17
21
|
from robotcode.core.uri import Uri
|
|
18
|
-
from robotcode.core.utils.dataclasses import CamelSnakeMixin
|
|
22
|
+
from robotcode.core.utils.dataclasses import CamelSnakeMixin, as_json
|
|
19
23
|
from robotcode.core.utils.logging import LoggingDescriptor
|
|
20
24
|
from robotcode.jsonrpc2.protocol import rpc_method
|
|
21
25
|
from robotcode.robot.diagnostics.entities import LibraryEntry
|
|
22
|
-
from robotcode.robot.diagnostics.library_doc import
|
|
26
|
+
from robotcode.robot.diagnostics.library_doc import (
|
|
27
|
+
KeywordDoc,
|
|
28
|
+
LibraryDoc,
|
|
29
|
+
is_library_by_path,
|
|
30
|
+
resolve_robot_variables,
|
|
31
|
+
)
|
|
23
32
|
from robotcode.robot.diagnostics.model_helper import ModelHelper
|
|
24
|
-
from robotcode.robot.diagnostics.namespace import Namespace
|
|
33
|
+
from robotcode.robot.diagnostics.namespace import DocumentType, Namespace
|
|
25
34
|
from robotcode.robot.diagnostics.semantic_analyzer.enums import ImportType, NodeKind, TokenKind
|
|
26
35
|
from robotcode.robot.diagnostics.semantic_analyzer.model import SemanticModel
|
|
27
36
|
from robotcode.robot.diagnostics.semantic_analyzer.nodes import (
|
|
@@ -30,6 +39,18 @@ from robotcode.robot.diagnostics.semantic_analyzer.nodes import (
|
|
|
30
39
|
KeywordCallStatement,
|
|
31
40
|
)
|
|
32
41
|
from robotcode.robot.utils.ast import get_node_at_position, range_from_token
|
|
42
|
+
from robotcode.robot.utils.markdown_docs import (
|
|
43
|
+
REFERENCE_KEYWORD,
|
|
44
|
+
REFERENCE_SECTION,
|
|
45
|
+
REFERENCE_TYPE,
|
|
46
|
+
LinkResolver,
|
|
47
|
+
ReferenceTarget,
|
|
48
|
+
normalize_reference,
|
|
49
|
+
replace_anchor_links,
|
|
50
|
+
slugify,
|
|
51
|
+
)
|
|
52
|
+
from robotcode.robot.utils.match import eq_namespace
|
|
53
|
+
from robotcode.robot.utils.variables import contains_variable
|
|
33
54
|
|
|
34
55
|
from ...common.decorators import code_action_kinds
|
|
35
56
|
from .protocol_part import RobotLanguageServerProtocolPart
|
|
@@ -43,6 +64,179 @@ class ConvertUriParams(CamelSnakeMixin):
|
|
|
43
64
|
uri: str
|
|
44
65
|
|
|
45
66
|
|
|
67
|
+
@dataclass(repr=False)
|
|
68
|
+
class DocumentationTarget(CamelSnakeMixin):
|
|
69
|
+
"""What the documentation actions document, as `robotcode doc lib` takes it.
|
|
70
|
+
|
|
71
|
+
`name` and `args` are resolved against `base_dir`. `base_dir` is left out
|
|
72
|
+
when the name does not depend on it: a library name that is not a path, or
|
|
73
|
+
an absolute path. Instead of a `keyword`, the page can be shown at a
|
|
74
|
+
heading, by the `anchor` the page's own links use, or at a data type, by
|
|
75
|
+
the name of its documentation (`data_type`).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
uri: str
|
|
79
|
+
name: str
|
|
80
|
+
args: List[str] = field(default_factory=list)
|
|
81
|
+
base_dir: Optional[str] = None
|
|
82
|
+
keyword: Optional[str] = None
|
|
83
|
+
anchor: Optional[str] = None
|
|
84
|
+
data_type: Optional[str] = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
DOCUMENTATION_VIEWER_LINK_TITLE = "Show in Documentation Viewer"
|
|
88
|
+
|
|
89
|
+
# VS Code cuts every Markdown text it shows here, in hovers, completion details,
|
|
90
|
+
# signature help and tooltips, and a cut breaks the last links
|
|
91
|
+
DOCUMENTATION_LINKS_SIZE_LIMIT = 100_000
|
|
92
|
+
|
|
93
|
+
_RE_LINKED_HEADING = re.compile(r"^(#{1,6}) ((?:Keyword|Library|Resource) \*.*\*)[ \t]*$")
|
|
94
|
+
_RE_LINK_TEXT_SPECIAL = re.compile(r"([\\\[\]])")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def documentation_viewer_uri(target: DocumentationTarget) -> Optional[str]:
|
|
98
|
+
"""The command URI that shows `target` in the Documentation Viewer, `None`
|
|
99
|
+
if the target cannot be encoded.
|
|
100
|
+
|
|
101
|
+
VS Code decodes the query of a command link twice, once when it parses the
|
|
102
|
+
URI and once more before it parses the arguments, so it is encoded twice.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
query = urllib.parse.quote(urllib.parse.quote(as_json([target], compact=True), safe=""), safe="")
|
|
106
|
+
except UnicodeEncodeError:
|
|
107
|
+
# a lone surrogate, as a file name that is not UTF-8 can give one
|
|
108
|
+
return None
|
|
109
|
+
return f"command:robotcode.showInDocumentationViewer?{query}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def documentation_viewer_link(target: DocumentationTarget) -> Optional[str]:
|
|
113
|
+
"""The destination and title of a Markdown link that shows `target` in the Documentation Viewer."""
|
|
114
|
+
uri = documentation_viewer_uri(target)
|
|
115
|
+
return None if uri is None else f'{uri} "{DOCUMENTATION_VIEWER_LINK_TITLE}"'
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def documentation_link_resolver(
|
|
119
|
+
target: DocumentationTarget, library_doc: LibraryDoc, *, introduction: bool = True
|
|
120
|
+
) -> LinkResolver:
|
|
121
|
+
"""A `LinkResolver` for the documentation of `library_doc` shown for `target`.
|
|
122
|
+
|
|
123
|
+
Keywords of the page, data types of the library and sections the page has
|
|
124
|
+
link to that place in the Documentation Viewer; a private keyword or a
|
|
125
|
+
section the page does not have stays inline code. Without `introduction`,
|
|
126
|
+
the page has no introduction, as the page of a suite file.
|
|
127
|
+
"""
|
|
128
|
+
keywords: Optional[Set[str]] = None
|
|
129
|
+
types: Optional[Set[str]] = None
|
|
130
|
+
sections: Optional[Set[str]] = None
|
|
131
|
+
|
|
132
|
+
def resolve(kind: str, name: str) -> Optional[str]:
|
|
133
|
+
nonlocal keywords, types, sections
|
|
134
|
+
if kind == REFERENCE_KEYWORD:
|
|
135
|
+
if keywords is None:
|
|
136
|
+
keywords = {kw.name for kw in library_doc.get_page_keywords()}
|
|
137
|
+
if name not in keywords:
|
|
138
|
+
return None
|
|
139
|
+
return documentation_viewer_link(replace(target, keyword=name, anchor=None, data_type=None))
|
|
140
|
+
if kind == REFERENCE_TYPE:
|
|
141
|
+
if types is None:
|
|
142
|
+
types = {t.name for t in library_doc.types}
|
|
143
|
+
if name not in types:
|
|
144
|
+
return None
|
|
145
|
+
return documentation_viewer_link(replace(target, keyword=None, anchor=None, data_type=name))
|
|
146
|
+
if kind == REFERENCE_SECTION:
|
|
147
|
+
if sections is None:
|
|
148
|
+
sections = _page_sections(library_doc, introduction)
|
|
149
|
+
if normalize_reference(name) not in sections:
|
|
150
|
+
return None
|
|
151
|
+
# a title without letters or digits has no anchor, the page is shown from its start
|
|
152
|
+
anchor = slugify(name) or None
|
|
153
|
+
return documentation_viewer_link(replace(target, keyword=None, anchor=anchor, data_type=None))
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
return resolve
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _page_sections(library_doc: LibraryDoc, introduction: bool) -> Set[str]:
|
|
160
|
+
"""The sections that have a heading on the page of `library_doc`, by normalized name."""
|
|
161
|
+
sections: Set[str] = set()
|
|
162
|
+
if introduction and library_doc.doc:
|
|
163
|
+
sections.add("introduction")
|
|
164
|
+
sections.update(normalize_reference(title) for title in library_doc.get_introduction_headings())
|
|
165
|
+
if any(v.arguments for v in library_doc.inits.values()):
|
|
166
|
+
sections.add("importing")
|
|
167
|
+
if library_doc.get_page_keywords():
|
|
168
|
+
sections.add("keywords")
|
|
169
|
+
return sections
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def link_heading(markdown: str, target: DocumentationTarget) -> str:
|
|
173
|
+
"""Make the first line of `markdown` a link that shows `target` in the
|
|
174
|
+
Documentation Viewer, if it is the heading of a keyword, a library or a
|
|
175
|
+
resource file."""
|
|
176
|
+
first, newline, rest = markdown.partition("\n")
|
|
177
|
+
match = _RE_LINKED_HEADING.match(first)
|
|
178
|
+
if match is None:
|
|
179
|
+
return markdown
|
|
180
|
+
link = documentation_viewer_link(target)
|
|
181
|
+
if link is None:
|
|
182
|
+
return markdown
|
|
183
|
+
text = _RE_LINK_TEXT_SPECIAL.sub(r"\\\1", match.group(2))
|
|
184
|
+
return f"{match.group(1)} [{text}]({link}){newline}{rest}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def link_documentation(
|
|
188
|
+
render: Callable[[Optional[LinkResolver]], str],
|
|
189
|
+
target: Optional[DocumentationTarget],
|
|
190
|
+
library_doc: Optional[LibraryDoc],
|
|
191
|
+
*,
|
|
192
|
+
heading: bool = True,
|
|
193
|
+
link_resolver: Optional[LinkResolver] = None,
|
|
194
|
+
) -> str:
|
|
195
|
+
"""Documentation that `render` writes, with links into the Documentation Viewer.
|
|
196
|
+
|
|
197
|
+
With a target, the names that `link_resolver` resolves, by default the
|
|
198
|
+
`documentation_link_resolver` of `library_doc`, the links to a place in the
|
|
199
|
+
documentation itself and, with `heading`, the heading link to the viewer.
|
|
200
|
+
Documentation that would get longer than VS Code shows keeps only the
|
|
201
|
+
heading link. Without a target, links to a place in the documentation
|
|
202
|
+
itself become text.
|
|
203
|
+
"""
|
|
204
|
+
if target is None:
|
|
205
|
+
return replace_anchor_links(render(None), _no_link)
|
|
206
|
+
|
|
207
|
+
def anchor_link(anchor: str) -> Optional[str]:
|
|
208
|
+
# `#` alone shows the page from its start
|
|
209
|
+
return documentation_viewer_uri(replace(target, keyword=None, anchor=anchor or None, data_type=None))
|
|
210
|
+
|
|
211
|
+
resolver = link_resolver
|
|
212
|
+
if resolver is None and library_doc is not None:
|
|
213
|
+
resolver = documentation_link_resolver(target, library_doc)
|
|
214
|
+
text = replace_anchor_links(render(resolver), anchor_link, DOCUMENTATION_VIEWER_LINK_TITLE)
|
|
215
|
+
if heading:
|
|
216
|
+
text = link_heading(text, target)
|
|
217
|
+
# VS Code counts the limit in UTF-16 code units
|
|
218
|
+
if len(text.encode("utf-16-le", "surrogatepass")) > 2 * DOCUMENTATION_LINKS_SIZE_LIMIT:
|
|
219
|
+
text = replace_anchor_links(render(None), _no_link)
|
|
220
|
+
if heading:
|
|
221
|
+
text = link_heading(text, target)
|
|
222
|
+
return text
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _no_link(anchor: str) -> Optional[str]:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def keyword_reference_targets(kw_doc: KeywordDoc, links: bool) -> Optional[Mapping[str, ReferenceTarget]]:
|
|
230
|
+
"""What references in the documentation of `kw_doc` can point to, to compute once for all of its parts.
|
|
231
|
+
|
|
232
|
+
Without links only documentation in Markdown has references; with links,
|
|
233
|
+
the names in documentation in Robot Framework's format link as well.
|
|
234
|
+
"""
|
|
235
|
+
if links and kw_doc.parent is not None:
|
|
236
|
+
return kw_doc.parent.get_reference_targets()
|
|
237
|
+
return kw_doc.get_reference_targets()
|
|
238
|
+
|
|
239
|
+
|
|
46
240
|
class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart, ModelHelper):
|
|
47
241
|
_logger = LoggingDescriptor()
|
|
48
242
|
|
|
@@ -62,6 +256,17 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
62
256
|
range: Range,
|
|
63
257
|
context: CodeActionContext,
|
|
64
258
|
) -> Optional[List[Union[Command, CodeAction]]]:
|
|
259
|
+
if not context.only or CodeActionKind.SOURCE.value not in context.only:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
target = self.target_at(document, range)
|
|
263
|
+
if target is None:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
return self.documentation_actions(target, document)
|
|
267
|
+
|
|
268
|
+
def target_at(self, document: TextDocument, range: Range) -> Optional[DocumentationTarget]:
|
|
269
|
+
"""What the documentation actions document at `range`, or `None` where they are not offered."""
|
|
65
270
|
namespace = self.parent.documents_cache.get_namespace(document)
|
|
66
271
|
|
|
67
272
|
# Tier 2 model-based path — used when the experimental SemanticAnalyzer
|
|
@@ -71,17 +276,16 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
71
276
|
# `find_keyword`, no AST walk.
|
|
72
277
|
semantic_model = namespace.semantic_model
|
|
73
278
|
if semantic_model is not None:
|
|
74
|
-
return self.
|
|
279
|
+
return self._target_at_from_model(document, range, namespace, semantic_model)
|
|
75
280
|
|
|
76
|
-
return self.
|
|
281
|
+
return self._target_at_legacy(document, range, namespace)
|
|
77
282
|
|
|
78
|
-
def
|
|
283
|
+
def _target_at_legacy(
|
|
79
284
|
self,
|
|
80
285
|
document: TextDocument,
|
|
81
286
|
range: Range,
|
|
82
|
-
context: CodeActionContext,
|
|
83
287
|
namespace: Namespace,
|
|
84
|
-
) -> Optional[
|
|
288
|
+
) -> Optional[DocumentationTarget]:
|
|
85
289
|
from robot.parsing.lexer import Token as RobotToken
|
|
86
290
|
from robot.parsing.model.statements import (
|
|
87
291
|
Fixture,
|
|
@@ -96,22 +300,20 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
96
300
|
model = self.parent.documents_cache.get_model(document)
|
|
97
301
|
node = get_node_at_position(model, range.start)
|
|
98
302
|
|
|
99
|
-
if
|
|
100
|
-
if
|
|
101
|
-
node
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return
|
|
303
|
+
if isinstance(node, (LibraryImport, ResourceImport)):
|
|
304
|
+
if range in range_from_token(node.get_token(RobotToken.NAME)):
|
|
305
|
+
if isinstance(node, LibraryImport):
|
|
306
|
+
return self.import_target(
|
|
307
|
+
node.name,
|
|
308
|
+
node.args,
|
|
309
|
+
True,
|
|
310
|
+
document,
|
|
311
|
+
namespace,
|
|
312
|
+
namespace.get_imported_library_libdoc(node.name, node.args, node.alias),
|
|
313
|
+
)
|
|
314
|
+
return self.import_target(node.name, (), False, document, namespace)
|
|
111
315
|
|
|
112
316
|
if isinstance(node, (KeywordCall, Fixture, TestTemplate, Template)):
|
|
113
|
-
# only source actions
|
|
114
|
-
|
|
115
317
|
result = self.get_keyworddoc_and_token_from_position(
|
|
116
318
|
(
|
|
117
319
|
node.value
|
|
@@ -133,24 +335,16 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
133
335
|
return None
|
|
134
336
|
|
|
135
337
|
if result is not None:
|
|
136
|
-
kw_doc,
|
|
338
|
+
kw_doc, kw_token = result
|
|
137
339
|
|
|
138
340
|
if kw_doc is not None:
|
|
139
|
-
|
|
140
|
-
|
|
341
|
+
prefix_entry, _ = self.get_namespace_info_from_keyword_token(namespace, kw_token)
|
|
342
|
+
return self.keyword_target(kw_doc, prefix_entry, document, namespace)
|
|
141
343
|
|
|
142
344
|
if isinstance(node, KeywordName):
|
|
143
345
|
name_token = node.get_token(RobotToken.KEYWORD_NAME)
|
|
144
346
|
if name_token is not None and range in range_from_token(name_token):
|
|
145
|
-
|
|
146
|
-
str(document.uri.to_path().name),
|
|
147
|
-
(),
|
|
148
|
-
document,
|
|
149
|
-
namespace,
|
|
150
|
-
name_token.value,
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
return [self.open_documentation_code_action(url)]
|
|
347
|
+
return self.document_target(document, namespace, name_token.value)
|
|
154
348
|
|
|
155
349
|
return None
|
|
156
350
|
|
|
@@ -158,14 +352,13 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
158
352
|
# Tier 2 model-based collection
|
|
159
353
|
# ------------------------------------------------------------------
|
|
160
354
|
|
|
161
|
-
def
|
|
355
|
+
def _target_at_from_model(
|
|
162
356
|
self,
|
|
163
357
|
document: TextDocument,
|
|
164
358
|
range: Range,
|
|
165
|
-
context: CodeActionContext,
|
|
166
359
|
namespace: Namespace,
|
|
167
360
|
model: SemanticModel,
|
|
168
|
-
) -> Optional[
|
|
361
|
+
) -> Optional[DocumentationTarget]:
|
|
169
362
|
"""Mirror legacy three-branch logic (import / keyword-call / keyword-def)
|
|
170
363
|
purely off the SemanticModel — no AST walks, no `find_keyword`.
|
|
171
364
|
|
|
@@ -177,14 +370,9 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
177
370
|
if stmt is None:
|
|
178
371
|
return None
|
|
179
372
|
|
|
180
|
-
# Branch 1: Library / Resource import
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
and isinstance(stmt, ImportStatement)
|
|
184
|
-
and stmt.import_type in (ImportType.LIBRARY, ImportType.RESOURCE)
|
|
185
|
-
and CodeActionKind.SOURCE.value in context.only
|
|
186
|
-
):
|
|
187
|
-
return self._import_action_from_model(stmt, document, range, namespace)
|
|
373
|
+
# Branch 1: Library / Resource import.
|
|
374
|
+
if isinstance(stmt, ImportStatement) and stmt.import_type in (ImportType.LIBRARY, ImportType.RESOURCE):
|
|
375
|
+
return self._import_target_from_model(stmt, document, range, namespace)
|
|
188
376
|
|
|
189
377
|
# Branch 2: keyword call / fixture / template.
|
|
190
378
|
if isinstance(stmt, KeywordCallStatement):
|
|
@@ -195,34 +383,24 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
195
383
|
return None
|
|
196
384
|
if not self._cursor_on_keyword_reference(range.start, stmt):
|
|
197
385
|
return None
|
|
198
|
-
|
|
199
|
-
return None
|
|
200
|
-
return self._build_keyword_action(kw_doc, document, namespace)
|
|
386
|
+
return self.keyword_target(kw_doc, stmt.lib_entry, document, namespace)
|
|
201
387
|
|
|
202
|
-
# Branch 3: keyword definition header
|
|
203
|
-
# (legacy doesn't gate this branch either).
|
|
388
|
+
# Branch 3: keyword definition header.
|
|
204
389
|
if isinstance(stmt, DefinitionStatement) and stmt.kind is NodeKind.KEYWORD_DEF:
|
|
205
390
|
name_tok = next((t for t in stmt.tokens if t.kind is TokenKind.KEYWORD_NAME), None)
|
|
206
391
|
if name_tok is None or range not in name_tok.range:
|
|
207
392
|
return None
|
|
208
|
-
|
|
209
|
-
str(document.uri.to_path().name),
|
|
210
|
-
(),
|
|
211
|
-
document,
|
|
212
|
-
namespace,
|
|
213
|
-
name_tok.value,
|
|
214
|
-
)
|
|
215
|
-
return [self.open_documentation_code_action(url)]
|
|
393
|
+
return self.document_target(document, namespace, name_tok.value)
|
|
216
394
|
|
|
217
395
|
return None
|
|
218
396
|
|
|
219
|
-
def
|
|
397
|
+
def _import_target_from_model(
|
|
220
398
|
self,
|
|
221
399
|
stmt: ImportStatement,
|
|
222
400
|
document: TextDocument,
|
|
223
401
|
range: Range,
|
|
224
402
|
namespace: Namespace,
|
|
225
|
-
) -> Optional[
|
|
403
|
+
) -> Optional[DocumentationTarget]:
|
|
226
404
|
"""Library / Resource import branch built off SemanticTokens.
|
|
227
405
|
|
|
228
406
|
- The import path lives in the IMPORT_NAME token (cursor-position check).
|
|
@@ -237,92 +415,243 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
237
415
|
|
|
238
416
|
if stmt.import_type is ImportType.LIBRARY:
|
|
239
417
|
arg_values: List[str] = []
|
|
418
|
+
seen_import_name = False
|
|
240
419
|
for tok in stmt.tokens:
|
|
241
|
-
if tok.kind is TokenKind.
|
|
242
|
-
|
|
243
|
-
|
|
420
|
+
if tok.kind is TokenKind.IMPORT_NAME:
|
|
421
|
+
seen_import_name = True
|
|
422
|
+
elif seen_import_name and tok.kind is TokenKind.SETTING_IMPORT:
|
|
423
|
+
break # WITH NAME / AS — everything after is the alias
|
|
424
|
+
elif tok.kind is TokenKind.ARGUMENT:
|
|
244
425
|
arg_values.append(tok.value)
|
|
245
426
|
args: Tuple[str, ...] = tuple(arg_values)
|
|
246
427
|
else:
|
|
247
428
|
args = ()
|
|
248
429
|
|
|
249
|
-
|
|
250
|
-
|
|
430
|
+
return self.import_target(
|
|
431
|
+
stmt.import_name or "",
|
|
432
|
+
args,
|
|
433
|
+
stmt.import_type is ImportType.LIBRARY,
|
|
434
|
+
document,
|
|
435
|
+
namespace,
|
|
436
|
+
stmt.lib_entry.library_doc if stmt.lib_entry is not None else None,
|
|
437
|
+
)
|
|
251
438
|
|
|
252
439
|
@staticmethod
|
|
253
440
|
def _cursor_on_keyword_reference(pos: Position, stmt: KeywordCallStatement) -> bool:
|
|
254
|
-
"""Cursor is within the NAMESPACE /
|
|
441
|
+
"""Cursor is within the NAMESPACE / "." / KEYWORD SemanticTokens
|
|
255
442
|
that make up the keyword reference (BDD prefix excluded). Mirrors
|
|
256
443
|
the legacy `position.is_in_range(range_from_token(keyword_token))`
|
|
257
444
|
after the BDD-prefix strip that
|
|
258
445
|
`get_keyworddoc_and_token_from_position` does.
|
|
259
446
|
"""
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
447
|
+
ref_toks = [t for t in stmt.tokens if t.kind in (TokenKind.NAMESPACE, TokenKind.KEYWORD)]
|
|
448
|
+
if not ref_toks:
|
|
449
|
+
return False
|
|
450
|
+
# Contiguous span from namespace start to keyword end also covers the
|
|
451
|
+
# "." operator between them.
|
|
452
|
+
return pos in Range(start=ref_toks[0].range.start, end=ref_toks[-1].range.end)
|
|
453
|
+
|
|
454
|
+
def keyword_target(
|
|
267
455
|
self,
|
|
268
456
|
kw_doc: KeywordDoc,
|
|
457
|
+
prefix_entry: Optional[LibraryEntry],
|
|
269
458
|
document: TextDocument,
|
|
270
459
|
namespace: Namespace,
|
|
271
|
-
) -> Optional[
|
|
272
|
-
"""
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
460
|
+
) -> Optional[DocumentationTarget]:
|
|
461
|
+
"""Target of a keyword reference: the import that owns `kw_doc`.
|
|
462
|
+
Shared between legacy and model paths so both resolve the same import.
|
|
463
|
+
|
|
464
|
+
For a call with a library or resource prefix, that is the prefix's
|
|
465
|
+
import: `LibraryDoc ==` ignores the import arguments, so a second import
|
|
466
|
+
of the same library would otherwise be documented with the first
|
|
467
|
+
import's arguments. A library keyword called without a prefix belongs
|
|
468
|
+
to the import the analysis resolved it to, see `_library_entry`."""
|
|
469
|
+
if prefix_entry is not None and prefix_entry.library_doc == kw_doc.parent:
|
|
470
|
+
return self.entry_target(prefix_entry, kw_doc.libtype == "LIBRARY", document, namespace, kw_doc.name)
|
|
276
471
|
|
|
277
472
|
if kw_doc.libtype == "LIBRARY":
|
|
278
|
-
entry =
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
)
|
|
473
|
+
entry = self._library_entry(kw_doc, namespace)
|
|
474
|
+
if entry is not None:
|
|
475
|
+
return self.entry_target(entry, True, document, namespace, kw_doc.name)
|
|
282
476
|
|
|
283
477
|
elif kw_doc.libtype == "RESOURCE":
|
|
284
478
|
entry = next(
|
|
285
479
|
(v for v in namespace.resources.values() if v.library_doc == kw_doc.parent),
|
|
286
480
|
None,
|
|
287
481
|
)
|
|
482
|
+
if entry is not None:
|
|
483
|
+
return self.entry_target(entry, False, document, namespace, kw_doc.name)
|
|
288
484
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
entry = LibraryEntry(
|
|
292
|
-
self_libdoc.name,
|
|
293
|
-
str(document.uri.to_path().name),
|
|
294
|
-
self_libdoc,
|
|
295
|
-
)
|
|
485
|
+
if namespace.library_doc == kw_doc.parent:
|
|
486
|
+
return self.document_target(document, namespace, kw_doc.name)
|
|
296
487
|
|
|
297
|
-
|
|
298
|
-
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
@staticmethod
|
|
491
|
+
def _library_entry(kw_doc: KeywordDoc, namespace: Namespace) -> Optional[LibraryEntry]:
|
|
492
|
+
"""The import of a library keyword called without a prefix.
|
|
493
|
+
|
|
494
|
+
Imports of the same library with other arguments compare equal. In a
|
|
495
|
+
live analysis each import has its own `LibraryDoc`, so the keyword's
|
|
496
|
+
parent is the one the keyword finder chose. After a cache restore that
|
|
497
|
+
identity is lost; then the import is chosen as the keyword finder
|
|
498
|
+
chooses between imports: among those that have the keyword, the first
|
|
499
|
+
that the library search order names."""
|
|
500
|
+
candidates = [v for v in namespace.libraries.values() if v.library_doc == kw_doc.parent]
|
|
501
|
+
if len(candidates) < 2:
|
|
502
|
+
return next(iter(candidates), None)
|
|
503
|
+
|
|
504
|
+
entry = next((v for v in candidates if v.library_doc is kw_doc.parent), None)
|
|
505
|
+
if entry is not None:
|
|
506
|
+
return entry
|
|
507
|
+
|
|
508
|
+
having = [v for v in candidates if kw_doc.name in v.library_doc.keywords] or candidates
|
|
509
|
+
for name in namespace.imports_manager.global_library_search_order:
|
|
510
|
+
entry = next((v for v in having if eq_namespace(name, v.alias or v.name)), None)
|
|
511
|
+
if entry is not None:
|
|
512
|
+
return entry
|
|
513
|
+
return having[0]
|
|
514
|
+
|
|
515
|
+
def link_resolver(
|
|
516
|
+
self,
|
|
517
|
+
document: TextDocument,
|
|
518
|
+
namespace: Namespace,
|
|
519
|
+
target: DocumentationTarget,
|
|
520
|
+
library_doc: LibraryDoc,
|
|
521
|
+
) -> LinkResolver:
|
|
522
|
+
"""The `documentation_link_resolver` for documentation of `library_doc` shown in `document`.
|
|
523
|
+
|
|
524
|
+
`robotcode doc lib` documents a suite file without its documentation,
|
|
525
|
+
so the keywords of a suite file link no sections of its introduction.
|
|
526
|
+
"""
|
|
527
|
+
introduction = not (library_doc == namespace.library_doc and self._is_suite(document))
|
|
528
|
+
return documentation_link_resolver(target, library_doc, introduction=introduction)
|
|
299
529
|
|
|
300
|
-
|
|
530
|
+
def _is_suite(self, document: TextDocument) -> bool:
|
|
531
|
+
"""Whether `robotcode doc lib` documents `document` as a suite: an initialization file or a file with tests."""
|
|
532
|
+
if self.parent.documents_cache.get_document_type(document) == DocumentType.INIT:
|
|
533
|
+
return True
|
|
534
|
+
model = self.parent.documents_cache.get_model(document)
|
|
535
|
+
return any(isinstance(section, TestCaseSection) for section in cast(File, model).sections)
|
|
536
|
+
|
|
537
|
+
def entry_target(
|
|
538
|
+
self,
|
|
539
|
+
entry: LibraryEntry,
|
|
540
|
+
is_library: bool,
|
|
541
|
+
document: TextDocument,
|
|
542
|
+
namespace: Namespace,
|
|
543
|
+
keyword: Optional[str] = None,
|
|
544
|
+
) -> DocumentationTarget:
|
|
545
|
+
"""Target of an import, resolved in the directory of the file that contains it."""
|
|
546
|
+
return self._target(
|
|
301
547
|
entry.import_name,
|
|
302
|
-
entry.args,
|
|
548
|
+
self._page_args(entry.args, entry.library_doc),
|
|
549
|
+
is_library,
|
|
303
550
|
document,
|
|
304
551
|
namespace,
|
|
305
|
-
|
|
552
|
+
Path(entry.import_source).parent if entry.import_source else None,
|
|
553
|
+
keyword,
|
|
306
554
|
)
|
|
307
555
|
|
|
308
|
-
|
|
556
|
+
def import_target(
|
|
557
|
+
self,
|
|
558
|
+
name: str,
|
|
559
|
+
args: Iterable[Any],
|
|
560
|
+
is_library: bool,
|
|
561
|
+
document: TextDocument,
|
|
562
|
+
namespace: Namespace,
|
|
563
|
+
library_doc: Optional[LibraryDoc] = None,
|
|
564
|
+
) -> DocumentationTarget:
|
|
565
|
+
"""Target of an import of the current document, `library_doc` is what the import loaded."""
|
|
566
|
+
return self._target(name, self._page_args(args, library_doc), is_library, document, namespace, None, None)
|
|
309
567
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
)
|
|
568
|
+
@staticmethod
|
|
569
|
+
def _page_args(args: Iterable[Any], library_doc: Optional[LibraryDoc]) -> Iterable[Any]:
|
|
570
|
+
"""The arguments of the page of an import. RobotCode loads a library whose arguments
|
|
571
|
+
fail without them, and so does its page: `robotcode doc lib` refuses those arguments."""
|
|
572
|
+
return () if library_doc is not None and library_doc.loaded_without_arguments else args
|
|
316
573
|
|
|
317
|
-
def
|
|
574
|
+
def document_target(
|
|
575
|
+
self,
|
|
576
|
+
document: TextDocument,
|
|
577
|
+
namespace: Namespace,
|
|
578
|
+
keyword: Optional[str] = None,
|
|
579
|
+
) -> DocumentationTarget:
|
|
580
|
+
"""Target of the current document itself, for its own keywords."""
|
|
581
|
+
return self._target(document.uri.to_path().name, (), False, document, namespace, None, keyword)
|
|
582
|
+
|
|
583
|
+
def _target(
|
|
318
584
|
self,
|
|
319
585
|
name: str,
|
|
320
|
-
args:
|
|
586
|
+
args: Iterable[Any],
|
|
587
|
+
is_library: bool,
|
|
321
588
|
document: TextDocument,
|
|
322
589
|
namespace: Namespace,
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
590
|
+
base_dir: Optional[Path],
|
|
591
|
+
keyword: Optional[str],
|
|
592
|
+
) -> DocumentationTarget:
|
|
593
|
+
directory = base_dir if base_dir is not None else document.uri.to_path().parent
|
|
594
|
+
str_args = [str(v) for v in args]
|
|
595
|
+
|
|
596
|
+
if contains_variable(name, "$@&%") or any(contains_variable(v, "$@&%") for v in str_args):
|
|
597
|
+
robot_variables = resolve_robot_variables(
|
|
598
|
+
str(namespace.imports_manager.root_folder),
|
|
599
|
+
str(directory),
|
|
600
|
+
namespace.imports_manager.get_resolvable_command_line_variables(),
|
|
601
|
+
variables=namespace.get_resolvable_variables(),
|
|
602
|
+
)
|
|
603
|
+
try:
|
|
604
|
+
name = robot_variables.replace_string(name, ignore_errors=False)
|
|
605
|
+
|
|
606
|
+
str_args = [robot_variables.replace_string(v, ignore_errors=False) for v in str_args]
|
|
607
|
+
|
|
608
|
+
except (SystemExit, KeyboardInterrupt):
|
|
609
|
+
raise
|
|
610
|
+
except BaseException:
|
|
611
|
+
pass
|
|
612
|
+
else:
|
|
613
|
+
# what `replace_string` returns for a string without variables,
|
|
614
|
+
# without the cost of resolving all variables of the namespace
|
|
615
|
+
name = unescape(name)
|
|
616
|
+
str_args = [unescape(v) for v in str_args]
|
|
617
|
+
|
|
618
|
+
depends_on_base_dir = (not is_library or is_library_by_path(name)) and not Path(name).is_absolute()
|
|
619
|
+
|
|
620
|
+
return DocumentationTarget(
|
|
621
|
+
uri=str(document.uri),
|
|
622
|
+
name=name,
|
|
623
|
+
args=str_args,
|
|
624
|
+
base_dir=str(directory) if depends_on_base_dir else None,
|
|
625
|
+
keyword=keyword,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
def documentation_actions(
|
|
629
|
+
self, target: DocumentationTarget, document: TextDocument
|
|
630
|
+
) -> List[Union[Command, CodeAction]]:
|
|
631
|
+
return [
|
|
632
|
+
CodeAction(
|
|
633
|
+
"Show in Documentation Viewer",
|
|
634
|
+
kind=CodeActionKind.SOURCE,
|
|
635
|
+
command=Command("Show in Documentation Viewer", "robotcode.showInDocumentationViewer", [target]),
|
|
636
|
+
is_preferred=True,
|
|
637
|
+
),
|
|
638
|
+
CodeAction(
|
|
639
|
+
"Show in New Documentation Viewer",
|
|
640
|
+
kind=CodeActionKind.SOURCE,
|
|
641
|
+
command=Command("Show in New Documentation Viewer", "robotcode.showInNewDocumentationViewer", [target]),
|
|
642
|
+
),
|
|
643
|
+
self.open_documentation_code_action(self.build_url(target, document)),
|
|
644
|
+
]
|
|
645
|
+
|
|
646
|
+
def open_documentation_code_action(self, url: str) -> CodeAction:
|
|
647
|
+
return CodeAction(
|
|
648
|
+
"Open Documentation (deprecated)",
|
|
649
|
+
kind=CodeActionKind.SOURCE,
|
|
650
|
+
command=Command("Open Documentation (deprecated)", "robotcode.showDocumentation", [url]),
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def build_url(self, target: DocumentationTarget, document: TextDocument) -> str:
|
|
654
|
+
base_dir = Path(target.base_dir) if target.base_dir is not None else document.uri.to_path().parent
|
|
326
655
|
|
|
327
656
|
workspace_folder = self.parent.workspace.get_workspace_folder(document.uri)
|
|
328
657
|
if workspace_folder is not None:
|
|
@@ -331,35 +660,17 @@ class RobotCodeActionDocumentationProtocolPart(RobotLanguageServerProtocolPart,
|
|
|
331
660
|
except ValueError:
|
|
332
661
|
pass
|
|
333
662
|
|
|
334
|
-
robot_variables = resolve_robot_variables(
|
|
335
|
-
str(namespace.imports_manager.root_folder),
|
|
336
|
-
str(base_dir),
|
|
337
|
-
namespace.imports_manager.get_resolvable_command_line_variables(),
|
|
338
|
-
variables=namespace.get_resolvable_variables(),
|
|
339
|
-
)
|
|
340
|
-
try:
|
|
341
|
-
name = robot_variables.replace_string(name, ignore_errors=False)
|
|
342
|
-
|
|
343
|
-
args = tuple(robot_variables.replace_string(v, ignore_errors=False) for v in args)
|
|
344
|
-
|
|
345
|
-
except (SystemExit, KeyboardInterrupt):
|
|
346
|
-
raise
|
|
347
|
-
except BaseException:
|
|
348
|
-
pass
|
|
349
|
-
|
|
350
|
-
url_args = "::".join(args) if args else ""
|
|
351
|
-
|
|
352
663
|
base_url = f"http://localhost:{self.parent.http_server.port}"
|
|
353
664
|
params = urllib.parse.urlencode(
|
|
354
665
|
{
|
|
355
|
-
"name": name,
|
|
356
|
-
"args":
|
|
666
|
+
"name": target.name,
|
|
667
|
+
"args": "::".join(target.args),
|
|
357
668
|
"basedir": str(base_dir),
|
|
358
669
|
"theme": "${theme}",
|
|
359
670
|
}
|
|
360
671
|
)
|
|
361
672
|
|
|
362
|
-
return f"{base_url}/?&{params}{f'#{target}' if target else ''}"
|
|
673
|
+
return f"{base_url}/?&{params}{f'#{target.keyword}' if target.keyword else ''}"
|
|
363
674
|
|
|
364
675
|
@rpc_method(name="robot/documentationServer/convertUri", param_type=ConvertUriParams, threaded=True)
|
|
365
676
|
def _convert_uri(self, uri: str, *args: Any, **kwargs: Any) -> Optional[str]:
|