Skip to content

Commit a884736

Browse files
committed
feat(repl): browse and search keywords from .kw
`.kw` does more than show a single keyword now. With no argument it lists every keyword you have imported, grouped by the library or resource it comes from. With text that isn't an exact keyword name it lists the keywords whose name contains that text, so you can find a keyword without remembering its full name or leaving the REPL. Giving an exact keyword name still shows that keyword's full documentation as before. In the interactive documentation viewer the listed keyword names are links: Tab to one and press Enter to open its documentation, and press `[` to go back to the list — the same navigation the cross-reference links inside a library page already use. On the plain backend the list comes back as text.
1 parent 3d1665d commit a884736

7 files changed

Lines changed: 340 additions & 81 deletions

File tree

packages/repl/src/robotcode/repl/_keyword_lookup.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
synchronously inside `suite.run()`.
1313
"""
1414

15-
from typing import Any, List, Optional, Tuple
15+
from typing import Any, Iterator, List, Optional, Tuple
1616

1717
from robot.running.context import EXECUTION_CONTEXTS
1818
from robot.utils import normalize
@@ -139,6 +139,25 @@ def _find_keyword_in_owner(owner: Any, normalized_name: str) -> Optional[Any]:
139139
return None
140140

141141

142+
def iter_keyword_owners() -> Iterator[Tuple[str, bool, List[str]]]:
143+
"""Yield ``(owner_name, is_resource, keyword_names)`` for each loaded
144+
library and resource, libraries first. ``keyword_names`` is sorted.
145+
Used to list / search what the session has imported.
146+
"""
147+
store = _current_kw_store()
148+
if store is None:
149+
return
150+
for owner in store.libraries.values():
151+
yield str(owner.name), False, _keyword_names(owner)
152+
for owner in store.resources.values():
153+
yield str(owner.name), True, _keyword_names(owner)
154+
155+
156+
def _keyword_names(owner: Any) -> List[str]:
157+
names = [str(kw.name) for kw in (getattr(owner, _LIB_KEYWORDS_ATTR, ()) or ()) if getattr(kw, "name", None)]
158+
return sorted(names)
159+
160+
142161
def lookup_library(name: str) -> Optional[Any]:
143162
"""The loaded library instance named ``name``, or ``None``.
144163

packages/repl/src/robotcode/repl/_pt/doc_viewer.py

Lines changed: 86 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,22 @@ class _RenderSnapshot(NamedTuple):
269269
anchor_to_line: Dict[str, int]
270270

271271

272+
class _NavState(NamedTuple):
273+
"""One entry in the back / forward navigation stacks.
274+
275+
Captures everything needed to return to a position the user was at:
276+
the document itself (`title` + `md_source`) plus the scroll offset
277+
and focused link. Carrying the source means a follow that loaded a
278+
*different* document — e.g. a keyword page opened from a list — can
279+
be reversed by reloading the previous document, not just scrolling.
280+
"""
281+
282+
title: str
283+
md_source: str
284+
scroll: int
285+
current_link: int
286+
287+
272288
# Viewer-only style entries. The host interpreter's `_DEFAULT_STYLE`
273289
# is for the prompt + log output; the viewer is its own Application
274290
# with its own narrow palette, so we don't grow `_pt.components` for
@@ -459,7 +475,14 @@ class DocViewer:
459475
prompt's scrollback is undisturbed.
460476
"""
461477

462-
def __init__(self) -> None:
478+
def __init__(
479+
self,
480+
link_resolver: Optional[Callable[[str], Optional[Tuple[str, str]]]] = None,
481+
) -> None:
482+
# Resolves a followed link target that is neither an `#anchor`
483+
# nor an `http(s)://` URL into new `(title, markdown)` content,
484+
# loaded in place. Used to open a keyword's page from a list.
485+
self._link_resolver = link_resolver
463486
self._title = ""
464487

465488
# Body content: stored as fragments so we can re-render with
@@ -489,14 +512,13 @@ def __init__(self) -> None:
489512
self._current_link = -1
490513
self._anchor_to_line: Dict[str, int] = {}
491514

492-
# Browser-style back / forward stacks for `#anchor` follows.
493-
# Each entry is `(vertical_scroll, current_link)`; only the
494-
# scroll position + focused link are restored, search state
495-
# is intentionally untouched (going back shouldn't drop the
496-
# user's current search). External URL follows don't push
497-
# since the viewer itself doesn't move.
498-
self._back_stack: List[Tuple[int, int]] = []
499-
self._forward_stack: List[Tuple[int, int]] = []
515+
# Browser-style back / forward stacks for `#anchor` jumps and
516+
# in-place content follows (see `_NavState`). Search state is
517+
# intentionally untouched on back/forward (going back shouldn't
518+
# drop the user's current search). External URL follows don't
519+
# push since the viewer itself doesn't move.
520+
self._back_stack: List[_NavState] = []
521+
self._forward_stack: List[_NavState] = []
500522

501523
# Resize state. `_md_source` keeps the markdown around so we
502524
# can re-render at the new width when the terminal resizes.
@@ -601,6 +623,28 @@ def run(self, title: str, markdown: str) -> None:
601623
screen buffer, so the host prompt's terminal state survives
602624
the call untouched.
603625
"""
626+
# A fresh top-level invocation starts with empty history.
627+
self._back_stack = []
628+
self._forward_stack = []
629+
self._load_document(title, markdown)
630+
631+
try:
632+
self._app.run()
633+
finally:
634+
# Don't leak a pending reflow callback into the next
635+
# `.doc` invocation — if it fired against a closed app
636+
# the invalidate() at the end would be a no-op, but the
637+
# state shuffle would still run.
638+
if self._resize_task is not None:
639+
self._resize_task.cancel()
640+
self._resize_task = None
641+
642+
def _load_document(self, title: str, markdown: str) -> None:
643+
"""Adopt ``markdown`` as the current document and reset per-doc
644+
state (scroll, focused link, search). Leaves the back / forward
645+
stacks alone so it can be reused for in-place link follows;
646+
`run` clears them for a fresh top-level invocation.
647+
"""
604648
self._title = title
605649
self._md_source = markdown
606650
# New doc → all cached renders are for the OLD markdown. Wipe.
@@ -610,27 +654,25 @@ def run(self, title: str, markdown: str) -> None:
610654
body_width = max(size.columns - 2, 40)
611655
self._render_at_width(body_width)
612656

613-
# Reset interaction state for a fresh document.
614657
self._current_link = -1
615-
self._back_stack = []
616-
self._forward_stack = []
617658
self._in_search_mode = False
618659
self._search_query = ""
619660
self._matches = []
620661
self._current_match = -1
621662
self._search_buffer.reset()
622663
self._body_window.vertical_scroll = 0
623664

624-
try:
625-
self._app.run()
626-
finally:
627-
# Don't leak a pending reflow callback into the next
628-
# `.doc` invocation — if it fired against a closed app
629-
# the invalidate() at the end would be a no-op, but the
630-
# state shuffle would still run.
631-
if self._resize_task is not None:
632-
self._resize_task.cancel()
633-
self._resize_task = None
665+
def _current_state(self) -> _NavState:
666+
"""Snapshot the current document + position for the nav stacks."""
667+
return _NavState(self._title, self._md_source, self._body_window.vertical_scroll, self._current_link)
668+
669+
def _restore_state(self, state: _NavState) -> None:
670+
"""Return to a `_NavState`, reloading its document first if the
671+
current one differs."""
672+
if state.md_source != self._md_source:
673+
self._load_document(state.title, state.md_source)
674+
self._body_window.vertical_scroll = state.scroll
675+
self._current_link = state.current_link
634676

