Coverage for  / home / runner / work / bijux-cli / bijux-cli / src / bijux_cli / cli / repl / completion.py: 100%

83 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-01-26 17:59 +0000

1# SPDX-License-Identifier: Apache-2.0 

2# Copyright © 2025 Bijan Mousavi 

3 

4"""Completion helpers for the REPL.""" 

5 

6from __future__ import annotations 

7 

8from collections.abc import Iterator 

9import shlex 

10from typing import Any 

11 

12from prompt_toolkit.completion import CompleteEvent, Completer, Completion 

13from prompt_toolkit.document import Document 

14import typer 

15 

16from bijux_cli.cli.core.constants import ( 

17 OPT_FORMAT, 

18 OPT_HELP, 

19 OPT_LOG_LEVEL, 

20 OPT_QUIET, 

21 PRETTY_FLAGS, 

22) 

23 

24GLOBAL_OPTS = [ 

25 *OPT_QUIET, 

26 *OPT_FORMAT, 

27 *OPT_LOG_LEVEL, 

28 *PRETTY_FLAGS, 

29 *OPT_HELP, 

30] 

31 

32_BUILTINS = ("exit", "quit") 

33 

34 

35def _split_words(text: str) -> list[str]: 

36 """Split input text into shell-like words for completion.""" 

37 try: 

38 words: list[str] = shlex.split(text) 

39 except ValueError: 

40 return [] 

41 if text.endswith(" ") or not text: 

42 words.append("") 

43 return words 

44 

45 

46def _collect_completions( 

47 words: list[str], 

48 cmd_map: dict[tuple[str, ...], Any], 

49 builtins: tuple[str, ...], 

50) -> list[tuple[str, int, str | None]]: 

51 """Compute completion tuples for the current buffer state.""" 

52 if not words: 

53 return [] 

54 

55 current = words[-1] 

56 completions: list[tuple[str, int, str | None]] = [] 

57 

58 if current.startswith("-"): 

59 completions.extend( 

60 (opt, -len(current), None) for opt in GLOBAL_OPTS if opt.startswith(current) 

61 ) 

62 

63 cmd_obj = None 

64 for i in range(len(words) - 1, 0, -1): 

65 key = tuple(words[:i]) 

66 if key in cmd_map: 

67 cmd_obj = cmd_map[key] 

68 break 

69 

70 if cmd_obj is None: 

71 completions.extend( 

72 (b, -len(current), None) for b in builtins if b.startswith(current) 

73 ) 

74 

75 completions.extend( 

76 (key[0], -len(current), None) 

77 for key in cmd_map 

78 if len(key) == 1 and key[0].startswith(current) 

79 ) 

80 if words[:3] == ["config", "set", ""]: 

81 completions.append(("KEY=VALUE", 0, "KEY=VALUE")) 

82 elif not completions and current == "": 

83 completions.append(("DUMMY", 0, "DUMMY")) 

84 return completions 

85 

86 is_group = hasattr(cmd_obj, "registered_commands") or hasattr( 

87 cmd_obj, "registered_groups" 

88 ) 

89 if is_group: 

90 names = [c.name for c in getattr(cmd_obj, "registered_commands", [])] 

91 names += [g.name for g in getattr(cmd_obj, "registered_groups", [])] 

92 completions.extend( 

93 (name, -len(current), None) for name in names if name.startswith(current) 

94 ) 

95 elif hasattr(cmd_obj, "params"): 

96 for param in cmd_obj.params: 

97 completions.extend( 

98 (opt, -len(current), None) 

99 for opt in (*param.opts, *(getattr(param, "secondary_opts", []) or [])) 

100 if opt.startswith(current) 

101 ) 

102 

103 if current and "--help".startswith(current): 

104 completions.append(("--help", -len(current), None)) 

105 

106 if not completions and words[:3] == ["config", "set", ""]: 

107 completions.append(("KEY=VALUE", 0, "KEY=VALUE")) 

108 elif not completions and current == "": 

109 completions.append(("DUMMY", 0, "DUMMY")) 

110 return completions 

111 

112 

113class CommandCompleter(Completer): 

114 """Provides context-aware tab-completion for the REPL.""" 

115 

116 def __init__(self, main_app: typer.Typer) -> None: 

117 """Initializes the completer.""" 

118 self.main_app = main_app 

119 self._cmd_map = self._collect(main_app) 

120 self._BUILTINS = _BUILTINS 

121 

122 def _collect( 

123 self, 

124 app: typer.Typer, 

125 path: list[str] | None = None, 

126 ) -> dict[tuple[str, ...], Any]: 

127 """Build a command lookup table keyed by command path.""" 

128 path = path or [] 

129 out: dict[tuple[str, ...], Any] = {} 

130 for cmd in getattr(app, "registered_commands", []): 

131 out[tuple(path + [cmd.name])] = cmd 

132 for grp in getattr(app, "registered_groups", []): 

133 out[tuple(path + [grp.name])] = grp.typer_instance 

134 out.update(self._collect(grp.typer_instance, path + [grp.name])) 

135 return out 

136 

137 def _find( 

138 self, 

139 words: list[str], 

140 ) -> tuple[Any | None, list[str]]: 

141 """Locate the deepest command match and return remaining tokens.""" 

142 for i in range(len(words), 0, -1): 

143 key = tuple(words[:i]) 

144 if key in self._cmd_map: 

145 return self._cmd_map[key], words[i:] 

146 return None, words 

147 

148 def get_completions( 

149 self, 

150 document: Document, 

151 _complete_event: CompleteEvent, 

152 ) -> Iterator[Completion]: 

153 """Yield completions for the current prompt buffer.""" 

154 text = document.text_before_cursor 

155 words = _split_words(text) 

156 if not words: 

157 return 

158 for value, start, display in _collect_completions( 

159 words, self._cmd_map, self._BUILTINS 

160 ): 

161 if display is None: 

162 yield Completion(value, start_position=start) 

163 else: 

164 yield Completion(value, display=display, start_position=start)