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.
Files changed (31) hide show
  1. robotcode/language_server/__version__.py +1 -1
  2. robotcode/language_server/common/parts/diagnostics.py +50 -17
  3. robotcode/language_server/common/parts/documents.py +34 -31
  4. robotcode/language_server/common/parts/workspace.py +57 -55
  5. robotcode/language_server/common/protocol.py +3 -1
  6. robotcode/language_server/robotframework/configuration.py +3 -0
  7. robotcode/language_server/robotframework/parts/code_action_documentation.py +440 -129
  8. robotcode/language_server/robotframework/parts/code_action_helper_mixin.py +17 -2
  9. robotcode/language_server/robotframework/parts/code_action_quick_fixes.py +6 -25
  10. robotcode/language_server/robotframework/parts/code_action_refactor.py +8 -6
  11. robotcode/language_server/robotframework/parts/code_lens.py +3 -4
  12. robotcode/language_server/robotframework/parts/completion.py +193 -21
  13. robotcode/language_server/robotframework/parts/debugging_utils.py +75 -6
  14. robotcode/language_server/robotframework/parts/diagnostics.py +15 -3
  15. robotcode/language_server/robotframework/parts/documents_cache.py +5 -1
  16. robotcode/language_server/robotframework/parts/hover.py +103 -21
  17. robotcode/language_server/robotframework/parts/http_server.py +17 -7
  18. robotcode/language_server/robotframework/parts/inline_value.py +69 -7
  19. robotcode/language_server/robotframework/parts/keywords_treeview.py +151 -53
  20. robotcode/language_server/robotframework/parts/model_variables.py +331 -0
  21. robotcode/language_server/robotframework/parts/references.py +47 -31
  22. robotcode/language_server/robotframework/parts/rename.py +1 -2
  23. robotcode/language_server/robotframework/parts/robot_workspace.py +229 -25
  24. robotcode/language_server/robotframework/parts/selection_range.py +26 -13
  25. robotcode/language_server/robotframework/parts/semantic_tokens.py +414 -113
  26. robotcode/language_server/robotframework/parts/signature_help.py +162 -70
  27. robotcode/language_server/robotframework/protocol.py +1 -0
  28. {robotcode_language_server-2.6.2.dist-info → robotcode_language_server-2.8.0.dev548.dist-info}/METADATA +7 -6
  29. {robotcode_language_server-2.6.2.dist-info → robotcode_language_server-2.8.0.dev548.dist-info}/RECORD +31 -30
  30. {robotcode_language_server-2.6.2.dist-info → robotcode_language_server-2.8.0.dev548.dist-info}/WHEEL +1 -1
  31. {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 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 (
@@ -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 KeywordDoc, resolve_robot_variables
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._collect_from_model(document, range, context, namespace, semantic_model)
279
+ return self._target_at_from_model(document, range, namespace, semantic_model)
75
280
 
76
- return self._collect_legacy(document, range, context, namespace)
281
+ return self._target_at_legacy(document, range, namespace)
77
282
 
78
- def _collect_legacy(
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[List[Union[Command, CodeAction]]]:
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 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)]
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, _ = result
338
+ kw_doc, kw_token = result
137
339
 
138
340
  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)
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
- 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)]
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 _collect_from_model(
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[List[Union[Command, CodeAction]]]:
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 — 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)
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
- if not (context.only and CodeActionKind.SOURCE.value in context.only):
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 — no context.only check
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
- 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)]
393
+ return self.document_target(document, namespace, name_tok.value)
216
394
 
217
395
  return None
218
396
 
219
- def _import_action_from_model(
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[List[Union[Command, CodeAction]]]:
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.CONTROL_FLOW:
242
- break # WITH NAME — everything after is the alias
243
- if tok.kind is TokenKind.ARGUMENT:
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
- url = self.build_url(stmt.import_name or "", args, document, namespace)
250
- return [self.open_documentation_code_action(url)]
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 / SEPARATOR / KEYWORD SemanticTokens
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
- return any(
261
- pos in t.range
262
- for t in stmt.tokens
263
- if t.kind in (TokenKind.NAMESPACE, TokenKind.SEPARATOR, TokenKind.KEYWORD)
264
- )
265
-
266
- def _build_keyword_action(
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[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
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 = next(
279
- (v for v in namespace.libraries.values() if v.library_doc == kw_doc.parent),
280
- None,
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
- 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
- )
485
+ if namespace.library_doc == kw_doc.parent:
486
+ return self.document_target(document, namespace, kw_doc.name)
296
487
 
297
- if entry is None:
298
- return None
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
- url = self.build_url(
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
- kw_doc.name,
552
+ Path(entry.import_source).parent if entry.import_source else None,
553
+ keyword,
306
554
  )
307
555
 
308
- return [self.open_documentation_code_action(url)]
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
- 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
- )
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 build_url(
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: Tuple[Any, ...],
586
+ args: Iterable[Any],
587
+ is_library: bool,
321
588
  document: TextDocument,
322
589
  namespace: Namespace,
323
- target: Optional[str] = None,
324
- ) -> str:
325
- base_dir = document.uri.to_path().parent
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": url_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]: