Repository: ansible/ansible-navigator (language: python)

Issue:
Add spell checking (cspell) to pre-commit (as optional)
This is not required for anyone to use.

but it works really well with https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker

This adds:
- new precommit entry for cspell
- Add a cspell dictionary
- Add a cspell configuration file

To use it (in addition to the vscode plugin)
```
pre-commit run cspell --hook-stage manual --all-files
```

Future PR(s) will give better access to it via tox without running the entirety of lint-vetting, which can be rather noisy.

(will need to keep an eye out for cspell releases as all the PRs aren't in the dictionaries yet)



Discussion / hints:

Fix patch:
diff --git a/.flake8 b/.flake8
index 6472faa42..f233eb365 100644
--- a/.flake8
+++ b/.flake8
@@ -148,7 +148,7 @@ per-file-ignores =
   src/ansible_navigator/tm_tokenize/tokenize.py: D100, D201, D400, D403, DAR101, DAR201, WPS210, WPS300
   src/ansible_navigator/tm_tokenize/utils.py: D100, D400, D403, DAR101, DAR201, WPS100, WPS111, WPS609
   src/ansible_navigator/ui_framework/__init__.py: D200, D400, WPS201, WPS300, WPS412
-  src/ansible_navigator/ui_framework/colorize.py: D200, D205, D400, D403, DAR101, DAR201, WPS110, WPS111, WPS114, WPS202, WPS204, WPS210, WPS220, WPS221, WPS226, WPS231, WPS232, WPS300, WPS306, WPS323, WPS331, WPS338, WPS355, WPS407, WPS420, WPS432, WPS440, WPS518
+  src/ansible_navigator/ui_framework/colorize.py: WPS110, WPS111, WPS114, WPS202, WPS204, WPS210, WPS220, WPS221, WPS226, WPS231, WPS232, WPS300, WPS306, WPS323, WPS331, WPS338, WPS355, WPS407, WPS420, WPS432, WPS440, WPS518
   src/ansible_navigator/ui_framework/curses_defs.py: D200, D400
   src/ansible_navigator/ui_framework/curses_window.py: D200, D202, D205, D209, D400, D401, D403, DAR101, DAR201, WPS110, WPS111, WPS210, WPS213, WPS220, WPS221, WPS229, WPS231, WPS300, WPS306, WPS323, WPS349, WPS407, WPS420, WPS436, WPS450
   src/ansible_navigator/ui_framework/field_button.py: D200, D205, D400, D403, DAR101, DAR201, WPS300, WPS306, WPS502, WPS601
diff --git a/src/ansible_navigator/ui_framework/colorize.py b/src/ansible_navigator/ui_framework/colorize.py
index a8c8feabd..c3d72f958 100644
--- a/src/ansible_navigator/ui_framework/colorize.py
+++ b/src/ansible_navigator/ui_framework/colorize.py
@@ -1,6 +1,5 @@
 # cspell:ignore A_INVIS
-"""Tokenize and color text
-"""
+"""Tokenize and color text."""
 
 import colorsys
 import curses
@@ -11,11 +10,20 @@
 import re
 
 from itertools import chain
+from typing import Dict
+from typing import List
+from typing import Optional
+from typing import Tuple
+from typing import Union
 
 from ..tm_tokenize.grammars import Grammars
+from ..tm_tokenize.region import Regions
 from ..tm_tokenize.tokenize import tokenize
 from .curses_defs import CursesLine
 from .curses_defs import CursesLinePart
+from .curses_defs import CursesLines
+from .curses_defs import RgbTuple
+from .curses_defs import SimpleLinePart
 
 
 CURSES_STYLES = {
@@ -32,32 +40,36 @@
 
 
 class ColorSchema:
-    """Simple holder for the schema (theme)"""
+    """A storage mechanism for the schema (theme)."""
 
     # pylint: disable=too-few-public-methods
 
-    def __init__(self, schema):
-        """start
+    def __init__(self, schema: Dict[str, Union[str, List, Dict]]):
+        """Initialize the ColorSchema class.
 
         :param schema: The color scheme, theme to use
-        :type schema: dict
         """
         self._schema = schema
 
     @functools.lru_cache(maxsize=None)
-    def get_color(self, scope):
-        """Get a color from the schema, from most specific to least
+    def get_color(self, scope: str) -> Optional[RgbTuple]:
+        """Get a color from the schema, from most specific to least.
 
         :param scope: The scope, aka format
-        :type scope: str
-        :return: the color in RGB format or None
-        :rtype: tuple or None
+        :return: The color in RGB format or nothing
         """
         for name in reversed(scope):
             for parts in range(0, len(name.split("."))):
                 prop = name.split()[-1].rsplit(".", parts)[0]
                 color = next(
-                    (tc for tc in self._schema["tokenColors"] if prop in to_list(tc["scope"])),
+                    (
+                        token_color
+                        for token_color in self._schema["tokenColors"]
+                        if (
+                            isinstance(token_color, dict)
+                            and prop in scope_to_list(token_color.get("scope", []))
+                        )
+                    ),
                     None,
                 )
                 if color:
@@ -67,9 +79,8 @@ def get_color(self, scope):
 
 
 class Colorize:
-    """Functionality for coloring"""
+    """Functionality for coloring."""
 
-    # pylint: disable=too-few-public-methods
     def __init__(self, grammar_dir: str, theme_path: str):
         """Initialize the colorizer.
 
@@ -77,28 +88,35 @@ def __init__(self, grammar_dir: str, theme_path: str):
         :param theme_path: The path to the currently configured color theme
         """
         self._logger = logging.getLogger(__name__)
-        self._schema = None
+        self._schema: ColorSchema
         self._grammars = Grammars(grammar_dir)
         self._theme_path = theme_path
         self._load()
 
     def _load(self):
+        """Load the color scheme from the file system."""
         with open(os.path.join(self._theme_path), encoding="utf-8") as fh:
             self._schema = ColorSchema(json.load(fh))
 
+    @staticmethod
     @functools.lru_cache(maxsize=100)
-    def render(self, doc, scope):
-        """render some text into columns and colors
+    def render_ansi(doc: str) -> CursesLines:
+        """Convert ansi colored text into curses lines.
+
+        :param doc: The text to convert
+        :return: Lines ready to present using the TUI
+        """
+        lines = tuple(ansi_to_curses(line) for line in doc.splitlines())
+        return lines
 
-        :param doc: The thing to tokenize and color
-        :type doc: str
+    @functools.lru_cache(maxsize=100)
+    def render(self, doc: str, scope: str) -> List[List[SimpleLinePart]]:
+        """Render text lines into lines of columns and colors.
+
+        :param doc: The string to split, tokenize and color
         :param scope: The scope, aka the format of the string
-        :type scope: str
         :return: A list of lines, each a list of dicts
-        :rtype: list
         """
-        if scope == "source.ansi":
-            return [ansi_to_curses(l) for l in doc.splitlines()]  # noqa: E741
         try:
             compiler = self._grammars.compiler_for_scope(scope)
         except KeyError:
@@ -131,66 +149,71 @@ def render(self, doc, scope):
             else:
                 return columns_and_colors(lines, self._schema)
 
-        res = [[{"column": 0, "chars": doc_line, "color": None}] for doc_line in doc.splitlines()]
+        res = [
+            [SimpleLinePart(column=0, chars=doc_line, color=None)] for doc_line in doc.splitlines()
+        ]
         return res
 
 
-def to_list(thing):
-    """convert something to a list if necessary
+def scope_to_list(scope: Union[str, List]) -> List:
+    """Convert a token scope to a list if necessary.
+
+    A scope in a theme should always be a string or list,
+    but just in case return an empty list if not
 
-    :param thing: Maybe a list?
-    :type thing: str or list
-    :return: thing as list
-    :rtype: list
+    :param scope: The scope
+    :return: Scope as list
     """
-    if not isinstance(thing, list):
-        return [thing]
-    return thing
+    if isinstance(scope, list):
+        return scope
+    if isinstance(scope, str):
+        return [scope]
+    return []
 
 
-def hex_to_rgb(value):
-    """Convert a hex value to RGB
+def hex_to_rgb(value: str) -> RgbTuple:
+    """Convert a hex value to RGB tuple.
 
-    :param value: the hex color
-    :type value: str
+    :param value: The hex color
     :returns: RGB tuple
-    :rtype: tuple
     """
-    if value:
-        value = value.lstrip("#")
-        value_length = len(value)
-        return tuple(
-            int(value[i : i + value_length // 3], 16)
-            for i in range(0, value_length, value_length // 3)
-        )
-    return None
+    value = value.lstrip("#")
+    value_length = len(value)
+    red, green, blue = (
+        int(value[i : i + value_length // 3], 16) for i in range(0, value_length, value_length // 3)
+    )
+    return red, green, blue
 
 
-def hex_to_rgb_curses(value):
-    """Convert a hex color to RGB scaled to 1000
-    b/c that's what curses needs
+def scale_for_curses(rgb_value: int) -> int:
+    """Scale a single RGB value for curses.
+
+    :param rgb_value: One RGB value
+    :returns: The value scaled for curses
+    """
+    curses_ceiling = 1000
+    rgb_ceiling = 255
+    return int(rgb_value * curses_ceiling / rgb_ceiling)
+
+
+def hex_to_rgb_curses(value: str) -> RgbTuple:
+    """Convert a hex color to RGB scaled for curses.
 
     :param value: an RGB color
-    :type value: tuple
     :return: The colors scaled to 1000
-    :rtype: tuple
     """
-    scale = lambda x: int(x * 1000 / 255)  # noqa: E731
     red, green, blue = hex_to_rgb(value)
-    return (scale(red), scale(green), scale(blue))
+    return (scale_for_curses(red), scale_for_curses(green), scale_for_curses(blue))
 
 
 def rgb_to_ansi(red: int, green: int, blue: int, colors: int) -> int:
-    """Convert an RGB color to an terminal color
-
-    :param red: the red component
-    :type red: int
-    :param green: the green component
-    :type green: int
-    :param blue: the blue component
-    :type blue: int
+    """Convert an RGB color to an ansi color.
+
+    :param red: The red component
+    :param green: The green component
+    :param blue: The blue component
     :param colors: The number of color supported by the terminal
-    :type colors: int
+    :returns: A color suitable for the terminal
     """
     # https://github.com/Qix-/color-convert/blob/master/conversions.js
     if colors == 256:
@@ -221,53 +244,59 @@ def rgb_to_ansi(red: int, green: int, blue: int, colors: int) -> int:
     return ansi
 
 
-def columns_and_colors(lines, schema):
-    """Convert to colors and columns
+def columns_and_colors(
+    lines: List[Tuple[Regions, str]],
+    schema: ColorSchema,
+) -> List[List[SimpleLinePart]]:
+    """Convert to colors and columns.
 
-    :param lines: A list of regions (line parts) and the line, \
-                  each a ([regions], line)
-    :type lines: List[Tuple[List[str], str]]
+    :param lines: Lines of text and their regions
     :param schema: An instance of the ColorSchema
-    :type schema: ColorSchema
+    :returns: Lines of text, each broken into sections
     """
-    result = []
+    results: List[List[SimpleLinePart]] = []
 
     for line in lines:
-        column = 0
-        char_dicts = [{"chars": c, "color": None} for c in line[1]]
+        # Break the into 1 character parts, temporarily set the column to 0
+        line_parts = [
+            SimpleLinePart(chars=character, color=None, column=0) for character in line[1]
+        ]
 
+        # Replace the color with the RgbTuple
         for region in line[0]:
             color = schema.get_color(region.scope)
             if color:
                 for idx in range(region.start, region.end):
-                    char_dicts[idx]["color"] = color
-
-        if char_dicts:
-            grouped = [char_dicts.pop(0)]
-            while char_dicts:
-                entry = char_dicts.pop(0)
-                if entry["color"] == grouped[-1]["color"]:
-                    grouped[-1]["chars"] += entry["chars"]
+                    line_parts[idx].color = color
+
+        # Compress the line, grouping characters of like color
+        if line_parts:
+            grouped = [line_parts.pop(0)]
+            while line_parts:
+                entry = line_parts.pop(0)
+                if entry.color == grouped[-1].color:
+                    grouped[-1].chars += entry.chars
                 else:
                     grouped.append(entry)
-            result.append(grouped)
+            results.append(grouped)
         else:
-            result.append([{"chars": line[1], "color": None}])
+            results.append([SimpleLinePart(chars=line[1], color=None, column=0)])
 
-    for line in result:
+    # Update the column in each line part, based on the total of the preceding text lengths
+    for result in results:
         column = 0
-        for chunk in line:
-            chunk["column"] = column
-            column += len(chunk["chars"])
+        for line_part in result:
+            line_part.column = column
+            column += len(line_part.chars)
 
-    return result
+    return results
 
 
 def ansi_to_curses(line: str) -> CursesLine:
-    """Convert ansible color codes to curses colors
+    """Convert ansible color codes to curses colors.
 
     :param line: A string with ansi colors
-    :return: A list of str tuples [(x, s, c), (x, s, c)...]
+    :return: A line ready for presentation in the TUI
     """
     printable = []
     ansi_regex = re.compile(r"(\x1b\[[\d;]*m)")
diff --git a/src/ansible_navigator/ui_framework/curses_defs.py b/src/ansible_navigator/ui_framework/curses_defs.py
index 72e50a898..23bf39073 100644
--- a/src/ansible_navigator/ui_framework/curses_defs.py
+++ b/src/ansible_navigator/ui_framework/curses_defs.py
@@ -1,7 +1,9 @@
 """commonly used definitions
 """
 
+from dataclasses import dataclass
 from typing import NamedTuple
+from typing import Optional
 from typing import Tuple
 
 
@@ -22,3 +24,18 @@ class CursesLinePart(NamedTuple):
 
 CursesLine = Tuple[CursesLinePart, ...]
 CursesLines = Tuple[CursesLine, ...]
+
+
+RgbTuple = Tuple[int, int, int]
+
+
+@dataclass
+class SimpleLinePart:
+    """Definition of one part of one line having a common color."""
+
+    #: One group of characters sharing the same color
+    chars: str
+    #: The column where the characters start, the sum of all previous characters in the line
+    column: int
+    #: The color for these characters
+    color: Optional[RgbTuple]
diff --git a/src/ansible_navigator/ui_framework/ui.py b/src/ansible_navigator/ui_framework/ui.py
index 1f0b7065e..6a7365233 100644
--- a/src/ansible_navigator/ui_framework/ui.py
+++ b/src/ansible_navigator/ui_framework/ui.py
@@ -28,6 +28,8 @@
 from .curses_defs import CursesLine
 from .curses_defs import CursesLinePart
 from .curses_defs import CursesLines
+from .curses_defs import RgbTuple
+from .curses_defs import SimpleLinePart
 from .curses_window import CursesWindow
 from .curses_window import Window
 from .field_text import FieldText
@@ -133,7 +135,7 @@ def __init__(
         self._status_width = status_width
         self._prefix_color = 8
         self._refresh = [refresh]
-        self._rgb_to_curses_color_idx: Dict[str, int] = {}
+        self._rgb_to_curses_color_idx: Dict[RgbTuple, int] = {}
         self._screen_min_height = screen_min_height
         self._scroll = 0
         self._serialization_format = self._default_obj_serialization
@@ -498,7 +500,7 @@ def _serialize_color(self, obj: Any) -> CursesLines:
         """
 
         if self.serialization_format() == "source.ansi":
-            return self._colorizer.render(doc=obj, scope=self.serialization_format())
+            return self._colorizer.render_ansi(doc=obj)
         if self.serialization_format() == "source.yaml":
             string = human_dump(obj)
         elif self.serialization_format() == "source.json":
@@ -525,7 +527,7 @@ def _color_lines_for_term(self, lines: List) -> CursesLines:
         """
         if curses.COLORS > 16 and self._term_osc4_support:
             unique_colors = list(
-                set(chars["color"] for line in lines for chars in line if chars["color"]),
+                set(chars.color for line in lines for chars in line if chars.color),
             )
             # start custom colors at 16
             for color in unique_colors:
@@ -553,43 +555,39 @@ def _color_lines_for_term(self, lines: List) -> CursesLines:
         colored_lines = self._colored_lines(lines)
         return colored_lines
 
-    def _colored_lines(self, lines: List[List[Dict]]) -> CursesLines:
-        """color each of the lines
+    def _colored_lines(self, lines: List[List[SimpleLinePart]]) -> CursesLines:
+        """Color each of the lines.
 
-        :params lines: the lines to transform
-        :type lines: list of lists of dicts
-            Lines[LinePart[{"color": RGB, "chars": text, "column": n},...]]
-        :return: all the lines
+        :params lines: The lines to transform
+        :return: All lines colored
         """
         return tuple(self._colored_line(line) for line in lines)
 
-    def _colored_line(self, line: List[Dict]) -> CursesLine:
-        """color one line
+    def _colored_line(self, line: List[SimpleLinePart]) -> CursesLine:
+        """Color one line.
 
-        :param line: the line to transform (e.g. ``LinePart[{"color": \
-                     rgb, "chars": text, "column": n},...]``)
-        :return one colored line:
+        :param line: The line to color
+        :returns: One line colored
         """
         return tuple(self._colored_line_part(line_part) for line_part in line)
 
-    def _colored_line_part(self, lp_dict: Dict) -> CursesLinePart:
-        """color one linepart
+    def _colored_line_part(self, line_part: SimpleLinePart) -> CursesLinePart:
+        """Color one line part.
 
-        :param lp_dict: a dict describing the line part (e.g. \
-                        ``{"color": rgb, "chars": text, "column": n}``)
-        :return: the colored line part
+        :param line_part: One line part
+        :returns: One line part colored
         """
-        if lp_dict["color"]:
+        if line_part.color:
             if self._term_osc4_support and curses.COLORS > 16:
-                color = self._rgb_to_curses_color_idx[lp_dict["color"]]
+                color = self._rgb_to_curses_color_idx[line_part.color]
             else:
-                red, green, blue = lp_dict["color"]
+                red, green, blue = line_part.color
                 color = rgb_to_ansi(red, green, blue, curses.COLORS)
         else:
             color = 0
         return CursesLinePart(
-            column=lp_dict["column"],
-            string=lp_dict["chars"],
+            column=line_part.column,
+            string=line_part.chars,
             color=color,
             decoration=0,
         )


Test patch:
diff --git a/tests/unit/ui_framework/test_colorize.py b/tests/unit/ui_framework/test_colorize.py
index e9ab325d4..a93744e32 100644
--- a/tests/unit/ui_framework/test_colorize.py
+++ b/tests/unit/ui_framework/test_colorize.py
@@ -26,7 +26,7 @@ def test_basic_success_json():
     )
     assert len(result) == 1
     assert len(result[0]) == 5
-    assert "".join(line_part["chars"] for line_part in result[0]) == sample
+    assert "".join(line_part.chars for line_part in result[0]) == sample
 
 
 def test_basic_success_yaml():
@@ -41,9 +41,9 @@ def test_basic_success_yaml():
     )
     assert len(result) == 2
     assert len(result[0]) == 1
