diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py index 561f3e026..dc6cf9ff5 100644 --- a/src/textual/css/_help_text.py +++ b/src/textual/css/_help_text.py @@ -149,7 +149,7 @@ def property_invalid_value_help_text( suggested_property_name = _contextualize_property_name( suggested_property_name, context ) - bullets.append(Bullet(f"Did you mean [i]{suggested_property_name}[/]?")) + bullets.append(Bullet(f'Did you mean "{suggested_property_name}"?')) return HelpText(f"Invalid CSS property [i]{property_name}[/]", bullets=bullets) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index e981edd4b..4ccf04ef6 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -1,8 +1,7 @@ from __future__ import annotations -from difflib import get_close_matches from functools import lru_cache -from typing import cast, Iterable, NoReturn +from typing import cast, Iterable, NoReturn, Sequence import rich.repr @@ -47,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: @@ -87,7 +87,7 @@ class StylesBuilder: process_method = getattr(self, f"process_{rule_name}", None) if process_method is None: - suggested_property_name = self._suggested_property_name_for_rule( + suggested_property_name = self._get_suggested_property_name_for_rule( declaration.name ) self.error( @@ -115,12 +115,14 @@ class StylesBuilder: self.error(declaration.name, declaration.token, str(error)) @lru_cache(maxsize=None) - def _processable_rule_names(self) -> frozenset[str]: - return frozenset( - [attr[8:] for attr in dir(self) if attr.startswith("process_")] - ) + 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 + """ + return [attr[8:] for attr in dir(self) if attr.startswith("process_")] - def _process_enum_multiple( + 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""" @@ -745,8 +747,10 @@ class StylesBuilder: process_content_align_horizontal = process_align_horizontal process_content_align_vertical = process_align_vertical - def _suggested_property_name_for_rule(self, rule_name: str) -> str | None: - possible_matches = get_close_matches( - rule_name, self._processable_rule_names(), n=1 - ) - return None if not possible_matches else possible_matches[0] + 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. + + 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..ffe9af2df --- /dev/null +++ b/src/textual/suggestions.py @@ -0,0 +1,24 @@ +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', or 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' - + or 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) diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index fe9e36456..202935a3d 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -64,6 +64,7 @@ def test_color_property_parsing(css_value, expectation, expected_color): ["backgroundu", "background"], ["bckgroundu", "background"], ["ofset-x", "offset-x"], + ["ofst_y", "offset-y"], ["colr", "color"], ["colour", "color"], ["wdth", "width"], @@ -73,7 +74,7 @@ def test_color_property_parsing(css_value, expectation, expected_color): ], ) def test_did_you_mean_for_css_property_names( - css_property_name, expected_property_name_suggestion + css_property_name: str, expected_property_name_suggestion ): stylesheet = Stylesheet() css = """ @@ -90,12 +91,15 @@ def test_did_you_mean_for_css_property_names( stylesheet.parse() _, help_text = err.value.errors.rules[0].errors[0] # type: Any, HelpText - assert help_text.summary == f"Invalid CSS property [i]{css_property_name}[/]" + 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 [i]{expected_property_name_suggestion}[/]?" + 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