From 3105b72ac2c9790792d339bc27312fc015a6bc6d Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Wed, 11 Mar 2026 22:39:52 +0100 Subject: [PATCH] Improved auto-completion engine for formatting parameters and added support for absolute value in number formatting. --- .idea/MyFastHtml.iml | 5 + docs/DataGrid Formatting - User Guide.md | 14 + src/myfasthtml/core/dsl/utils.py | 2 +- src/myfasthtml/core/formatting/dataclasses.py | 1 + .../completion/FormattingCompletionEngine.py | 76 +++++- .../formatting/dsl/completion/contexts.py | 61 +++-- .../core/formatting/dsl/completion/presets.py | 39 ++- .../core/formatting/dsl/transformer.py | 2 +- .../core/formatting/formatter_resolver.py | 9 +- tests/core/formatting/dsl/test_completion.py | 244 +++++++++++++++++- .../formatting/test_formatter_resolver.py | 24 ++ 11 files changed, 438 insertions(+), 39 deletions(-) diff --git a/.idea/MyFastHtml.iml b/.idea/MyFastHtml.iml index 86d2b9e..be4c50c 100644 --- a/.idea/MyFastHtml.iml +++ b/.idea/MyFastHtml.iml @@ -4,6 +4,11 @@ + + + + + diff --git a/docs/DataGrid Formatting - User Guide.md b/docs/DataGrid Formatting - User Guide.md index bda464d..2f2d416 100644 --- a/docs/DataGrid Formatting - User Guide.md +++ b/docs/DataGrid Formatting - User Guide.md @@ -330,6 +330,7 @@ Formats numbers, currencies, and percentages. | `decimal_sep` | string | `"."` | Decimal separator (e.g., ".", ",") | | `precision` | int | `0` | Number of decimal places | | `multiplier` | number | `1` | Multiply value before display (e.g., 100 for %) | +| `absolute` | boolean | `False` | Display absolute value (no sign) | **Examples:** @@ -349,6 +350,10 @@ column rate: # Large numbers with thousands separator column population: format.number(thousands_sep=",", precision=0) + +# Absolute value - display variance without sign +column variance: + format.number(absolute=True, suffix=" €", precision=2) ``` #### Date Formatter @@ -1085,6 +1090,14 @@ DaisyUI 5 color presets: ### Formatter Types +Possible formatter types : +- `number` +- `date` +- `boolean` +- `text` +- `enum` +- `constant` + #### Number Formatter | Parameter | Type | Default | Description | @@ -1095,6 +1108,7 @@ DaisyUI 5 color presets: | `decimal_sep` | string | `"."` | Decimal separator | | `precision` | int | `0` | Decimal places | | `multiplier` | number | `1` | Multiply before display | +| `absolute` | boolean | `False` | Display absolute value (no sign) | #### Date Formatter diff --git a/src/myfasthtml/core/dsl/utils.py b/src/myfasthtml/core/dsl/utils.py index b3b9adb..735efcd 100644 --- a/src/myfasthtml/core/dsl/utils.py +++ b/src/myfasthtml/core/dsl/utils.py @@ -8,7 +8,7 @@ and other common operations used by completion engines. from .types import Position, WordRange # Delimiters used to detect word boundaries -DELIMITERS = set('"\' ()[]{}=,:<>!\t\n\r') +DELIMITERS = set('"\' ()[]{}=,:<>!.\t\n\r') def get_line_at(text: str, line_number: int) -> str: diff --git a/src/myfasthtml/core/formatting/dataclasses.py b/src/myfasthtml/core/formatting/dataclasses.py index 0982f49..438c151 100644 --- a/src/myfasthtml/core/formatting/dataclasses.py +++ b/src/myfasthtml/core/formatting/dataclasses.py @@ -79,6 +79,7 @@ class NumberFormatter(Formatter): decimal_sep: str = "." precision: int = 0 multiplier: float = 1.0 + absolute: bool = False @dataclass diff --git a/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py b/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py index ebfec55..e093fe9 100644 --- a/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py +++ b/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py @@ -4,7 +4,9 @@ Completion engine for the formatting DSL. Implements the BaseCompletionEngine for DataGrid formatting rules. """ import logging +import re +from myfasthtml.core.dsl import utils from myfasthtml.core.dsl.base_completion import BaseCompletionEngine from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult from myfasthtml.core.utils import make_safe_id @@ -12,6 +14,17 @@ from . import presets from .contexts import Context, DetectedScope, detect_scope, detect_context from .provider import DatagridMetadataProvider +_PARAM_CONTEXTS = { + Context.STYLE_ARGS, + Context.STYLE_PARAM, + Context.FORMAT_PARAM_NUMBER, + Context.FORMAT_PARAM_DATE, + Context.FORMAT_PARAM_BOOLEAN, + Context.FORMAT_PARAM_TEXT, + Context.FORMAT_PARAM_ENUM, + Context.FORMAT_PARAM_CONSTANT, +} + logger = logging.getLogger("FormattingCompletionEngine") class FormattingCompletionEngine(BaseCompletionEngine): @@ -37,6 +50,55 @@ class FormattingCompletionEngine(BaseCompletionEngine): self.table_name: str = table_name # current table name self._id = "formatting_completion_engine#" + make_safe_id(table_name) + def get_completions(self, text: str, cursor: Position) -> CompletionResult: + """ + Get completions with parameter deduplication. + + Extends the base implementation by filtering out parameters + that are already present in the current style() or format.*() call. + + Args: + text: The full DSL document text + cursor: Cursor position + + Returns: + CompletionResult with already-used parameters removed + """ + result = super().get_completions(text, cursor) + + scope = self.detect_scope(text, cursor.line) + context = self.detect_context(text, cursor, scope) + + if context in _PARAM_CONTEXTS: + line_to_cursor = utils.get_line_up_to_cursor(text, cursor) + used_params = self._extract_used_params(line_to_cursor) + if used_params: + result.suggestions = [ + s for s in result.suggestions + if s.label.rstrip("=") not in used_params + ] + + return result + + def _extract_used_params(self, line_to_cursor: str) -> set[str]: + """ + Extract parameter names already typed in the current function call. + + Scans backwards from the last opening parenthesis to find all + 'name=' patterns already present. + + Args: + line_to_cursor: The current line up to the cursor position + + Returns: + Set of parameter names already used (without the '=') + """ + paren_pos = line_to_cursor.rfind("(") + if paren_pos == -1: + return set() + args_text = line_to_cursor[paren_pos + 1:] + return set(re.findall(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\s*=", args_text)) + def detect_scope(self, text: str, current_line: int) -> DetectedScope: """ Detect the current scope by scanning previous lines. @@ -146,11 +208,23 @@ class FormattingCompletionEngine(BaseCompletionEngine): case Context.FORMAT_TYPE: return presets.FORMAT_TYPES + case Context.FORMAT_PARAM_NUMBER: + return presets.FORMAT_PARAMS_NUMBER + case Context.FORMAT_PARAM_DATE: return presets.FORMAT_PARAMS_DATE - + + case Context.FORMAT_PARAM_BOOLEAN: + return presets.FORMAT_PARAMS_BOOLEAN + case Context.FORMAT_PARAM_TEXT: return presets.FORMAT_PARAMS_TEXT + + case Context.FORMAT_PARAM_ENUM: + return presets.FORMAT_PARAMS_ENUM + + case Context.FORMAT_PARAM_CONSTANT: + return presets.FORMAT_PARAMS_CONSTANT # ================================================================= # After style/format diff --git a/src/myfasthtml/core/formatting/dsl/completion/contexts.py b/src/myfasthtml/core/formatting/dsl/completion/contexts.py index 558e590..36bc44f 100644 --- a/src/myfasthtml/core/formatting/dsl/completion/contexts.py +++ b/src/myfasthtml/core/formatting/dsl/completion/contexts.py @@ -45,8 +45,12 @@ class Context(Enum): # Format contexts FORMAT_PRESET = auto() # Inside format("): preset names FORMAT_TYPE = auto() # After "format.": number, date, etc. + FORMAT_PARAM_NUMBER = auto() # Inside format.number(): prefix=, suffix=, etc. FORMAT_PARAM_DATE = auto() # Inside format.date(): format= + FORMAT_PARAM_BOOLEAN = auto() # Inside format.boolean(): true_value=, false_value=, etc. FORMAT_PARAM_TEXT = auto() # Inside format.text(): transform=, etc. + FORMAT_PARAM_ENUM = auto() # Inside format.enum(): source=, default=, etc. + FORMAT_PARAM_CONSTANT = auto() # Inside format.constant(): value= # After style/format AFTER_STYLE_OR_FORMAT = auto() # After ")": style(, format(, if @@ -234,17 +238,20 @@ def detect_context(text: str, cursor: Position, scope: DetectedScope) -> Context if re.search(r'style\s*\(\s*"[^"]*$', line_to_cursor): return Context.STYLE_PRESET - # After style( without quote - args (preset or params) - if re.search(r"style\s*\(\s*$", line_to_cursor): + # After style( without quote - args (preset or params), including partial word + if re.search(r"style\s*\(\s*[a-zA-Z_]*$", line_to_cursor): return Context.STYLE_ARGS - - # After comma in style() - params - if re.search(r"style\s*\([^)]*,\s*$", line_to_cursor): + + # After comma in style() - params, including partial word + if re.search(r"style\s*\([^)]*,\s*[a-zA-Z_]*$", line_to_cursor): return Context.STYLE_PARAM - # After param= in style - check which param + # After boolean param= in style or format.number if re.search(r"style\s*\([^)]*(?:bold|italic|underline|strikethrough)\s*=\s*$", line_to_cursor): return Context.BOOLEAN_VALUE + + if re.search(r"format\s*\.\s*\w+\s*\([^)]*absolute\s*=\s*$", line_to_cursor): + return Context.BOOLEAN_VALUE if re.search(r"style\s*\([^)]*(?:color|background_color)\s*=\s*$", line_to_cursor): return Context.COLOR_VALUE @@ -253,29 +260,45 @@ def detect_context(text: str, cursor: Position, scope: DetectedScope) -> Context # Format contexts # ------------------------------------------------------------------------- - # After "format." - type - if re.search(r"format\s*\.\s*$", line_to_cursor): + # After "format." - type (cursor right after dot or while typing type name) + if re.search(r"format\s*\.\s*[a-zA-Z]*$", line_to_cursor): return Context.FORMAT_TYPE # Inside format(" - preset if re.search(r'format\s*\(\s*"[^"]*$', line_to_cursor): return Context.FORMAT_PRESET - # Inside format.date( - params - if re.search(r"format\s*\.\s*date\s*\(\s*$", line_to_cursor): - return Context.FORMAT_PARAM_DATE - - # After format= in format.date + # After format= in format.date (checked before FORMAT_PARAM_DATE) if re.search(r"format\s*\.\s*date\s*\([^)]*format\s*=\s*$", line_to_cursor): return Context.DATE_FORMAT_VALUE - - # Inside format.text( - params - if re.search(r"format\s*\.\s*text\s*\(\s*$", line_to_cursor): - return Context.FORMAT_PARAM_TEXT - - # After transform= in format.text + + # After transform= in format.text (checked before FORMAT_PARAM_TEXT) if re.search(r"format\s*\.\s*text\s*\([^)]*transform\s*=\s*$", line_to_cursor): return Context.TRANSFORM_VALUE + + # Inside format.number( - right after ( or after a comma, including partial word + if re.search(r"format\s*\.\s*number\s*\((?:[^)]*,)?\s*[a-zA-Z_]*$", line_to_cursor): + return Context.FORMAT_PARAM_NUMBER + + # Inside format.date( - right after ( or after a comma, including partial word + if re.search(r"format\s*\.\s*date\s*\((?:[^)]*,)?\s*[a-zA-Z_]*$", line_to_cursor): + return Context.FORMAT_PARAM_DATE + + # Inside format.boolean( - right after ( or after a comma, including partial word + if re.search(r"format\s*\.\s*boolean\s*\((?:[^)]*,)?\s*[a-zA-Z_]*$", line_to_cursor): + return Context.FORMAT_PARAM_BOOLEAN + + # Inside format.text( - right after ( or after a comma, including partial word + if re.search(r"format\s*\.\s*text\s*\((?:[^)]*,)?\s*[a-zA-Z_]*$", line_to_cursor): + return Context.FORMAT_PARAM_TEXT + + # Inside format.enum( - right after ( or after a comma, including partial word + if re.search(r"format\s*\.\s*enum\s*\((?:[^)]*,)?\s*[a-zA-Z_]*$", line_to_cursor): + return Context.FORMAT_PARAM_ENUM + + # Inside format.constant( - right after ( or after a comma, including partial word + if re.search(r"format\s*\.\s*constant\s*\((?:[^)]*,)?\s*[a-zA-Z_]*$", line_to_cursor): + return Context.FORMAT_PARAM_CONSTANT # ------------------------------------------------------------------------- # After style/format - if or more style/format diff --git a/src/myfasthtml/core/formatting/dsl/completion/presets.py b/src/myfasthtml/core/formatting/dsl/completion/presets.py index 0889e00..a8ac9ca 100644 --- a/src/myfasthtml/core/formatting/dsl/completion/presets.py +++ b/src/myfasthtml/core/formatting/dsl/completion/presets.py @@ -164,27 +164,56 @@ STYLE_PARAMS: list[Suggestion] = [ # ============================================================================= FORMAT_TYPES: list[Suggestion] = [ - Suggestion("number", "Number formatting", "type"), - Suggestion("date", "Date formatting", "type"), - Suggestion("boolean", "Boolean formatting", "type"), - Suggestion("text", "Text transformation", "type"), - Suggestion("enum", "Value mapping", "type"), + Suggestion("number(", "Number formatting", "type"), + Suggestion("date(", "Date formatting", "type"), + Suggestion("boolean(", "Boolean formatting", "type"), + Suggestion("text(", "Text transformation", "type"), + Suggestion("enum(", "Value mapping", "type"), + Suggestion("constant(", "Constant value", "type"), ] # ============================================================================= # Format Parameters by Type # ============================================================================= +FORMAT_PARAMS_NUMBER: list[Suggestion] = [ + Suggestion("prefix=", "Text before the value", "parameter"), + Suggestion("suffix=", "Text after the value", "parameter"), + Suggestion("precision=", "Number of decimal places", "parameter"), + Suggestion("thousands_sep=", "Thousands separator", "parameter"), + Suggestion("decimal_sep=", "Decimal separator", "parameter"), + Suggestion("multiplier=", "Multiply value before display", "parameter"), + Suggestion("absolute=", "Display absolute value (no sign)", "parameter"), +] + FORMAT_PARAMS_DATE: list[Suggestion] = [ Suggestion("format=", "strftime pattern", "parameter"), ] +FORMAT_PARAMS_BOOLEAN: list[Suggestion] = [ + Suggestion("true_value=", "Display text for True", "parameter"), + Suggestion("false_value=", "Display text for False", "parameter"), + Suggestion("null_value=", "Display text for null", "parameter"), +] + FORMAT_PARAMS_TEXT: list[Suggestion] = [ Suggestion("transform=", "Text transformation", "parameter"), Suggestion("max_length=", "Maximum length", "parameter"), Suggestion("ellipsis=", "Truncation suffix", "parameter"), ] +FORMAT_PARAMS_ENUM: list[Suggestion] = [ + Suggestion("source=", "Column with labels", "parameter"), + Suggestion("default=", "Fallback display value", "parameter"), + Suggestion("allow_empty=", "Allow empty values", "parameter"), + Suggestion("empty_label=", "Label for empty values", "parameter"), + Suggestion("order_by=", "Sort order", "parameter"), +] + +FORMAT_PARAMS_CONSTANT: list[Suggestion] = [ + Suggestion("value=", "Constant value to display", "parameter"), +] + # ============================================================================= # Condition Keywords # ============================================================================= diff --git a/src/myfasthtml/core/formatting/dsl/transformer.py b/src/myfasthtml/core/formatting/dsl/transformer.py index 3fff84f..f220cfb 100644 --- a/src/myfasthtml/core/formatting/dsl/transformer.py +++ b/src/myfasthtml/core/formatting/dsl/transformer.py @@ -363,7 +363,7 @@ class DSLTransformer(Transformer): def _filter_number_kwargs(self, kwargs: dict) -> dict: """Filter kwargs for NumberFormatter.""" - valid_keys = {"prefix", "suffix", "thousands_sep", "decimal_sep", "precision", "multiplier"} + valid_keys = {"prefix", "suffix", "thousands_sep", "decimal_sep", "precision", "multiplier", "absolute"} return {k: v for k, v in kwargs.items() if k in valid_keys} def _filter_date_kwargs(self, kwargs: dict) -> dict: diff --git a/src/myfasthtml/core/formatting/formatter_resolver.py b/src/myfasthtml/core/formatting/formatter_resolver.py index f05d1e2..0dd0d56 100644 --- a/src/myfasthtml/core/formatting/formatter_resolver.py +++ b/src/myfasthtml/core/formatting/formatter_resolver.py @@ -39,8 +39,11 @@ class NumberFormatterResolver(BaseFormatterResolver): if value is None: return "" - # Convert to float and apply multiplier - num = float(value) * formatter.multiplier + # Convert to float, apply absolute value if requested, then multiplier + num = float(value) + if formatter.absolute: + num = abs(num) + num = num * formatter.multiplier # Round to precision if formatter.precision > 0: @@ -97,6 +100,7 @@ class NumberFormatterResolver(BaseFormatterResolver): decimal_sep=formatter.decimal_sep if formatter.decimal_sep != "." else preset.get("decimal_sep", "."), precision=formatter.precision if formatter.precision != 0 else preset.get("precision", 0), multiplier=formatter.multiplier if formatter.multiplier != 1.0 else preset.get("multiplier", 1.0), + absolute=formatter.absolute if formatter.absolute else preset.get("absolute", False), ) else: return NumberFormatter( @@ -107,6 +111,7 @@ class NumberFormatterResolver(BaseFormatterResolver): decimal_sep=preset.get("decimal_sep", "."), precision=preset.get("precision", 0), multiplier=preset.get("multiplier", 1.0), + absolute=preset.get("absolute", False), ) diff --git a/tests/core/formatting/dsl/test_completion.py b/tests/core/formatting/dsl/test_completion.py index e761a7f..14aef7e 100644 --- a/tests/core/formatting/dsl/test_completion.py +++ b/tests/core/formatting/dsl/test_completion.py @@ -431,14 +431,62 @@ def test_context_format_type_after_dot(): assert context == Context.FORMAT_TYPE -def test_context_format_param_date(): - """Test FORMAT_PARAM_DATE context inside format.date().""" - text = "column amount:\n format.date(" - cursor = Position(line=1, ch=16) +def test_context_format_number_absolute_param_suggests_boolean(): + """Test that 'absolute=' in format.number() triggers BOOLEAN_VALUE context.""" + text = "column amount:\n format.number(absolute=" + cursor = Position(line=1, ch=len(" format.number(absolute=")) scope = DetectedScope(scope_type="column", column_name="amount") context = detect_context(text, cursor, scope) - assert context == Context.FORMAT_PARAM_DATE + assert context == Context.BOOLEAN_VALUE + + +def test_context_format_type_while_typing(): + """Test FORMAT_TYPE context while typing a format type name after 'format.'.""" + text = "column amount:\n format.dat" + cursor = Position(line=1, ch=14) + scope = DetectedScope(scope_type="column", column_name="amount") + + context = detect_context(text, cursor, scope) + assert context == Context.FORMAT_TYPE + + +@pytest.mark.parametrize("line,expected_context", [ + (' format.number(', Context.FORMAT_PARAM_NUMBER), + (' format.date(', Context.FORMAT_PARAM_DATE), + (' format.boolean(', Context.FORMAT_PARAM_BOOLEAN), + (' format.text(', Context.FORMAT_PARAM_TEXT), + (' format.enum(', Context.FORMAT_PARAM_ENUM), + (' format.constant(', Context.FORMAT_PARAM_CONSTANT), +]) +def test_i_can_detect_format_param_context_after_open_paren(line, expected_context): + """Test FORMAT_PARAM_* context right after the opening parenthesis.""" + text = f"column amount:\n{line}" + cursor = Position(line=1, ch=len(line)) + scope = DetectedScope(scope_type="column", column_name="amount") + + context = detect_context(text, cursor, scope) + assert context == expected_context + + +@pytest.mark.parametrize("line,expected_context", [ + (' format.number(prefix="€ ",', Context.FORMAT_PARAM_NUMBER), + (' format.date(format="%Y",', Context.FORMAT_PARAM_DATE), + (' format.boolean(true_value="Yes",', Context.FORMAT_PARAM_BOOLEAN), + (' format.text(transform="upper",', Context.FORMAT_PARAM_TEXT), + (' format.enum(source="label",', Context.FORMAT_PARAM_ENUM), + (' format.constant(value="N/A",', Context.FORMAT_PARAM_CONSTANT), +]) +def test_i_can_detect_format_param_context_after_comma(line, expected_context): + """Test FORMAT_PARAM_* context after a comma (second parameter position).""" + text = f"column amount:\n{line}" + cursor = Position(line=1, ch=len(line)) + scope = DetectedScope(scope_type="column", column_name="amount") + + context = detect_context(text, cursor, scope) + assert context == expected_context + + def test_context_format_param_text(): @@ -682,11 +730,12 @@ def test_suggestions_format_type(provider): suggestions = engine.get_suggestions(Context.FORMAT_TYPE, scope, "") labels = [s.label for s in suggestions] - assert "number" in labels - assert "date" in labels - assert "boolean" in labels - assert "text" in labels - assert "enum" in labels + assert "number(" in labels + assert "date(" in labels + assert "boolean(" in labels + assert "text(" in labels + assert "enum(" in labels + assert "constant(" in labels def test_suggestions_operators(provider): @@ -761,6 +810,62 @@ def test_suggestions_rule_start(provider): assert "format." in labels +def test_suggestions_format_param_number(provider): + """Test suggestions for FORMAT_PARAM_NUMBER context.""" + engine = FormattingCompletionEngine(provider, "app.orders") + scope = DetectedScope(scope_type="column", column_name="amount") + + suggestions = engine.get_suggestions(Context.FORMAT_PARAM_NUMBER, scope, "") + labels = [s.label for s in suggestions] + + assert "prefix=" in labels + assert "suffix=" in labels + assert "precision=" in labels + assert "thousands_sep=" in labels + assert "decimal_sep=" in labels + assert "multiplier=" in labels + assert "absolute=" in labels + + +def test_suggestions_format_param_boolean(provider): + """Test suggestions for FORMAT_PARAM_BOOLEAN context.""" + engine = FormattingCompletionEngine(provider, "app.orders") + scope = DetectedScope(scope_type="column", column_name="amount") + + suggestions = engine.get_suggestions(Context.FORMAT_PARAM_BOOLEAN, scope, "") + labels = [s.label for s in suggestions] + + assert "true_value=" in labels + assert "false_value=" in labels + assert "null_value=" in labels + + +def test_suggestions_format_param_enum(provider): + """Test suggestions for FORMAT_PARAM_ENUM context.""" + engine = FormattingCompletionEngine(provider, "app.orders") + scope = DetectedScope(scope_type="column", column_name="amount") + + suggestions = engine.get_suggestions(Context.FORMAT_PARAM_ENUM, scope, "") + labels = [s.label for s in suggestions] + + assert "source=" in labels + assert "default=" in labels + assert "allow_empty=" in labels + assert "empty_label=" in labels + assert "order_by=" in labels + + +def test_suggestions_format_param_constant(provider): + """Test suggestions for FORMAT_PARAM_CONSTANT context.""" + engine = FormattingCompletionEngine(provider, "app.orders") + scope = DetectedScope(scope_type="column", column_name="amount") + + suggestions = engine.get_suggestions(Context.FORMAT_PARAM_CONSTANT, scope, "") + labels = [s.label for s in suggestions] + + assert "value=" in labels + + def test_suggestions_none_context(provider): """Test that NONE context returns empty suggestions.""" engine = FormattingCompletionEngine(provider, "app.orders") @@ -853,6 +958,64 @@ def test_i_can_get_completions_in_comment_returns_empty(provider): assert result.is_empty +@pytest.mark.parametrize("text,cursor_line,used_param,remaining_param", [ + ( + 'column amount:\n format.number(prefix="€ ",', + 1, "prefix=", "suffix=", + ), + ( + 'column amount:\n format.number(prefix="€ ", suffix=" CHF",', + 1, "suffix=", "precision=", + ), + ( + 'column amount:\n style(bold=True,', + 1, "bold=", "italic=", + ), + ( + 'column amount:\n style(background_color="red",', + 1, "background_color=", "color=", + ), +]) +def test_i_cannot_get_already_used_param_in_suggestions(provider, text, cursor_line, used_param, remaining_param): + """Test that already-used parameters are not suggested again.""" + cursor = Position(line=cursor_line, ch=len(text.split("\n")[cursor_line])) + + result = get_completions(text, cursor, provider, "app.orders") + + labels = [s.label for s in result.suggestions] + assert used_param not in labels, f"'{used_param}' should not appear after being used" + assert remaining_param in labels, f"'{remaining_param}' should still appear" + + +def test_i_can_get_completions_while_typing_param_name(provider): + """Test that suggestions appear while typing a partial parameter name. + + Typing 'pre' after a comma should suggest 'precision=' (filtered by prefix) + and exclude 'prefix=' (already used). + """ + text = 'column amount:\n format.number(prefix="€ ", pre' + cursor = Position(line=1, ch=len(text.split("\n")[1])) + + result = get_completions(text, cursor, provider, "app.orders") + + labels = [s.label for s in result.suggestions] + assert "precision=" in labels + assert "prefix=" not in labels + + +def test_i_can_get_completions_condition_unaffected_by_param_filtering(provider): + """Test that condition suggestions are not affected by parameter filtering.""" + text = 'column amount:\n style(bold=True) if ' + cursor = Position(line=1, ch=len(text.split("\n")[1])) + + result = get_completions(text, cursor, provider, "app.orders") + + labels = [s.label for s in result.suggestions] + assert "value" in labels + assert "col." in labels + assert "not" in labels + + def test_i_can_create_formatting_completion_engine(provider): """Test that FormattingCompletionEngine can be instantiated.""" engine = FormattingCompletionEngine(provider, "app.orders") @@ -882,3 +1045,64 @@ def test_i_can_use_engine_detect_context(provider): context = engine.detect_context(text, cursor, scope) assert context == Context.STYLE_ARGS + + +@pytest.mark.parametrize("format_type,expected_params", [ + ("number", ["prefix=", "suffix=", "precision="]), + ("date", ["format="]), + ("boolean", ["true_value=", "false_value=", "null_value="]), + ("text", ["transform=", "max_length=", "ellipsis="]), + ("enum", ["source=", "default=", "allow_empty="]), + ("constant", ["value="]), +]) +def test_i_can_get_completions_for_format_params(provider, format_type, expected_params): + """Test that correct parameters are suggested after 'format.('.""" + text = f"column amount:\n format.{format_type}(" + cursor = Position(line=1, ch=len(f" format.{format_type}(")) + + result = get_completions(text, cursor, provider, "app.orders") + + assert not result.is_empty + labels = [s.label for s in result.suggestions] + for param in expected_params: + assert param in labels, f"Expected '{param}' in suggestions for format.{format_type}(" + + +def test_i_can_get_completions_for_format_type_after_dot(provider): + """Test that format types are suggested after 'format.' + + The dot must be treated as a word delimiter so that the prefix + is empty after the dot, allowing all FORMAT_TYPE suggestions through. + Without this, the prefix 'format.' would filter out 'number(', 'date(', etc. + """ + text = "column amount:\n format." + cursor = Position(line=1, ch=11) + + result = get_completions(text, cursor, provider, "app.orders") + + assert not result.is_empty + labels = [s.label for s in result.suggestions] + assert "number(" in labels + assert "date(" in labels + assert "boolean(" in labels + assert "text(" in labels + assert "enum(" in labels + assert "constant(" in labels + + +def test_i_can_get_completions_for_format_type_filtered_by_prefix(provider): + """Test that format types are filtered by partial input after 'format.' + + Typing 'format.dat' should only suggest 'date(', not the other types. + The dot delimiter ensures the prefix is 'dat', not 'format.dat'. + """ + text = "column amount:\n format.dat" + cursor = Position(line=1, ch=14) + + result = get_completions(text, cursor, provider, "app.orders") + + labels = [s.label for s in result.suggestions] + assert "date(" in labels + assert "number(" not in labels + assert "boolean(" not in labels + assert "text(" not in labels diff --git a/tests/core/formatting/test_formatter_resolver.py b/tests/core/formatting/test_formatter_resolver.py index c1335b5..93c0890 100644 --- a/tests/core/formatting/test_formatter_resolver.py +++ b/tests/core/formatting/test_formatter_resolver.py @@ -47,6 +47,30 @@ class TestNumberFormatter: ) assert resolver.resolve(formatter, value) == expected + @pytest.mark.parametrize("value,expected", [ + (-42, "42"), + (-1.5, "2"), # rounds abs(-1.5) = 1.5 → 2 + (10, "10"), + (-0.0, "0"), + ]) + def test_i_can_format_number_with_absolute(self, value, expected): + """Test that absolute=True strips the sign before formatting.""" + resolver = FormatterResolver() + formatter = NumberFormatter(absolute=True) + assert resolver.resolve(formatter, value) == expected + + def test_i_can_combine_absolute_with_prefix_suffix(self): + """Test that absolute=True works together with prefix and suffix.""" + resolver = FormatterResolver() + formatter = NumberFormatter(absolute=True, prefix="$", suffix=" USD", precision=2) + assert resolver.resolve(formatter, -1234.56) == "$1234.56 USD" + + def test_i_can_format_negative_number_without_absolute(self): + """Test that absolute=False (default) preserves the sign.""" + resolver = FormatterResolver() + formatter = NumberFormatter(absolute=False, precision=2) + assert resolver.resolve(formatter, -42.5) == "-42.50" + def test_number_none_value(self): resolver = FormatterResolver() formatter = NumberFormatter(precision=2)