robotcode-repl 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.
@@ -0,0 +1,651 @@
1
+ """The `robotcode doc` command group.
2
+
3
+ Shows the documentation of one library, resource file or suite file as the
4
+ project sees it: loaded anew on every call, with the `robot.toml`
5
+ configuration and the selected profiles, like `robot` loads it.
6
+ """
7
+
8
+ import contextlib
9
+ import os
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ import click
16
+ from robot.conf import RobotSettings
17
+ from robot.errors import INFO_PRINTED, DataError, Information
18
+ from robot.output import LOGGER
19
+ from robot.running import TestSuite
20
+ from robot.running.builder import ResourceFileBuilder, TestSuiteBuilder
21
+ from robot.running.importer import RESOURCE_EXTENSIONS
22
+ from robot.utils import MultiMatcher, getshortdoc
23
+ from robot.utils.recommendations import RecommendationFinder
24
+ from robot.variables.scopes import GlobalVariables
25
+
26
+ from robotcode.plugin import Application, OutputFormat, pass_application
27
+ from robotcode.plugin._agent_detection import is_running_in_ai_agent
28
+ from robotcode.plugin.click_helper.types import add_options
29
+ from robotcode.robot.diagnostics.library_doc import (
30
+ IgnoreEasterEggLibraryWarning,
31
+ KeywordDoc,
32
+ LibraryDoc,
33
+ find_file,
34
+ get_library_doc,
35
+ get_resource_doc_from_resource,
36
+ resolve_robot_variables,
37
+ )
38
+ from robotcode.robot.utils import RF_VERSION
39
+ from robotcode.robot.utils.markdown_docs import anchor_link_resolver, unique_reference_labels
40
+ from robotcode.robot.utils.match import normalize
41
+ from robotcode.robot.utils.variables import contains_variable
42
+ from robotcode.runner.cli.robot import RobotFrameworkEx, handle_robot_options
43
+
44
+ from .cli import VARIABLE_AND_PATH_OPTIONS, _is_interactive_stdin
45
+
46
+ if RF_VERSION >= (6, 0):
47
+
48
+ def _build_resource(path: str, settings: RobotSettings) -> Any:
49
+ return ResourceFileBuilder(lang=settings.languages, process_curdir=False).build(path)
50
+
51
+ def _build_suite(path: str, settings: RobotSettings, allow_empty_suite: bool = False) -> Any:
52
+ return TestSuiteBuilder(
53
+ lang=settings.languages, allow_empty_suite=allow_empty_suite, process_curdir=False
54
+ ).build(path)
55
+
56
+ else:
57
+
58
+ def _build_resource(path: str, settings: RobotSettings) -> Any:
59
+ return ResourceFileBuilder(process_curdir=False).build(path)
60
+
61
+ def _build_suite(path: str, settings: RobotSettings, allow_empty_suite: bool = False) -> Any:
62
+ return TestSuiteBuilder(allow_empty_suite=allow_empty_suite, process_curdir=False).build(path)
63
+
64
+
65
+ if RF_VERSION >= (6, 1):
66
+
67
+ def _suite_name_from_source(source: str) -> str:
68
+ return str(TestSuite.name_from_source(source))
69
+
70
+ else:
71
+ from robot.running.builder.parsers import format_name
72
+
73
+ def _suite_name_from_source(source: str) -> str:
74
+ return str(format_name(source))
75
+
76
+
77
+ # Robot Framework recognises a suite initialization file by this name, with any suite file extension
78
+ _INIT_FILE_NAME = "__init__"
79
+ # files with these extensions are resource files only, never suite files
80
+ _RESOURCE_ONLY_EXTENSIONS = {".resource", ".rsrc"}
81
+
82
+
83
+ @dataclass
84
+ class KeywordEntry:
85
+ name: str
86
+ anchor: str
87
+ args: str
88
+ short_doc: str
89
+ tags: List[str]
90
+ doc: str
91
+
92
+
93
+ @dataclass
94
+ class TypeEntry:
95
+ name: str
96
+ anchor: str
97
+
98
+
99
+ @dataclass
100
+ class LibraryDocumentation:
101
+ name: str
102
+ type: str
103
+ version: str
104
+ scope: str
105
+ source: str
106
+ lineno: int
107
+ markdown: str
108
+ keywords: List[KeywordEntry]
109
+ types: List[TypeEntry]
110
+
111
+
112
+ @dataclass
113
+ class KeywordSelection:
114
+ name: str
115
+ type: str
116
+ version: str
117
+ scope: str
118
+ source: str
119
+ lineno: int
120
+ keywords: List[KeywordEntry]
121
+
122
+
123
+ LANGUAGE_OPTION = click.option(
124
+ "--language",
125
+ metavar="LANG",
126
+ type=str,
127
+ multiple=True,
128
+ help="Activate localization in addition to the languages of the configuration. See `robot --language` option.",
129
+ )
130
+
131
+ BASE_DIR_OPTION = click.option(
132
+ "--base-dir",
133
+ type=click.Path(file_okay=False, path_type=Path),
134
+ help="The directory for `${CURDIR}` and relative paths in TARGET. Default: the current directory.",
135
+ )
136
+
137
+ OUTPUT_OPTION = click.option(
138
+ "-o",
139
+ "--output",
140
+ type=click.Path(dir_okay=False, path_type=Path),
141
+ help="Write the Markdown to this file instead of the terminal.",
142
+ )
143
+
144
+ TARGET_ARGUMENT = click.argument("target", metavar="TARGET")
145
+
146
+
147
+ @click.group(invoke_without_command=False)
148
+ def doc() -> None:
149
+ """\
150
+ Show the documentation of a library, resource file or suite file.
151
+
152
+ TARGET is a library name, the path of a library, resource or suite file,
153
+ with import arguments appended as in Libdoc: `Name::arg1::arg2`. It is
154
+ loaded anew on every call, with the `robot.toml` configuration and the
155
+ selected profiles.
156
+
157
+ \b
158
+ Examples:
159
+ ```
160
+ robotcode doc lib Collections
161
+ robotcode doc keywords Collections dictionary
162
+ robotcode doc keyword BuiltIn "Should Be*"
163
+ robotcode doc browse resources/common.resource
164
+ robotcode --format json doc lib 'Remote::http://${HOST}:8270'
165
+ ```
166
+ """
167
+
168
+
169
+ def _abort(app: Application, messages: List[str]) -> None:
170
+ for message in messages:
171
+ app.error(message)
172
+ app.exit(1)
173
+
174
+
175
+ def _load_file(path: str, settings: RobotSettings) -> LibraryDoc:
176
+ file = Path(path)
177
+
178
+ if file.stem.lower() == _INIT_FILE_NAME and file.suffix.lower() not in _RESOURCE_ONLY_EXTENSIONS:
179
+ # documented as the suite of its directory, as Libdoc documents `__init__.robot`
180
+ suite = _build_suite(path, settings, allow_empty_suite=True)
181
+ name = suite.name
182
+ if name == _suite_name_from_source(path):
183
+ name = _suite_name_from_source(str(file.parent))
184
+ return _suite_doc(suite, path, name)
185
+
186
+ try:
187
+ resource = _build_resource(path, settings)
188
+ except DataError as resource_error:
189
+ if file.suffix.lower() in _RESOURCE_ONLY_EXTENSIONS:
190
+ # as Libdoc, a resource file is not tried as a suite file
191
+ raise
192
+ try:
193
+ suite = _build_suite(path, settings)
194
+ except DataError:
195
+ # as in Libdoc, the error of the file as a resource file
196
+ raise resource_error from None
197
+ return _suite_doc(suite, path, suite.name)
198
+
199
+ return get_resource_doc_from_resource(resource, path)
200
+
201
+
202
+ def _suite_doc(suite: Any, path: str, name: str) -> LibraryDoc:
203
+ result = get_resource_doc_from_resource(suite.resource, path)
204
+ result.name = name
205
+ result.type = "SUITE"
206
+ return result
207
+
208
+
209
+ class _RobotMessages:
210
+ """Collects the errors and warnings Robot Framework logs while a target is loaded,
211
+ so that they are reported like the command's own messages."""
212
+
213
+ def __init__(self) -> None:
214
+ self.active = False
215
+ self.messages: List[Tuple[str, str]] = []
216
+
217
+ def message(self, msg: Any) -> None:
218
+ if self.active and msg.level in ("ERROR", "WARN"):
219
+ self.messages.append((msg.level, msg.message))
220
+
221
+ def report(self, app: Application) -> None:
222
+ for level, message in self.messages:
223
+ (app.error if level == "ERROR" else app.warning)(message)
224
+ self.messages = []
225
+
226
+
227
+ def _absolute_from(start_dir: Path, value: str) -> str:
228
+ """`value`, a path that may be followed by `:` or `;` and arguments, with the path
229
+ made absolute against `start_dir` when it exists there."""
230
+ for end in (len(value), *(i for i, c in enumerate(value) if c in ":;")):
231
+ path = Path(value[:end])
232
+ if value[:end] and not path.is_absolute() and (start_dir / path).exists():
233
+ return str((start_dir / path).absolute()) + value[end:]
234
+ return value
235
+
236
+
237
+ def _load(
238
+ app: Application,
239
+ target: str,
240
+ variable: Tuple[str, ...],
241
+ variablefile: Tuple[str, ...],
242
+ pythonpath: Tuple[str, ...],
243
+ language: Tuple[str, ...],
244
+ base_dir: Optional[Path],
245
+ ) -> LibraryDoc:
246
+ """Load TARGET like `robot` loads it, or abort like Libdoc when it cannot be loaded."""
247
+ name, *args = target.split("::")
248
+ if not name:
249
+ raise click.UsageError("TARGET must name a library, a resource file or a suite file.")
250
+
251
+ start_dir = Path.cwd()
252
+ base = str((base_dir or start_dir).absolute())
253
+
254
+ # paths given to the command are relative to where it was started, as TARGET and `-o` are
255
+ robot_options: Tuple[str, ...] = ()
256
+ for var in variable:
257
+ robot_options += ("--variable", var)
258
+ for varfile in variablefile:
259
+ robot_options += ("--variablefile", _absolute_from(start_dir, varfile))
260
+ for pypath in pythonpath:
261
+ robot_options += ("--pythonpath", _absolute_from(start_dir, pypath))
262
+ for lang in language:
263
+ robot_options += ("--language", lang)
264
+
265
+ root_folder, _, cmd_options = handle_robot_options(app, robot_options)
266
+
267
+ messages = _RobotMessages()
268
+ # Robot Framework's own console output is replaced by the command's, see `_RobotMessages`
269
+ LOGGER.unregister_console_logger()
270
+ LOGGER.register_logger(messages)
271
+ messages.active = True
272
+
273
+ result: Optional[LibraryDoc] = None
274
+ is_library = False
275
+ try:
276
+ with app.save_syspath(), app.chdir(root_folder) as orig_folder:
277
+ try:
278
+ # the options of the command follow the configuration's, so their languages add up
279
+ options, _ = RobotFrameworkEx(
280
+ app, ["."], False, root_folder=root_folder, orig_folder=orig_folder
281
+ ).parse_arguments((*cmd_options, *robot_options))
282
+ if app.config.dry:
283
+ app.echo(
284
+ f"Dry run, not loading anything. Would document '{target}' with the following options:\n"
285
+ + "\n".join(f"{k} = {v!r}" for k, v in options.items())
286
+ )
287
+ app.exit(INFO_PRINTED)
288
+
289
+ # a documentation writes no output files, so their directories are not created either
290
+ settings = RobotSettings(options, output=None, log=None, report=None, debugfile=None, xunit=None)
291
+ if settings.pythonpath:
292
+ sys.path = settings.pythonpath + sys.path
293
+
294
+ # variable files may print, but standard output belongs to the documentation
295
+ with contextlib.redirect_stdout(sys.stderr):
296
+ variables = GlobalVariables(settings).as_dict()
297
+ messages.report(app)
298
+ working_dir = str(root_folder or start_dir)
299
+
300
+ if contains_variable(name, "$@&%"):
301
+ # backslashes in TARGET are part of the text, such as in a path on Windows
302
+ name = resolve_robot_variables(working_dir, base, variables).replace_string(
303
+ name.replace("\\", "\\\\"), ignore_errors=False
304
+ )
305
+ if Path(name).suffix.lower() in RESOURCE_EXTENSIONS:
306
+ if args:
307
+ raise DataError(f"Resource and suite files take no import arguments: '{target}'.")
308
+ result = _load_file(find_file(name, working_dir, base, variables), settings)
309
+ else:
310
+ is_library = True
311
+ # a name that exists as a path is made absolute first, as Libdoc does
312
+ library_path = Path(base, name.replace("/", os.sep))
313
+ if library_path.exists():
314
+ name = str(library_path.absolute())
315
+ result = get_library_doc(
316
+ name,
317
+ tuple(arg.replace("\\", "\\\\") for arg in args),
318
+ working_dir=working_dir,
319
+ base_dir=base,
320
+ command_line_variables=variables,
321
+ )
322
+ except Information as err:
323
+ app.echo(str(err))
324
+ app.exit(INFO_PRINTED)
325
+ except (DataError, IgnoreEasterEggLibraryWarning) as err:
326
+ # the errors of a failed load belong to that load, as in Libdoc
327
+ messages.messages = []
328
+ _abort(app, [str(err)])
329
+ finally:
330
+ messages.active = False
331
+ LOGGER.unregister_logger(messages)
332
+
333
+ assert result is not None
334
+
335
+ errors = [e.message for e in result.errors or []]
336
+ if is_library and (not result.inits or result.loaded_without_arguments or (errors and not result.keywords)):
337
+ # no instance with the given arguments, or no keywords to read from it: Libdoc documents nothing either
338
+ _abort(app, errors or [f"Importing '{target}' failed."])
339
+
340
+ logged = [message for _, message in messages.messages]
341
+ messages.report(app)
342
+ for message in errors:
343
+ if not any(message in other for other in logged):
344
+ app.error(message)
345
+ for kw in result.keywords.values():
346
+ for error in kw.errors or []:
347
+ if not any(error.message in other for other in logged):
348
+ app.error(f"{kw.name}: {error.message}")
349
+
350
+ return result
351
+
352
+
353
+ def _page(library: LibraryDoc) -> str:
354
+ return library.to_markdown(only_doc=False, header_level=0, link_resolver=anchor_link_resolver)
355
+
356
+
357
+ def _check_output(app: Application, output: Optional[Path]) -> Optional[Path]:
358
+ if output is None:
359
+ return None
360
+ if app.config.output_format not in (None, OutputFormat.TEXT):
361
+ raise click.UsageError("`-o/--output` writes Markdown and cannot be used with `--format`.")
362
+ # relative to where the command was started, before anything changes the current directory
363
+ return output.absolute()
364
+
365
+
366
+ def _is_text_format(app: Application) -> bool:
367
+ return app.config.output_format in (None, OutputFormat.TEXT)
368
+
369
+
370
+ def _write_markdown(app: Application, text: str, output: Optional[Path]) -> None:
371
+ if output is None:
372
+ app.echo_as_markdown(text.rstrip("\n"))
373
+ return
374
+ try:
375
+ # as Libdoc, the directory of the file is created when it is missing
376
+ output.parent.mkdir(parents=True, exist_ok=True)
377
+ output.write_text(text, encoding="utf-8", newline="\n")
378
+ except OSError as e:
379
+ _abort(app, [f"Writing '{output}' failed: {e}"])
380
+
381
+
382
+ def _one_line(text: str) -> str:
383
+ return " ".join(line.strip() for line in text.splitlines() if line.strip())
384
+
385
+
386
+ def _keyword_entries(library: LibraryDoc, page: str, keywords: List[KeywordDoc]) -> List[KeywordEntry]:
387
+ page_keywords = library.get_page_keywords()
388
+ anchors = dict(zip((id(kw) for kw in page_keywords), library.get_page_anchors(page)[0]))
389
+ return [
390
+ KeywordEntry(
391
+ name=kw.name,
392
+ anchor=anchors.get(id(kw), ""),
393
+ args=kw.parameter_signature().replace("‍", ""),
394
+ short_doc=_one_line(kw.format_text(getshortdoc(kw.doc))),
395
+ tags=list(kw.tags),
396
+ doc=kw.format_text(kw.doc),
397
+ )
398
+ for kw in keywords
399
+ ]
400
+
401
+
402
+ def _keyword_selection(library: LibraryDoc, keywords: List[KeywordDoc]) -> KeywordSelection:
403
+ return KeywordSelection(
404
+ name=library.name,
405
+ type=library.type,
406
+ version=library.version,
407
+ scope=library.scope,
408
+ source=library.source or "",
409
+ lineno=library.line_no,
410
+ keywords=_keyword_entries(library, _page(library), keywords),
411
+ )
412
+
413
+
414
+ def _title(library: LibraryDoc) -> str:
415
+ return f"# {library.type.capitalize()} *{library.name}*"
416
+
417
+
418
+ @doc.command()
419
+ @TARGET_ARGUMENT
420
+ @add_options(*VARIABLE_AND_PATH_OPTIONS, LANGUAGE_OPTION, BASE_DIR_OPTION, OUTPUT_OPTION)
421
+ @pass_application
422
+ def lib(
423
+ app: Application,
424
+ target: str,
425
+ variable: Tuple[str, ...],
426
+ variablefile: Tuple[str, ...],
427
+ pythonpath: Tuple[str, ...],
428
+ language: Tuple[str, ...],
429
+ base_dir: Optional[Path],
430
+ output: Optional[Path],
431
+ ) -> None:
432
+ """\
433
+ Show the full documentation of TARGET.
434
+
435
+ The page has the introduction, the importing arguments, an index and
436
+ the documentation of every keyword, and the data types the keywords use.
437
+
438
+ \b
439
+ Examples:
440
+ ```
441
+ robotcode doc lib Collections
442
+ robotcode doc lib -o common.md resources/common.resource
443
+ robotcode --format json doc lib 'MyLibrary::arg1'
444
+ ```
445
+ """
446
+ output = _check_output(app, output)
447
+ library = _load(app, target, variable, variablefile, pythonpath, language, base_dir)
448
+ page = _page(library)
449
+
450
+ if not _is_text_format(app):
451
+ _, type_anchors = library.get_page_anchors(page)
452
+ app.print_data(
453
+ LibraryDocumentation(
454
+ name=library.name,
455
+ type=library.type,
456
+ version=library.version,
457
+ scope=library.scope,
458
+ source=library.source or "",
459
+ lineno=library.line_no,
460
+ markdown=page,
461
+ keywords=_keyword_entries(library, page, library.get_page_keywords()),
462
+ types=[TypeEntry(t.name, type_anchors.get(t.name, "")) for t in library.get_page_types()],
463
+ )
464
+ )
465
+ return
466
+
467
+ _write_markdown(app, page, output)
468
+
469
+
470
+ @doc.command()
471
+ @TARGET_ARGUMENT
472
+ @click.argument("patterns", metavar="[PATTERN]...", nargs=-1)
473
+ @click.option(
474
+ "--tag",
475
+ "tags",
476
+ metavar="TAG",
477
+ multiple=True,
478
+ help="Only keywords with a tag that matches TAG. `*` and `?` are wildcards. Can be given more than once.",
479
+ )
480
+ @add_options(*VARIABLE_AND_PATH_OPTIONS, LANGUAGE_OPTION, BASE_DIR_OPTION, OUTPUT_OPTION)
481
+ @pass_application
482
+ def keywords(
483
+ app: Application,
484
+ target: str,
485
+ patterns: Tuple[str, ...],
486
+ tags: Tuple[str, ...],
487
+ variable: Tuple[str, ...],
488
+ variablefile: Tuple[str, ...],
489
+ pythonpath: Tuple[str, ...],
490
+ language: Tuple[str, ...],
491
+ base_dir: Optional[Path],
492
+ output: Optional[Path],
493
+ ) -> None:
494
+ """\
495
+ List the keywords of TARGET with their arguments and short documentation.
496
+
497
+ A PATTERN selects the keywords whose name contains it. `*` and `?` are
498
+ wildcards; case, spaces and underscores are ignored. Several patterns
499
+ select the keywords that match any of them.
500
+
501
+ \b
502
+ Examples:
503
+ ```
504
+ robotcode doc keywords Collections
505
+ robotcode doc keywords Collections dictionary
506
+ robotcode doc keywords Collections "get*list"
507
+ robotcode doc keywords Collections append insert
508
+ robotcode doc keywords resources/common.resource --tag smoke
509
+ ```
510
+ """
511
+ output = _check_output(app, output)
512
+ library = _load(app, target, variable, variablefile, pythonpath, language, base_dir)
513
+
514
+ name_matcher = MultiMatcher([f"*{p}*" for p in patterns], ignore="_", match_if_no_patterns=True)
515
+ tag_matcher = MultiMatcher(tags, ignore="_", match_if_no_patterns=True)
516
+ selected = [
517
+ kw
518
+ for kw in library.get_page_keywords()
519
+ if name_matcher.match(kw.name) and (not tags or tag_matcher.match_any(kw.tags))
520
+ ]
521
+
522
+ if not _is_text_format(app):
523
+ app.print_data(_keyword_selection(library, selected))
524
+ return
525
+
526
+ lines = [
527
+ f"- **{kw.name}** `{kw.parameter_signature().replace(chr(0x200D), '')}`"
528
+ + (f" — {short_doc}" if (short_doc := _one_line(kw.format_text(getshortdoc(kw.doc)))) else "")
529
+ for kw in selected
530
+ ]
531
+ _write_markdown(app, "\n".join([_title(library), "", *(lines or ["_(no keyword matches)_"])]) + "\n", output)
532
+
533
+
534
+ @doc.command()
535
+ @TARGET_ARGUMENT
536
+ @click.argument("names", metavar="NAME...", nargs=-1, required=True)
537
+ @add_options(*VARIABLE_AND_PATH_OPTIONS, LANGUAGE_OPTION, BASE_DIR_OPTION, OUTPUT_OPTION)
538
+ @pass_application
539
+ def keyword(
540
+ app: Application,
541
+ target: str,
542
+ names: Tuple[str, ...],
543
+ variable: Tuple[str, ...],
544
+ variablefile: Tuple[str, ...],
545
+ pythonpath: Tuple[str, ...],
546
+ language: Tuple[str, ...],
547
+ base_dir: Optional[Path],
548
+ output: Optional[Path],
549
+ ) -> None:
550
+ """\
551
+ Show the full documentation of keywords of TARGET.
552
+
553
+ A NAME selects the keywords whose name matches it exactly or as a pattern
554
+ with `*` and `?`, and the keywords with embedded arguments that match it.
555
+ Case, spaces and underscores are ignored. The data types that the
556
+ arguments use follow the keywords.
557
+
558
+ \b
559
+ Examples:
560
+ ```
561
+ robotcode doc keyword Collections "Get Match Count"
562
+ robotcode doc keyword BuiltIn "Should Be*"
563
+ robotcode doc keyword resources/common.resource "Open Chrome Browser"
564
+ ```
565
+ """
566
+ output = _check_output(app, output)
567
+ library = _load(app, target, variable, variablefile, pythonpath, language, base_dir)
568
+
569
+ page_keywords = library.get_page_keywords()
570
+ selected_ids = set()
571
+ missing: List[str] = []
572
+ for name in names:
573
+ matcher = MultiMatcher([name], ignore="_")
574
+ matches = {id(kw) for kw in page_keywords if matcher.match(kw.name)}
575
+ matches.update(id(kw) for kw in library.keywords.iter_all(name))
576
+ matches &= {id(kw) for kw in page_keywords}
577
+ if not matches:
578
+ missing.append(name)
579
+ selected_ids |= matches
580
+ selected = [kw for kw in page_keywords if id(kw) in selected_ids]
581
+
582
+ if not _is_text_format(app):
583
+ app.print_data(_keyword_selection(library, selected))
584
+ elif selected:
585
+ type_docs: Dict[str, Any] = {}
586
+ for kw in selected:
587
+ for argument in kw.arguments:
588
+ for type_doc in library.get_types_for_argument(argument):
589
+ type_docs.setdefault(type_doc.name, type_doc)
590
+ parts = [kw.to_markdown() for kw in selected]
591
+ parts.extend(type_docs[t].to_markdown(header_level=1) for t in sorted(type_docs, key=lambda t: (t.lower(), t)))
592
+ # One document: a label that two parts define for other URLs would link both to the first URL.
593
+ labels: Dict[str, str] = {}
594
+ _write_markdown(
595
+ app,
596
+ "\n\n---\n\n".join(unique_reference_labels(part.strip(), labels) for part in parts) + "\n",
597
+ output,
598
+ )
599
+
600
+ if missing:
601
+ finder = RecommendationFinder(normalize)
602
+ candidates = [kw.name for kw in page_keywords]
603
+ for name in missing:
604
+ app.error(finder.find_and_format(name, candidates, f"No keyword matches '{name}'."))
605
+ app.exit(1)
606
+
607
+
608
+ def _is_interactive_stdout() -> bool:
609
+ try:
610
+ return sys.stdout.isatty()
611
+ except (AttributeError, ValueError, OSError):
612
+ return False
613
+
614
+
615
+ @doc.command()
616
+ @TARGET_ARGUMENT
617
+ @add_options(*VARIABLE_AND_PATH_OPTIONS, LANGUAGE_OPTION, BASE_DIR_OPTION)
618
+ @pass_application
619
+ def browse(
620
+ app: Application,
621
+ target: str,
622
+ variable: Tuple[str, ...],
623
+ variablefile: Tuple[str, ...],
624
+ pythonpath: Tuple[str, ...],
625
+ language: Tuple[str, ...],
626
+ base_dir: Optional[Path],
627
+ ) -> None:
628
+ """\
629
+ Browse the full documentation of TARGET in the terminal.
630
+
631
+ Opens the documentation viewer of the REPL, with links, search and
632
+ back/forward navigation. In AI-agent sessions, in pipes and without a
633
+ terminal, the Markdown is printed instead, as `robotcode doc lib` prints it.
634
+
635
+ \b
636
+ Examples:
637
+ ```
638
+ robotcode doc browse Collections
639
+ robotcode doc browse resources/common.resource
640
+ ```
641
+ """
642
+ library = _load(app, target, variable, variablefile, pythonpath, language, base_dir)
643
+ page = _page(library)
644
+
645
+ if _is_interactive_stdin() and _is_interactive_stdout() and not is_running_in_ai_agent():
646
+ from ._pt.doc_viewer import DocViewer
647
+
648
+ DocViewer().run(library.name, page, outline=True)
649
+ return
650
+
651
+ app.echo_as_markdown(page.rstrip("\n"))
robotcode/repl/hooks.py CHANGED
@@ -5,8 +5,9 @@ import click
5
5
  from robotcode.plugin import hookimpl
6
6
 
7
7
  from .cli import repl, robot_debug
8
+ from .doc_cli import doc
8
9
 
9
10
 
10
11
  @hookimpl
11
12
  def register_cli_commands() -> List[click.Command]:
12
- return [repl, robot_debug]
13
+ return [repl, robot_debug, doc]