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.
Files changed (34) hide show
  1. robotcode/language_server/__version__.py +1 -1
  2. robotcode/language_server/cli.py +30 -5
  3. robotcode/language_server/common/parts/diagnostics.py +50 -17
  4. robotcode/language_server/common/parts/documents.py +32 -26
  5. robotcode/language_server/common/parts/workspace.py +57 -55
  6. robotcode/language_server/common/protocol.py +3 -1
  7. robotcode/language_server/robotframework/configuration.py +3 -0
  8. robotcode/language_server/robotframework/parts/code_action_documentation.py +437 -146
  9. robotcode/language_server/robotframework/parts/code_action_helper_mixin.py +17 -2
  10. robotcode/language_server/robotframework/parts/code_action_quick_fixes.py +6 -25
  11. robotcode/language_server/robotframework/parts/code_action_refactor.py +8 -6
  12. robotcode/language_server/robotframework/parts/code_lens.py +3 -4
  13. robotcode/language_server/robotframework/parts/completion.py +193 -21
  14. robotcode/language_server/robotframework/parts/debugging_utils.py +75 -6
  15. robotcode/language_server/robotframework/parts/diagnostics.py +15 -3
  16. robotcode/language_server/robotframework/parts/documentation_viewer.py +127 -0
  17. robotcode/language_server/robotframework/parts/documents_cache.py +5 -1
  18. robotcode/language_server/robotframework/parts/hover.py +103 -21
  19. robotcode/language_server/robotframework/parts/http_server.py +17 -7
  20. robotcode/language_server/robotframework/parts/inline_value.py +69 -7
  21. robotcode/language_server/robotframework/parts/keywords_treeview.py +151 -53
  22. robotcode/language_server/robotframework/parts/model_variables.py +331 -0
  23. robotcode/language_server/robotframework/parts/references.py +107 -45
  24. robotcode/language_server/robotframework/parts/rename.py +1 -2
  25. robotcode/language_server/robotframework/parts/robot_workspace.py +229 -25
  26. robotcode/language_server/robotframework/parts/selection_range.py +26 -13
  27. robotcode/language_server/robotframework/parts/semantic_tokens.py +446 -113
  28. robotcode/language_server/robotframework/parts/signature_help.py +162 -70
  29. robotcode/language_server/robotframework/protocol.py +12 -1
  30. robotcode/language_server/robotframework/server.py +4 -2
  31. {robotcode_language_server-2.7.0.dist-info → robotcode_language_server-2.8.0.dev8.dist-info}/METADATA +7 -6
  32. {robotcode_language_server-2.7.0.dist-info → robotcode_language_server-2.8.0.dev8.dist-info}/RECORD +34 -32
  33. {robotcode_language_server-2.7.0.dist-info → robotcode_language_server-2.8.0.dev8.dist-info}/WHEEL +1 -1
  34. {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 typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union, cast
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 KeywordDoc, resolve_robot_variables
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._collect_from_model(document, range, context, namespace, semantic_model)
277
+ return self._target_at_from_model(document, range, namespace, semantic_model)
75
278
 
76
- return self._collect_legacy(document, range, context, namespace)
279
+ return self._target_at_legacy(document, range, namespace)
77
280
 
78
- def _collect_legacy(
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[List[Union[Command, CodeAction]]]:
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 context.only and isinstance(node, (LibraryImport, ResourceImport)):
100
- if CodeActionKind.SOURCE.value in context.only and range in range_from_token(
101
- node.get_token(RobotToken.NAME)
102
- ):
103
- url = self.build_url(
104
- node.name,
105
- node.args if isinstance(node, LibraryImport) else (),
106
- document,
107
- namespace,
108
- )
109
-
110
- return [self.open_documentation_code_action(url)]
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, _ = result
336
+ kw_doc, kw_token = result
137
337
 
138
338
  if kw_doc is not None:
139
- if context.only and CodeActionKind.SOURCE.value in context.only:
140
- return self._build_keyword_action(kw_doc, document, namespace)
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
- url = self.build_url(
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 _collect_from_model(
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[List[Union[Command, CodeAction]]]:
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 — gated on context.only at entry.
181
- if (
182
- context.only
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)
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._build_keyword_action(kw_doc, document, namespace)
381
+ return self.keyword_target(call.keyword_doc, call.lib_entry, document, namespace)
201
382
 
202
- # Branch 3: keyword definition header — no context.only check
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
- url = self.build_url(
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 _import_action_from_model(
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[List[Union[Command, CodeAction]]]:
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.CONTROL_FLOW:
242
- break # WITH NAME — everything after is the alias
243
- if tok.kind is TokenKind.ARGUMENT:
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
- url = self.build_url(stmt.import_name or "", args, document, namespace)
250
- return [self.open_documentation_code_action(url)]
251
-
252
- @staticmethod
253
- def _cursor_on_keyword_reference(pos: Position, stmt: KeywordCallStatement) -> bool:
254
- """Cursor is within the NAMESPACE / SEPARATOR / KEYWORD SemanticTokens
255
- that make up the keyword reference (BDD prefix excluded). Mirrors
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 _build_keyword_action(
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[List[Union[Command, CodeAction]]]:
272
- """Resolve the LibraryEntry that owns `kw_doc` and build the
273
- Open-Documentation action. Shared between legacy and model paths so
274
- the URL construction stays identical."""
275
- entry: Optional[LibraryEntry] = None
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 = next(
279
- (v for v in namespace.libraries.values() if v.library_doc == kw_doc.parent),
280
- None,
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
- self_libdoc = namespace.library_doc
290
- if entry is None and self_libdoc == kw_doc.parent:
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
- if entry is None:
298
- return None
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
- url = self.build_url(
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
- kw_doc.name,
532
+ Path(entry.import_source).parent if entry.import_source else None,
533
+ keyword,
306
534
  )
307
535
 
308
- return [self.open_documentation_code_action(url)]
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
- def open_documentation_code_action(self, url: str) -> CodeAction:
311
- return CodeAction(
312
- "Open Documentation",
313
- kind=CodeActionKind.SOURCE,
314
- command=Command("Open Documentation", "robotcode.showDocumentation", [url]),
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 build_url(
563
+ def _target(
318
564
  self,
319
565
  name: str,
320
- args: Tuple[Any, ...],
566
+ args: Iterable[Any],
567
+ is_library: bool,
321
568
  document: TextDocument,
322
569
  namespace: Namespace,
323
- target: Optional[str] = None,
324
- ) -> str:
325
- base_dir = document.uri.to_path().parent
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": url_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]: