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)