-    assert result[0][0]["chars"] == sample.splitlines()[0]
+    assert result[0][0].chars == sample.splitlines()[0]
     assert len(result[1]) == 3
-    assert "".join(line_part["chars"] for line_part in result[1]) == sample.splitlines()[1]
+    assert "".join(line_part.chars for line_part in result[1]) == sample.splitlines()[1]
 
 
 def test_basic_success_no_color():
@@ -55,9 +55,9 @@ def test_basic_success_no_color():
     )
     assert len(result) == 1
     assert len(result[0]) == 1
-    assert result[0][0]["chars"] == sample
-    assert result[0][0]["color"] is None
-    assert result[0][0]["column"] == 0
+    assert result[0][0].chars == sample
+    assert result[0][0].color is None
+    assert result[0][0].column == 0
 
 
 @patch("ansible_navigator.ui_framework.colorize.tokenize")
@@ -73,7 +73,7 @@ def test_graceful_failure(mocked_func, caplog):
     )
     assert len(result) == 1
     assert len(result[0]) == 1
-    assert result[0][0]["chars"] == sample
-    assert result[0][0]["color"] is None
-    assert result[0][0]["column"] == 0
+    assert result[0][0].chars == sample
+    assert result[0][0].color is None
+    assert result[0][0].column == 0
     assert "rendered without color" in caplog.text