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