From 64c3571c40794fa9e95b6d17e294f0854c88e464 Mon Sep 17 00:00:00 2001 From: rmichaelthomas Date: Tue, 21 Jul 2026 20:22:43 -0700 Subject: [PATCH] Fix _emit_string round-trip for number/date-shaped string values lexer._classify resolves a bare unquoted word as reserved-word tables -> _NUMBER_RE -> _DATE_RE -> UNKNOWN. renderer._emit_string didn't know about the number/date shapes, so a string value like "2025-07-01" or "75" rendered bare and re-lexed as a DATE/NUMBER token on the next parse instead of staying a string. Import _NUMBER_RE/_DATE_RE from .lexer rather than re-declaring them, mirroring how _emit_string already imports ALL_RESERVED from vocabulary instead of restating the reserved-word list. Co-Authored-By: Claude Sonnet 5 --- src/liminate/renderer.py | 19 ++++++++-- tests/test_renderer.py | 82 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/liminate/renderer.py b/src/liminate/renderer.py index ee58dad..671d52c 100644 --- a/src/liminate/renderer.py +++ b/src/liminate/renderer.py @@ -70,6 +70,7 @@ WhenNode, ) from .vocabulary import ALL_RESERVED +from .lexer import _DATE_RE, _NUMBER_RE # v3a §110: action lines inside a `when` block are indented by two spaces @@ -623,10 +624,22 @@ def _emit_string(s: str) -> str: as a verb/connective/etc.), or - the value differs from its lowercased form (case would be lost because the lexer normalizes unquoted words; quoted content is - preserved verbatim). + preserved verbatim), or + - the value is number-shaped or date-shaped (bare form would + re-lex as a NUMBER or DATE token — a self-typing lexical shape — + instead of a string, per lexer._classify's resolution order; + _NUMBER_RE / _DATE_RE are imported from the lexer so this stays + in lockstep with the literal grammar it mirrors). `with status as active` stays bare; `with status as "Active"` keeps - its quotes. + its quotes; a string value `"2025-07-01"` or `"75"` now keeps its + quotes so it round-trips as a string, not a date/number. """ - if " " in s or s in ALL_RESERVED or s != s.lower(): + if ( + " " in s + or s in ALL_RESERVED + or s != s.lower() + or _NUMBER_RE.match(s) + or _DATE_RE.match(s) + ): return f'"{s}"' return s diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 7c3c970..24b3136 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -10,6 +10,8 @@ round-trips through `parse_when_block` (split-by-indent). """ +from datetime import date + import pytest from liminate.lexer import tokenize @@ -20,6 +22,7 @@ CompoundConditionNode, ConditionNode, CountNode, + DateLiteral, EachNode, EachPronoun, FilterNode, @@ -39,7 +42,7 @@ parse, parse_when_block, ) -from liminate.renderer import render, render_with_explicit_precedence +from liminate.renderer import _emit_string, render, render_with_explicit_precedence from liminate.reorderer import reorder from liminate.result import LiminateResult, ResultStatus @@ -79,6 +82,19 @@ def test_render_bareword_with_uppercase_keeps_quotes(): assert render(BareWord("Active")) == '"Active"' +def test_emit_string_quotes_number_and_date_shaped_content(): + """A string value whose content is number- or date-shaped must stay + quoted — bare emission would re-lex as a NUMBER/DATE token (the + lexer's self-typing lexical shapes), not a string. `-3.5` guards the + full _NUMBER_RE shape (sign + decimal), not just plain digits. + Safe bare strings (single lowercase non-reserved, non-numeric word) + are unaffected.""" + assert _emit_string("2025-07-01") == '"2025-07-01"' + assert _emit_string("75") == '"75"' + assert _emit_string("-3.5") == '"-3.5"' + assert _emit_string("active") == "active" + + def test_render_show(): assert render(ShowNode(target=NameRef("age"))) == "show age" assert render(ShowNode(target=None)) == "show" @@ -323,6 +339,12 @@ def test_explicit_precedence_renders_parens_for_mixed_clause(): ), # `of` on the left side of a condition (v2d §100) ('choose if total of o1 is above 50: show "big order"', None), + # _emit_string round-trip gap: a string value shaped like a NUMBER or + # DATE literal must keep its quotes so it re-parses as a string, not + # a NumberLiteral/DateLiteral. + ('remember a value called d with "2025-07-01"', None), + ('remember a value called n with "75"', None), + ('remember a value called n with "-3.5"', None), ] @@ -342,6 +364,64 @@ def test_round_trip(line, comps): ) +# ---------- _emit_string round-trip gap: number/date-shaped strings ---------- + + +def test_date_shaped_string_value_round_trips_as_string_not_date_literal(): + """A QuotedString whose content is date-shaped must stay a string + through render + re-parse, not collapse into a DateLiteral.""" + line = 'remember a value called d with "2025-07-01"' + first_ast = _parse(line) + assert isinstance(first_ast, RememberValueNode) + assert isinstance(first_ast.value, QuotedString) + + rendered = render(first_ast) + assert rendered == 'remember a value called d with "2025-07-01"' + + second_ast = _parse(rendered) + assert isinstance(second_ast.value, QuotedString) + assert second_ast == first_ast + + +def test_number_shaped_string_value_round_trips_as_string_not_number_literal(): + """A QuotedString whose content is number-shaped must stay a string + through render + re-parse, not collapse into a NumberLiteral.""" + line = 'remember a value called n with "75"' + first_ast = _parse(line) + assert isinstance(first_ast, RememberValueNode) + assert isinstance(first_ast.value, QuotedString) + + rendered = render(first_ast) + assert rendered == 'remember a value called n with "75"' + + second_ast = _parse(rendered) + assert isinstance(second_ast.value, QuotedString) + assert second_ast == first_ast + + +def test_negative_decimal_number_shaped_string_value_stays_quoted(): + """Guards the full _NUMBER_RE shape (leading '-' and a decimal + point), not just a plain-digits case.""" + line = 'remember a value called n with "-3.5"' + first_ast = _parse(line) + assert isinstance(first_ast.value, QuotedString) + + rendered = render(first_ast) + assert rendered == 'remember a value called n with "-3.5"' + + second_ast = _parse(rendered) + assert second_ast == first_ast + + +def test_real_number_and_date_literals_still_render_bare(): + """Regression guard: this fix is confined to _emit_string (string + nodes). Genuine NumberLiteral/DateLiteral values must keep rendering + bare — the fix must not make real numbers/dates start quoting.""" + assert render(NumberLiteral(75)) == "75" + assert render(NumberLiteral(-3.5)) == "-3.5" + assert render(DateLiteral(value=date(2025, 7, 1))) == "2025-07-01" + + # ---------- mixed-precedence amber still produces a canonical rendering ---------- def test_amber_result_carries_paren_free_canonical():