635677
def _render_at_width(self, body_width: int) -> None:
636678
"""Render the markdown at ``body_width`` and adopt the result.
@@ -923,21 +965,22 @@ def _follow_current_link(self) -> None:
923965
didn't match the markdown source title). For `http(s)://`
924966
targets, hand off to the OS's default browser via
925967
`webbrowser.open` — caught so an SSH session or headless
926-
environment can't take the viewer down.
927-
928-
Anchor jumps push the current scroll + focused link onto the
929-
back stack so `[` returns to the previous position
930-
(browser-style). The forward stack is cleared because the
931-
new jump branches the history. External URL follows don't
932-
push — the viewer didn't move.
968+
environment can't take the viewer down. Any other target is
969+
handed to `link_resolver` (if configured); when it returns
970+
content, that document is loaded in place.
971+
972+
Anchor jumps and content follows push the current position onto
973+
the back stack so `[` returns to it (browser-style). The forward
974+
stack is cleared because the new jump branches the history.
975+
External URL follows don't push — the viewer didn't move.
933976
"""
934977
if self._current_link < 0 or not self._links:
935978
return
936979
_start, _end, target = self._links[self._current_link]
937980
if target.startswith("#"):
938981
line = self._anchor_to_line.get(target[1:])
939982
if line is not None:
940-
self._back_stack.append((self._body_window.vertical_scroll, self._current_link))
983+
self._back_stack.append(self._current_state())
941984
self._forward_stack.clear()
942985
# `_max_scroll()` returns 0 before the first render —
943986
# only clamp when we have real render_info, otherwise
@@ -949,31 +992,34 @@ def _follow_current_link(self) -> None:
949992
webbrowser.open(target)
950993
except Exception:
951994
pass
995+
elif self._link_resolver is not None:
996+
resolved = self._link_resolver(target)
997+
if resolved is not None:
998+
self._back_stack.append(self._current_state())
999+
self._forward_stack.clear()
1000+
self._load_document(resolved[0], resolved[1])
9521001

9531002
def _go_back(self) -> bool:
954-
"""Restore the previous scroll position from the back stack.
1003+
"""Return to the previous position from the back stack.
9551004
9561005
Returns True if there was something to restore (so the
9571006
keybinding can decide whether to invalidate the layout).
9581007
Symmetric with `_go_forward` — pushing the *current* state
9591008
onto the opposite stack before popping, so back→forward
960-
round-trips return the user to where they were.
1009+
round-trips return the user to where they were. The previous
1010+
document is reloaded if a content follow had replaced it.
9611011
"""
9621012
if not self._back_stack:
9631013
return False
964-
self._forward_stack.append((self._body_window.vertical_scroll, self._current_link))
965-
scroll, link = self._back_stack.pop()
966-
self._body_window.vertical_scroll = scroll
967-
self._current_link = link
1014+
self._forward_stack.append(self._current_state())
1015+
self._restore_state(self._back_stack.pop())
9681016
return True
9691017

9701018
def _go_forward(self) -> bool:
9711019
if not self._forward_stack:
9721020
return False
973-
self._back_stack.append((self._body_window.vertical_scroll, self._current_link))
974-
scroll, link = self._forward_stack.pop()
975-
self._body_window.vertical_scroll = scroll
976-
self._current_link = link
1021+
self._back_stack.append(self._current_state())
1022+
self._restore_state(self._forward_stack.pop())
9771023
return True
9781024

9791025
# ------------------------------------------------------------------

packages/repl/src/robotcode/repl/console_interpreter.py

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@
3636

3737
from .__version__ import __version__
3838
from ._indent import compute_indent
39-
from ._keyword_lookup import _LIB_KEYWORDS_ATTR, lookup_keyword_owner, lookup_library, lookup_resource
39+
from ._keyword_lookup import (
40+
_LIB_KEYWORDS_ATTR,
41+
iter_keyword_owners,
42+
lookup_keyword_owner,
43+
lookup_library,
44+
lookup_resource,
45+
)
4046
from ._session_export import render_robot_file
4147
from .base_interpreter import BaseInterpreter, is_true
4248

@@ -595,48 +601,95 @@ def _vars(self, arg: str) -> None:
595601

596602
@dot_command("kw")
597603
def _kw(self, arg: str) -> None:
598-
"""Show the documentation for a keyword: .kw <name>
604+
"""Show or search keyword documentation: .kw [name-or-text]
599605
600-
Shows the keyword's signature (arguments with their types and
601-
defaults), description, tags, and where it comes from.
606+
With a keyword name, shows its full documentation: signature
607+
(arguments with their types and defaults), description, tags, and
608+
where it comes from. Names are resolved just like in a Robot
609+
Framework suite, so the `Owner.Keyword` form works too.
602610
603-
Names are resolved just like in a Robot Framework suite, so the
604-
`Owner.Keyword` form works too when the same name comes from more
605-
than one imported library or resource.
611+
With no argument, lists every keyword grouped by the library or
612+
resource it belongs to. With text that isn't an exact keyword
613+
name, lists the keywords whose name contains that text.
606614
607615
Usage:
608-
.kw <keyword-name>
616+
.kw list every loaded keyword
617+
.kw <text> list keywords whose name contains <text>
618+
.kw <keyword-name> show full documentation for one keyword
609619
610620
Examples:
611-
.kw Log
621+
.kw
622+
.kw append
612623
.kw Get From Dictionary
613624
.kw BuiltIn.Log
614625
"""
615626
if self.app is None:
616627
return
617628
if not arg:
618-
self.app.echo("Usage: .kw <keyword-name>")
629+
self._list_keywords(None)
619630
return
620-
found = lookup_keyword_owner(arg)
621-
if found is None:
622-
self.app.echo(f"No keyword found: {arg!r}")
631+
632+
doc = self._keyword_doc(arg)
633+
if doc is None:
634+
# Not an exact keyword — treat the argument as a search filter.
635+
self._list_keywords(arg)
623636
return
624-
owner, runtime_kw, is_resource = found
637+
self.show_doc(*doc)
625638

