Repository: Textualize/textual

Issue:
Did you Mean?
I'm a big fan of when software detects typos and offers suggestions.

We should have a function in the project which uses [Levenshtein Word distance](https://en.wikipedia.org/wiki/Levenshtein_distance) to offer suggestions when a symbol is not found.

The first application of this should probably be CSS declarations. i.e. if I use the UK spelling of "colour" it the error would contain a message to the effect of `Did you mean "color" ?`

However I would imagine there are multiple uses for this.

IIRC there is a routine in the standard library that already does this. Might want to google for that.

Discussion:

Patch:
diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py
index 6dac2bee2..dc6cf9ff5 100644
--- a/src/textual/css/_help_text.py
+++ b/src/textual/css/_help_text.py
@@ -128,7 +128,32 @@ def _spacing_examples(property_name: str) -> ContextSpecificBullets:
     )
 
 
-def spacing_wrong_number_of_values(
+def property_invalid_value_help_text(
+    property_name: str, context: StylingContext, *, suggested_property_name: str = None
+) -> HelpText:
+    """Help text to show when the user supplies an invalid value for CSS property
+    property.
+
+    Args:
+        property_name (str): The name of the property
+        context (StylingContext | None): The context the spacing property is being used in.
+    Keyword Args:
+        suggested_property_name (str | None): A suggested name for the property (e.g. "width" for "wdth"). Defaults to None.
+
+    Returns:
+        HelpText: Renderable for displaying the help text for this property
+    """
+    property_name = _contextualize_property_name(property_name, context)
+    bullets = []
+    if suggested_property_name:
+        suggested_property_name = _contextualize_property_name(
+            suggested_property_name, context
+        )
+        bullets.append(Bullet(f'Did you mean "{suggested_property_name}"?'))
+    return HelpText(f"Invalid CSS property [i]{property_name}[/]", bullets=bullets)
+
+
+def spacing_wrong_number_of_values_help_text(
     property_name: str,
     num_values_supplied: int,
     context: StylingContext,
@@ -159,7 +184,7 @@ def spacing_wrong_number_of_values(
     )
 
 
-def spacing_invalid_value(
+def spacing_invalid_value_help_text(
     property_name: str,
     context: StylingContext,
 ) -> HelpText:
diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py
index 01b64b001..ecafefa86 100644
--- a/src/textual/css/_style_properties.py
+++ b/src/textual/css/_style_properties.py
@@ -22,7 +22,7 @@ from ._help_text import (
     style_flags_property_help_text,
 )
 from ._help_text import (
-    spacing_wrong_number_of_values,
+    spacing_wrong_number_of_values_help_text,
     scalar_help_text,
     string_enum_help_text,
     color_property_help_text,
@@ -415,7 +415,7 @@ class SpacingProperty:
             except ValueError as error:
                 raise StyleValueError(
                     str(error),
-                    help_text=spacing_wrong_number_of_values(
+                    help_text=spacing_wrong_number_of_values_help_text(
                         property_name=self.name,
                         num_values_supplied=len(spacing),
                         context="inline",
diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py
index 54b1a2d46..2c72e13d3 100644
--- a/src/textual/css/_styles_builder.py
+++ b/src/textual/css/_styles_builder.py
@@ -1,14 +1,15 @@
 from __future__ import annotations
 
-from typing import cast, Iterable, NoReturn
+from functools import lru_cache
+from typing import cast, Iterable, NoReturn, Sequence
 
 import rich.repr
 
 from ._error_tools import friendly_list
 from ._help_renderables import HelpText
 from ._help_text import (
-    spacing_invalid_value,
-    spacing_wrong_number_of_values,
+    spacing_invalid_value_help_text,
+    spacing_wrong_number_of_values_help_text,
     scalar_help_text,
     color_property_help_text,
     string_enum_help_text,
@@ -21,6 +22,7 @@ from ._help_text import (
     offset_property_help_text,
     offset_single_axis_help_text,
     style_flags_property_help_text,
+    property_invalid_value_help_text,
 )
 from .constants import (
     VALID_ALIGN_HORIZONTAL,
@@ -44,6 +46,7 @@ from ..color import Color, ColorParseError
 from .._duration import _duration_as_seconds
 from .._easing import EASING
 from ..geometry import Spacing, SpacingDimensions, clamp
+from ..suggestions import get_suggestion
 
 
 def _join_tokens(tokens: Iterable[Token], joiner: str = "") -> str:
@@ -84,26 +87,47 @@ class StylesBuilder:
         process_method = getattr(self, f"process_{rule_name}", None)
 
         if process_method is None:
+            suggested_property_name = self._get_suggested_property_name_for_rule(
+                declaration.name
+            )
             self.error(
                 declaration.name,
                 declaration.token,
-                f"unknown declaration {declaration.name!r}",
+                property_invalid_value_help_text(
+                    declaration.name,
+                    "css",
+                    suggested_property_name=suggested_property_name,
+                ),
             )
-        else:
-            tokens = declaration.tokens
+            return
 
-            important = tokens[-1].name == "important"
-            if important:
-                tokens = tokens[:-1]
-                self.styles.important.add(rule_name)
-            try:
-                process_method(declaration.name, tokens)
-            except DeclarationError:
-                raise
-            except Exception as error:
-                self.error(declaration.name, declaration.token, str(error))
+        tokens = declaration.tokens
+
+        important = tokens[-1].name == "important"
+        if important:
+            tokens = tokens[:-1]
+            self.styles.important.add(rule_name)
+        try:
+            process_method(declaration.name, tokens)
+        except DeclarationError:
+            raise
+        except Exception as error:
+            self.error(declaration.name, declaration.token, str(error))
+
+    @lru_cache(maxsize=None)
+    def _get_processable_rule_names(self) -> Sequence[str]:
+        """
+        Returns the list of CSS properties we can manage -
+        i.e. the ones for which we have a `process_[property name]` method
 
-    def _process_enum_multiple(
+        Returns:
+            Sequence[str]: All the "Python-ised" CSS property names this class can handle.
+
+        Example: ("width", "background", "offset_x", ...)
+        """
+        return [attr[8:] for attr in dir(self) if attr.startswith("process_")]
+
+    def _get_process_enum_multiple(
         self, name: str, tokens: list[Token], valid_values: set[str], count: int
     ) -> tuple[str, ...]:
         """Generic code to process a declaration with two enumerations, like overflow: auto auto"""
@@ -332,14 +356,20 @@ class StylesBuilder:
                 try:
                     append(int(value))
                 except ValueError:
-                    self.error(name, token, spacing_invalid_value(name, context="css"))
+                    self.error(
+                        name,
+                        token,
+                        spacing_invalid_value_help_text(name, context="css"),
+                    )
             else:
-                self.error(name, token, spacing_invalid_value(name, context="css"))
+                self.error(
+                    name, token, spacing_invalid_value_help_text(name, context="css")
+                )
         if len(space) not in (1, 2, 4):
             self.error(
                 name,
                 tokens[0],
-                spacing_wrong_number_of_values(
+                spacing_wrong_number_of_values_help_text(
                     name, num_values_supplied=len(space), context="css"
                 ),
             )
@@ -348,7 +378,9 @@ class StylesBuilder:
     def _process_space_partial(self, name: str, tokens: list[Token]) -> None:
         """Process granular margin / padding declarations."""
         if len(tokens) != 1:
-            self.error(name, tokens[0], spacing_invalid_value(name, context="css"))
+            self.error(
+                name, tokens[0], spacing_invalid_value_help_text(name, context="css")
+            )
 
         _EDGE_SPACING_MAP = {"top": 0, "right": 1, "bottom": 2, "left": 3}
         token = tokens[0]
@@ -356,7 +388,9 @@ class StylesBuilder:
         if token_name == "number":
             space = int(value)
         else:
-            self.error(name, token, spacing_invalid_value(name, context="css"))
+            self.error(
+                name, token, spacing_invalid_value_help_text(name, context="css")
+            )
         style_name, _, edge = name.replace("-", "_").partition("_")
 
         current_spacing = cast(
@@ -717,3 +751,18 @@ class StylesBuilder:
     process_content_align = process_align
     process_content_align_horizontal = process_align_horizontal
     process_content_align_vertical = process_align_vertical
+
+    def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None:
+        """
+        Returns a valid CSS property "Python" name, or None if no close matches could be found.
+
+        Args:
+            rule_name (str): An invalid "Python-ised" CSS property (i.e. "offst_x" rather than "offst-x")
+
+        Returns:
+            str | None: The closest valid "Python-ised" CSS property.
+                Returns `None` if no close matches could be found.
+
+        Example: returns "background" for rule_name "bkgrund", "offset_x" for "ofset_x"
+        """
+        return get_suggestion(rule_name, self._get_processable_rule_names())
diff --git a/src/textual/suggestions.py b/src/textual/suggestions.py
new file mode 100644
index 000000000..e3fb20db2
--- /dev/null
+++ b/src/textual/suggestions.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from difflib import get_close_matches
+from typing import Sequence
+
+
+def get_suggestion(word: str, possible_words: Sequence[str]) -> str | None:
+    """
+    Returns a close match of `word` amongst `possible_words`.
+
+    Args:
+        word (str): The word we want to find a close match for
+        possible_words (Sequence[str]): The words amongst which we want to find a close match
+
+    Returns:
+        str | None: The closest match amongst the `possible_words`. Returns `None` if no close matches could be found.
+
+    Example: returns "red" for word "redu" and possible words ("yellow", "red")
+    """
+    possible_matches = get_close_matches(word, possible_words, n=1)
+    return None if not possible_matches else possible_matches[0]
+
+
+def get_suggestions(word: str, possible_words: Sequence[str], count: int) -> list[str]:
+    """
+    Returns a list of up to `count` matches of `word` amongst `possible_words`.
+
+    Args:
+        word (str): The word we want to find a close match for
+        possible_words (Sequence[str]): The words amongst which we want to find close matches
+
+    Returns:
+        list[str]: The closest matches amongst the `possible_words`, from the closest to the least close.
+            Returns an empty list if no close matches could be found.
+
+    Example: returns ["yellow", "ellow"] for word "yllow" and possible words ("yellow", "red", "ellow")
+    """
+    return get_close_matches(word, possible_words, n=count)


Test patch:
diff --git a/tests/css/test_help_text.py b/tests/css/test_help_text.py
index 5c34c3974..f5818a11d 100644
--- a/tests/css/test_help_text.py
+++ b/tests/css/test_help_text.py
@@ -1,10 +1,22 @@
 import pytest
 
 from tests.utilities.render import render
-from textual.css._help_text import spacing_wrong_number_of_values, spacing_invalid_value, scalar_help_text, \
-    string_enum_help_text, color_property_help_text, border_property_help_text, layout_property_help_text, \
-    docks_property_help_text, dock_property_help_text, fractional_property_help_text, offset_property_help_text, \
-    align_help_text, offset_single_axis_help_text, style_flags_property_help_text
+from textual.css._help_text import (
+    spacing_wrong_number_of_values_help_text,
+    spacing_invalid_value_help_text,
+    scalar_help_text,
+    string_enum_help_text,
+    color_property_help_text,
+    border_property_help_text,
+    layout_property_help_text,
+    docks_property_help_text,
+    dock_property_help_text,
+    fractional_property_help_text,
+    offset_property_help_text,
+    align_help_text,
+    offset_single_axis_help_text,
+    style_flags_property_help_text,
+)
 
 
 @pytest.fixture(params=["css", "inline"])
@@ -15,22 +27,24 @@ def styling_context(request):
 def test_help_text_examples_are_contextualized():
     """Ensure that if the user is using CSS, they see CSS-specific examples
     and if they're using inline styles they see inline-specific examples."""
-    rendered_inline = render(spacing_invalid_value("padding", "inline"))
+    rendered_inline = render(spacing_invalid_value_help_text("padding", "inline"))
     assert "widget.styles.padding" in rendered_inline
 
-    rendered_css = render(spacing_invalid_value("padding", "css"))
+    rendered_css = render(spacing_invalid_value_help_text("padding", "css"))
     assert "padding:" in rendered_css
 
 
 def test_spacing_wrong_number_of_values(styling_context):
-    rendered = render(spacing_wrong_number_of_values("margin", 3, styling_context))
+    rendered = render(
+        spacing_wrong_number_of_values_help_text("margin", 3, styling_context)
+    )
     assert "Invalid number of values" in rendered
     assert "margin" in rendered
     assert "3" in rendered
 
 
 def test_spacing_invalid_value(styling_context):
-    rendered = render(spacing_invalid_value("padding", styling_context))
+    rendered = render(spacing_invalid_value_help_text("padding", styling_context))
     assert "Invalid value for" in rendered
     assert "padding" in rendered
 
@@ -47,7 +61,9 @@ def test_scalar_help_text(styling_context):
 
 
 def test_string_enum_help_text(styling_context):
-    rendered = render(string_enum_help_text("display", ["none", "hidden"], styling_context))
+    rendered = render(
+        string_enum_help_text("display", ["none", "hidden"], styling_context)
+    )
     assert "Invalid value for" in rendered
 
     # Ensure property name is mentioned
@@ -113,7 +129,9 @@ def test_offset_single_axis_help_text():
 
 
 def test_style_flags_property_help_text(styling_context):
-    rendered = render(style_flags_property_help_text("text-style", "notavalue b", styling_context))
+    rendered = render(
+        style_flags_property_help_text("text-style", "notavalue b", styling_context)
+    )
     assert "Invalid value" in rendered
     assert "notavalue" in rendered
 
diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py
index 4544d1e1d..202935a3d 100644
--- a/tests/css/test_stylesheet.py
+++ b/tests/css/test_stylesheet.py
@@ -1,7 +1,10 @@
 from contextlib import nullcontext as does_not_raise
+from typing import Any
+
 import pytest
 
 from textual.color import Color
+from textual.css._help_renderables import HelpText
 from textual.css.stylesheet import Stylesheet, StylesheetParseError
 from textual.css.tokenizer import TokenizeError
 
@@ -53,3 +56,50 @@ def test_color_property_parsing(css_value, expectation, expected_color):
     if expected_color:
         css_rule = stylesheet.rules[0]
         assert css_rule.styles.background == expected_color
+
+
+@pytest.mark.parametrize(
+    "css_property_name,expected_property_name_suggestion",
+    [
+        ["backgroundu", "background"],
+        ["bckgroundu", "background"],
+        ["ofset-x", "offset-x"],
+        ["ofst_y", "offset-y"],
+        ["colr", "color"],
+        ["colour", "color"],
+        ["wdth", "width"],
+        ["wth", "width"],
+        ["wh", None],
+        ["xkcd", None],
+    ],
+)
+def test_did_you_mean_for_css_property_names(
+    css_property_name: str, expected_property_name_suggestion
+):
+    stylesheet = Stylesheet()
+    css = """
+    * {
+      border: blue;
+      ${PROPERTY}: red;
+    }
+    """.replace(
+        "${PROPERTY}", css_property_name
+    )
+
+    stylesheet.add_source(css)
+    with pytest.raises(StylesheetParseError) as err:
+        stylesheet.parse()
+
+    _, help_text = err.value.errors.rules[0].errors[0]  # type: Any, HelpText
+    displayed_css_property_name = css_property_name.replace("_", "-")
+    assert (
+        help_text.summary == f"Invalid CSS property [i]{displayed_css_property_name}[/]"
+    )
+
+    expected_bullets_length = 1 if expected_property_name_suggestion else 0
+    assert len(help_text.bullets) == expected_bullets_length
+    if expected_property_name_suggestion is not None:
+        expected_suggestion_message = (
+            f'Did you mean "{expected_property_name_suggestion}"?'
+        )
+        assert help_text.bullets[0].markup == expected_suggestion_message
diff --git a/tests/test_suggestions.py b/tests/test_suggestions.py
new file mode 100644
index 000000000..8faedcbaf
--- /dev/null
+++ b/tests/test_suggestions.py
@@ -0,0 +1,35 @@
+import pytest
+
+from textual.suggestions import get_suggestion, get_suggestions
+
+
+@pytest.mark.parametrize(
+    "word, possible_words, expected_result",
+    (
+        ["background", ("background",), "background"],
+        ["backgroundu", ("background",), "background"],
+        ["bkgrund", ("background",), "background"],
+        ["llow", ("background",), None],
+        ["llow", ("background", "yellow"), "yellow"],
+        ["yllow", ("background", "yellow", "ellow"), "yellow"],
+    ),
+)
+def test_get_suggestion(word, possible_words, expected_result):
+    assert get_suggestion(word, possible_words) == expected_result
+
+
+@pytest.mark.parametrize(
+    "word, possible_words, count, expected_result",
+    (
+        ["background", ("background",), 1, ["background"]],
+        ["backgroundu", ("background",), 1, ["background"]],
+        ["bkgrund", ("background",), 1, ["background"]],
+        ["llow", ("background",), 1, []],
+        ["llow", ("background", "yellow"), 1, ["yellow"]],
+        ["yllow", ("background", "yellow", "ellow"), 1, ["yellow"]],
+        ["yllow", ("background", "yellow", "ellow"), 2, ["yellow", "ellow"]],
+        ["yllow", ("background", "yellow", "red"), 2, ["yellow"]],
+    ),
+)
+def test_get_suggestions(word, possible_words, count, expected_result):
+    assert get_suggestions(word, possible_words, count) == expected_result