Repository: bhrutledge/zkeys

Issue:
Refactor `main()` into functions for each option
i.e., instead of embedding the logic in the `if ...` clause. This will make it more testable, and potentially allow for use as a library.

Discussion:

Patch:
diff --git a/src/zkeys.py b/src/zkeys.py
index 58a93c9..5c8d203 100644
--- a/src/zkeys.py
+++ b/src/zkeys.py
@@ -13,6 +13,69 @@ except metadata.PackageNotFoundError:  # pragma: no cover
     __version__ = "unknown"
 
 
+@dataclass
+class Keybinding:
+    """
+    Map an in-string like '^[b' to a ZLE widget like 'backward-word'.
+
+    >>> binding = Keybinding('^[b', 'backward-word')
+    >>> binding.in_string
+    '^[b'
+    >>> binding.prefix
+    '^['
+    >>> binding.character
+    'b'
+    >>> binding.widget
+    'backward-word'
+    """
+
+    in_string: str
+    widget: str
+
+    PREFIXES = {
+        prefix: rank
+        for rank, prefix in enumerate(
+            [
+                "^",
+                "^[",
+                "^[^",
+                "M-",
+                "M-^",
+                "^X",
+                "^X^",
+                "^[[",
+                "^[O",
+                "^[[3",
+            ]
+        )
+    }
+
+    IGNORE_WIDGETS = {
+        "bracketed-paste",
+        "digit-argument",
+        "neg-argument",
+        "self-insert-unmeta",
+    }
+
+    @property
+    def prefix(self) -> str:
+        return self.in_string[:-1]
+
+    @property
+    def character(self) -> str:
+        return self.in_string[-1]
+
+    def prefix_comparison(self) -> Tuple[int, str]:
+        prefix_rank = self.PREFIXES.get(self.prefix, 999)
+        return (prefix_rank, self.character.upper())
+
+    def widget_comparison(self) -> Tuple[str, int, str]:
+        return (self.widget, *self.prefix_comparison())
+
+
+Row = Tuple[str, List[str]]
+
+
 def main() -> None:
     parser = argparse.ArgumentParser(
         description=__doc__.strip(),
@@ -50,92 +113,20 @@ def main() -> None:
     )
     args = parser.parse_args()
 
-    lines = (line.strip() for line in args.file) if args.file else run_bindkey()
-    bindings = sorted(parse_bindkey(lines))
+    input_lines = (line.strip() for line in args.file) if args.file else run_bindkey()
+    bindings = list(parse_bindkey(input_lines))
 
     if args.widget:
-        widgets = group_bindings(bindings)
-
-        for widget, bindings in sorted(widgets.items()):
-            in_strings = "".join(f"{b.in_string:8}" for b in bindings)
-            print(f"{widget:40}{in_strings}".strip())
-
+        rows = group_by_widget(bindings)
     elif args.prefix:
-        prefixes = group_bindings(bindings, attr="prefix")
-
-        for prefix, bindings in prefixes.items():
-            keys = " ".join(b.key for b in bindings)
-            print(f"{prefix:8}{keys}".strip())
-
+        rows = group_by_prefix(bindings)
+    elif args.in_string:
+        rows = sort_by_in_string(bindings)
     else:
-        if not args.in_string:
-            bindings = sorted(bindings, key=lambda b: b.widget)
-
-        for binding in bindings:
-            print(f"{binding.in_string:10}{binding.widget}")
-
-
-PREFIXES = {
-    prefix: rank
-    for rank, prefix in enumerate(
-        [
-            "^",
-            "^[",
-            "^[^",
-            "M-",
-            "M-^",
-            "^X",
-            "^X^",
-            "^[[",
-            "^[O",
-            "^[[3",
-        ]
-    )
-}
-
-
-IGNORE_WIDGETS = {
-    "bracketed-paste",
-    "digit-argument",
-    "neg-argument",
-    "self-insert-unmeta",
-}
-
+        rows = sort_by_widget(bindings)
 
-@dataclass
-class Keybinding:
-    """
-    Map an in-string like '^[b' to a ZLE widget like 'backward-word'.
-
-    >>> binding = Keybinding('^[b', 'backward-word')
-    >>> binding.in_string
-    '^[b'
-    >>> binding.prefix
-    '^['
-    >>> binding.key
-    'b'
-    >>> binding.widget
-    'backward-word'
-    """
-
-    in_string: str
-    widget: str
-
-    @property
-    def prefix(self) -> str:
-        return self.in_string[:-1]
-
-    @property
-    def key(self) -> str:
-        return self.in_string[-1]
-
-    @property
-    def _compare_string(self) -> Tuple[int, str]:
-        """Compare by prefix rank, then by key."""
-        return (PREFIXES.get(self.prefix, 999), self.key.upper())
-
-    def __lt__(self, other: "Keybinding") -> bool:
-        return self._compare_string < other._compare_string
+    for output_line in format_table(rows):
+        print(output_line)
 
 
 def run_bindkey() -> Iterable[str]:
@@ -156,7 +147,7 @@ def parse_bindkey(lines: Iterable[str]) -> Iterable[Keybinding]:
             continue
 
         in_string, widget = match.groups()
-        if widget in IGNORE_WIDGETS:
+        if widget in Keybinding.IGNORE_WIDGETS:
             continue
 
         # HACK: Remove slashes for readability, e.g. \M-\$ becomes M-$
@@ -165,16 +156,45 @@ def parse_bindkey(lines: Iterable[str]) -> Iterable[Keybinding]:
         yield Keybinding(in_string, widget)
 
 
-def group_bindings(
-    bindings: Iterable[Keybinding],
-    *,
-    attr: str = "widget",
-) -> Dict[str, List[Keybinding]]:
-    group: Dict[str, List[Keybinding]] = defaultdict(list)
-    for binding in bindings:
-        group[getattr(binding, attr)].append(binding)
+def group_by_widget(bindings: Iterable[Keybinding]) -> Iterable[Row]:
+    widgets: Dict[str, List[str]] = defaultdict(list)
+
+    for binding in sorted(bindings, key=Keybinding.widget_comparison):
+        widgets[binding.widget].append(binding.in_string)
+
+    return widgets.items()
+
+
+def group_by_prefix(bindings: Iterable[Keybinding]) -> Iterable[Row]:
+    prefixes: Dict[str, List[str]] = defaultdict(list)
+
+    for binding in sorted(bindings, key=Keybinding.prefix_comparison):
+        prefixes[binding.prefix].append(binding.character)
+
+    return prefixes.items()
+
+
+def sort_by_in_string(bindings: Iterable[Keybinding]) -> List[Row]:
+    return [
+        (binding.in_string, [binding.widget])
+        for binding in sorted(bindings, key=Keybinding.prefix_comparison)
+    ]
+
+
+def sort_by_widget(bindings: Iterable[Keybinding]) -> List[Row]:
+    return [
+        (binding.in_string, [binding.widget])
+        for binding in sorted(bindings, key=Keybinding.widget_comparison)
+    ]
+
+
+def format_table(rows: Iterable[Row]) -> Iterable[str]:
+    key_width = max(len(k) for k, _ in rows) + 4
+    value_width = max(len(v) for _, values in rows for v in values)
 
-    return group
+    for key, values in rows:
+        values = [f"{v:{value_width}}" for v in values]
+        yield f"{key:{key_width}}{' '.join(values).strip()}"
 
 
 if __name__ == "__main__":  # pragma: no cover


Test patch:
diff --git a/tests/test_script.py b/tests/test_script.py
index cf639f0..f70731f 100644
--- a/tests/test_script.py
+++ b/tests/test_script.py
@@ -61,72 +61,72 @@ bindkey "\M-q" push-line
         pytest.param(
             [],
             """
-            ^X?       _complete_debug
-            ^L        clear-screen
-            ^[^L      clear-screen
-            ^[[B      down-line-or-history
-            ^Q        push-line
-            ^[Q       push-line
-            ^[q       push-line
-            M-Q       push-line
-            M-q       push-line
-            ^['       quote-line
-            ^["       quote-region
-            ^@        set-mark-command
-            ^_        undo
-            ^Xu       undo
-            ^X^U      undo
-            ^[[A      up-line-or-history
+            ^X?     _complete_debug
+            ^L      clear-screen
+            ^[^L    clear-screen
+            ^[[B    down-line-or-history
+            ^Q      push-line
+            ^[Q     push-line
+            ^[q     push-line
+            M-Q     push-line
+            M-q     push-line
+            ^['     quote-line
+            ^["     quote-region
+            ^@      set-mark-command
+            ^_      undo
+            ^Xu     undo
+            ^X^U    undo
+            ^[[A    up-line-or-history
             """,
             id="sorted_by_widget",
         ),
         pytest.param(
             ["-i"],
             """
-            ^@        set-mark-command
-            ^L        clear-screen
-            ^Q        push-line
-            ^_        undo
-            ^["       quote-region
-            ^['       quote-line
-            ^[Q       push-line
-            ^[q       push-line
-            ^[^L      clear-screen
-            M-Q       push-line
-            M-q       push-line
-            ^X?       _complete_debug
-            ^Xu       undo
-            ^X^U      undo
-            ^[[A      up-line-or-history
-            ^[[B      down-line-or-history
+            ^@      set-mark-command
+            ^L      clear-screen
+            ^Q      push-line
+            ^_      undo
+            ^["     quote-region
+            ^['     quote-line
+            ^[Q     push-line
+            ^[q     push-line
+            ^[^L    clear-screen
+            M-Q     push-line
+            M-q     push-line
+            ^X?     _complete_debug
+            ^Xu     undo
+            ^X^U    undo
+            ^[[A    up-line-or-history
+            ^[[B    down-line-or-history
             """,
             id="sorted_by_string",
         ),
         pytest.param(
             ["-w"],
             """
-            _complete_debug                         ^X?
-            clear-screen                            ^L      ^[^L
-            down-line-or-history                    ^[[B
-            push-line                               ^Q      ^[Q     ^[q     M-Q     M-q
-            quote-line                              ^['
-            quote-region                            ^["
-            set-mark-command                        ^@
-            undo                                    ^_      ^Xu     ^X^U
-            up-line-or-history                      ^[[A
+            _complete_debug         ^X?
+            clear-screen            ^L   ^[^L
+            down-line-or-history    ^[[B
+            push-line               ^Q   ^[Q  ^[q  M-Q  M-q
+            quote-line              ^['
+            quote-region            ^["
+            set-mark-command        ^@
+            undo                    ^_   ^Xu  ^X^U
+            up-line-or-history      ^[[A
             """,
             id="grouped_by_widget",
         ),
         pytest.param(
             ["-p"],
             """
-            ^       @ L Q _
-            ^[      " ' Q q
-            ^[^     L
-            M-      Q q
-            ^X      ? u
-            ^X^     U
-            ^[[     A B
+            ^      @ L Q _
+            ^[     " ' Q q
+            ^[^    L
+            M-     Q q
+            ^X     ? u
+            ^X^    U
+            ^[[    A B
             """,
             id="grouped_by_prefix",
         ),