626-
kw_name = getattr(runtime_kw, "name", arg)
639+
def _keyword_doc(self, name: str) -> Optional[Tuple[str, str]]:
640+
"""`(title, markdown)` for an exactly-named keyword, or ``None``.
627641
628-
# Prefer the diagnostics `KeywordDoc` — it carries
629-
# `to_markdown(...)` with proper signature + arg table + types,
630-
# which the runtime keyword object (`StaticKeyword`) doesn't.
642+
Shared by `.kw <name>` and the doc-viewer link resolver. Prefers
643+
the diagnostics `KeywordDoc` (proper signature + arg table +
644+
types); falls back to a hand-built page from the runtime object
645+
when that conversion can't surface one.
646+
"""
647+
found = lookup_keyword_owner(name)
648+
if found is None:
649+
return None
650+
owner, runtime_kw, is_resource = found
651+
kw_name = getattr(runtime_kw, "name", name)
631652
diag_kw = _diagnostics_keyword_doc(owner, is_resource, kw_name)
632653
if diag_kw is not None:
633-
self.show_doc(kw_name, diag_kw.to_markdown(header_level=1))
654+
return kw_name, diag_kw.to_markdown(header_level=1)
655+
return kw_name, _render_runtime_keyword_md(runtime_kw, kw_name)
656+
657+
def _list_keywords(self, pattern: Optional[str]) -> None:
658+
"""List loaded keywords grouped by owner, optionally filtered by a
659+
case-insensitive substring of the keyword name."""
660+
if self.app is None:
661+
return
662+
needle = pattern.casefold() if pattern else None
663+
664+
sections: List[str] = []
665+
total = 0
666+
for owner_name, is_resource, names in iter_keyword_owners():
667+
if needle is not None:
668+
names = [n for n in names if needle in n.casefold()]
669+
if not names:
670+
continue
671+
kind = "Resource" if is_resource else "Library"
672+
sections.append(f"## {owner_name} ({kind})")
673+
sections.extend(self._keyword_list_entry(owner_name, n) for n in names)
674+
sections.append("")
675+
total += len(names)
676+
677+
if total == 0:
678+
if pattern:
679+
self.app.echo(f"No keywords found matching {pattern!r}.")
680+
else:
681+
self.app.echo("(no keywords loaded)")
634682
return
635683

636-
# Fallback for keywords the diagnostics conversion can't surface
637-
# — hand-build a minimal page from whatever the runtime object
638-
# exposes.
639-
self.show_doc(kw_name, _render_runtime_keyword_md(runtime_kw, kw_name))
684+
title = f"Keywords matching '{pattern}'" if pattern else "Keywords"
685+
self.show_doc(title, f"# {title}\n\n" + "\n".join(sections))
686+
687+
def _keyword_list_entry(self, owner_name: str, kw_name: str) -> str:
688+
"""One bullet line for `_list_keywords`. Plain text here; the
689+
prompt_toolkit backend overrides this to emit a follow-able link
690+
into the keyword's documentation."""
691+
del owner_name
692+
return f"- {kw_name}"
640693

641694
@dot_command("doc")
642695
def _doc(self, arg: str) -> None:

0 commit comments

Comments
 (0)