From c5837318dc4f3afaf9be9a3db878ae0ce45fb620 Mon Sep 17 00:00:00 2001 From: rmichaelthomas Date: Sat, 4 Jul 2026 12:14:38 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Calendar=20Era=20=E2=80=94=20dates=20as?= =?UTF-8?q?=20a=20first-class=20value=20type=20(v29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `date` as a third scalar value type alongside number and string, threaded through the full pipeline: lexer (TokenType.DATE, _DATE_RE), parser (DateLiteral, calendar validation, bare-date starting/until), interpreter (storage, display, comparison, arithmetic, within-tolerance, today injection), analyzer (date types, _require_comparable, date-aware arithmetic checks, list_of_dates parity with existing list types), and renderer (bare ISO 8601 round-trip). Zero new reserved words — DATE is a literal type, accounted the same way as NUMBER. Also extends highest/lowest to dates (interpreter.py) and mirrors the new date-comparison guard into listener.py's duplicate _apply_op, both needed for goal-form parity but not explicitly enumerated in the spec's file list. Co-Authored-By: Claude Sonnet 5 --- docs/language/syntax.md | 96 +++++- src/liminate/analyzer.py | 91 ++++-- src/liminate/cli.py | 5 + src/liminate/interpreter.py | 107 ++++++- src/liminate/lexer.py | 3 + src/liminate/listener.py | 9 + src/liminate/parser.py | 62 +++- src/liminate/renderer.py | 6 +- src/liminate/run.py | 13 +- src/liminate/vocabulary.py | 4 + tests/test_date.py | 572 ++++++++++++++++++++++++++++++++++++ tests/test_vocabulary.py | 2 + 12 files changed, 922 insertions(+), 48 deletions(-) create mode 100644 tests/test_date.py diff --git a/docs/language/syntax.md b/docs/language/syntax.md index f617706..9a81909 100644 --- a/docs/language/syntax.md +++ b/docs/language/syntax.md @@ -573,13 +573,16 @@ first, then the symbol table. ## Temporal boundaries (`starting` / `until`) Two statement-initial connectives give a rule an effective date and a -sunset clause. They attach to any verb statement and take a quoted -ISO 8601 date (`YYYY-MM-DD`): +sunset clause. They attach to any verb statement and take an ISO 8601 +date (`YYYY-MM-DD`), quoted or bare (Calendar Era, v29 — bare dates +were added alongside the [date value type](#dates); both forms are +equivalent and always canonicalize to the quoted form): ``` starting "2025-07-01" require amount is above 50000 until "2025-12-31" forbid total is above 10000 starting "2025-07-01" until "2025-12-31" permit category is "travel" +starting 2025-07-01 require amount is above 50000 ``` `starting` declares when a rule takes effect; `until` declares when it @@ -1021,9 +1024,11 @@ A literal value can be: - **A number** — digits with an optional single decimal point. Examples: `30`, `3.14`, `100`. The language does not support negative numbers or scientific notation. -- **A single-word string** — any bare word that is not a number and - not in the reserved-word list. Examples: `red`, `active`, - `portland`. Strings are case-folded to lowercase. +- **A date** — a bare ISO 8601 date, `YYYY-MM-DD`. Example: + `2025-07-01`. See [Dates](#dates) (Calendar Era, v29). +- **A single-word string** — any bare word that is not a number, not + a date, and not in the reserved-word list. Examples: `red`, + `active`, `portland`. Strings are case-folded to lowercase. - **A quoted multi-word string** — any text inside `"..."` (v2c). Used for values that contain spaces or that collide with the reserved-word list. Examples: `"in progress"`, `"high priority"`, @@ -1043,6 +1048,87 @@ remember a list called items with filter and blue Wrapping the reserved word in quotes is the v2c remedy — quoted content bypasses the vocabulary lookup. +## Dates + +Calendar Era (v29) adds `date` as a third scalar value type, alongside +number and string. Zero new reserved words — a bare date is recognized +by shape (`YYYY-MM-DD`), the same way a bare number is recognized by +its digit shape. + +A date literal is bare, unquoted ISO 8601: + +``` +remember a date called due-date with 2025-07-01 +``` + +Dates work anywhere a value does: a standalone value, a record field, +or a list item — a list of dates must be homogeneous, same as a list +of numbers or strings. + +``` +remember an order called o1 with filed-date as 2025-07-01 and status as active +remember a list called deadlines with 2025-07-01 and 2025-08-01 and 2025-09-01 +``` + +**Quoting still marks data.** `"2025-07-01"` (quoted) is a string, not +a date — the same "quotes bypass vocabulary/type inference" rule that +applies everywhere else in the language (see [Quoting](#quoting-v2c)). +This is a deliberate escape hatch: wrap a date-shaped value in quotes +if you want it treated as text. + +**Comparison.** Every condition operator works on dates: `is`, `above`, +`below`, `equal to`, `not above`, `not below`, `not equal to`, `within`. +Comparing a date to a number or to text is a type error — both sides of +an ordered comparison must be dates: + +``` +require due-date is below 2025-12-31 +forbid filed-date is above 2025-12-31 unless waiver is equal to yes +``` + +**`within` on dates** measures a day count rather than a numeric +distance. The tolerance is still a plain number: + +``` +require filed-date is within 30 of 2025-07-01 +``` + +**Arithmetic** extends `plus`/`minus` to dates, in whole days only +(fractional-day arithmetic isn't supported): + +``` +remember a date called deadline from filing-date plus 30 +remember a value called gap from deadline minus due-date +``` + +`date plus number` and `date minus number` return a date. `date minus +date` returns the day count between them (a number). `date plus date`, +and multiplying or dividing a date, are errors. + +**`highest` / `lowest`** and **`sort`** work on date fields and lists +of dates the same way they work on numbers — `highest`/`lowest` return +the latest/earliest date; `sort` orders chronologically: + +``` +sort the entries by filed-date +highest filed-date of submissions +lowest filed-date of submissions +keep the orders where filed-date is above 2025-01-01 +``` + +**`today`.** Dates don't evaluate against a clock on their own — a +program that wants "the current date" references the name `today`, +which is a product-layer injected value, not a language builtin. The +CLI injects it automatically on every run; an embedder calling `run()` +directly supplies it via `inject={"today": ...}`. A program that +references `today` without an injected value gets "I can't find +'today'" like any other unresolved name — and a program that never +references `today` is unaffected by its presence. + +``` +require due-date is below today +``` + ## Mixed `and` / `or` and the amber prompt A `where` clause that mixes `and` and `or` is unambiguous to the diff --git a/src/liminate/analyzer.py b/src/liminate/analyzer.py index e187fdf..a0b47da 100644 --- a/src/liminate/analyzer.py +++ b/src/liminate/analyzer.py @@ -44,6 +44,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import date from typing import Any from .parser import ( @@ -59,6 +60,7 @@ CompoundConditionNode, ConditionNode, CountNode, + DateLiteral, EachNode, EachPronoun, ExpectNode, @@ -140,8 +142,8 @@ class SymbolEntry: # Recognized type strings. _TYPE_NAMES = frozenset({ - "number", "string", "record", - "list_of_numbers", "list_of_strings", "list_of_records", + "number", "string", "record", "date", + "list_of_numbers", "list_of_strings", "list_of_records", "list_of_dates", "composition", }) @@ -605,7 +607,10 @@ def _check_value_expr( in_action_block: bool = False, live_value_names: set[str] | None = None, ) -> None: - if isinstance(value_node, (NumberLiteral, BareWord, EachPronoun, QuotedString)): + if isinstance( + value_node, + (NumberLiteral, DateLiteral, BareWord, EachPronoun, QuotedString), + ): return if isinstance(value_node, NameRef): if value_node.name not in symtab: @@ -775,6 +780,11 @@ def _check_arithmetic_operand( ) -> None: if isinstance(operand, NumberLiteral): return + if isinstance(operand, DateLiteral): + # Calendar Era (v29) — dates are valid arithmetic operands + # (date ± number, date - date); runtime enforces the exact + # operand-shape rules (_eval_date_arithmetic). + return if isinstance(operand, QuotedString): raise _SemanticError( f"Arithmetic only works with numbers, not text. " @@ -787,7 +797,7 @@ def _check_arithmetic_operand( _check_field_access(operand, symtab) entry = symtab[operand.record_name] ftype = (entry.schema or {}).get(operand.field) - if ftype not in ("number", None, "unknown"): + if ftype not in ("number", "date", None, "unknown"): raise _SemanticError( f"Arithmetic only works with numbers, but " f"'{operand.field} of {operand.record_name}' is " @@ -805,9 +815,9 @@ def _check_arithmetic_operand( f"You might need to 'remember' it first." ) t = symtab[operand.name].type - if t not in ("number", "unknown"): + if t not in ("number", "date", "unknown"): raise _SemanticError( - f"Arithmetic only works with numbers, but " + f"Arithmetic only works with numbers and dates, but " f"'{operand.name}' is {_singular(t)}." ) return @@ -886,9 +896,9 @@ def _check_remember_list( f"'{examples[i]}' is {_singular(t)}." ) only = next(iter(distinct)) - if only not in ("number", "string", "record"): + if only not in ("number", "string", "record", "date"): raise _SemanticError( - f"v1 lists may only contain numbers, text, or records. " + f"v1 lists may only contain numbers, text, records, or dates. " f"'{examples[0]}' is {_singular(only)}." ) @@ -896,6 +906,8 @@ def _check_remember_list( def _infer_item_type(item: ASTNode, symtab: dict[str, SymbolEntry]) -> tuple[str, str]: if isinstance(item, NumberLiteral): return "number", _fmt_number(item.value) + if isinstance(item, DateLiteral): + return "date", item.value.isoformat() if isinstance(item, BareWord): if item.word in symtab: return symtab[item.word].type, item.word @@ -1182,7 +1194,9 @@ def _check_sort( f"You might need to 'remember' it first." ) entry = symtab[name] - if entry.type not in ("list_of_numbers", "list_of_strings", "list_of_records"): + if entry.type not in ( + "list_of_numbers", "list_of_strings", "list_of_records", "list_of_dates", + ): raise _SemanticError( f"I can only sort a list. '{name}' is {_singular(entry.type)}." ) @@ -1227,7 +1241,9 @@ def _check_transform( f"You might need to 'remember' it first." ) entry = symtab[name] - if entry.type not in ("list_of_numbers", "list_of_strings", "list_of_records"): + if entry.type not in ( + "list_of_numbers", "list_of_strings", "list_of_records", "list_of_dates", + ): raise _SemanticError( f"I can only transform a list. '{name}' is {_singular(entry.type)}." ) @@ -1287,16 +1303,16 @@ def _check_condition( # No type-error fires here for v1 (the spec doesn't lock one). return if cond.op in ("above", "below"): - _require_numeric(field_type, field_label, cond.op) - _require_numeric(value_type, value_label, cond.op) + _require_comparable(field_type, field_label, cond.op) + _require_comparable(value_type, value_label, cond.op) return if cond.op == "within": # Issue #19: all three operands of `is within of ` # must be numeric. - _require_numeric(field_type, field_label, "within") - _require_numeric(value_type, value_label, "within") + _require_comparable(field_type, field_label, "within") + _require_comparable(value_type, value_label, "within") target_type, target_label = _resolve_value(cond.value2, symtab, iterator) - _require_numeric(target_type, target_label, "within") + _require_comparable(target_type, target_label, "within") return if cond.op == "equal_to": return # any same-type comparison; analyzer doesn't enforce @@ -1305,17 +1321,19 @@ def _check_condition( if cond.op.startswith("not_"): inner = cond.op[len("not_"):] if inner in ("above", "below"): - _require_numeric(field_type, field_label, f"not {inner}") - _require_numeric(value_type, value_label, f"not {inner}") + _require_comparable(field_type, field_label, f"not {inner}") + _require_comparable(value_type, value_label, f"not {inner}") return raise _SemanticError(f"Unknown comparison operator '{cond.op}'.") -def _require_numeric(t: str, label: str, op: str) -> None: - if t == "number": +def _require_comparable(t: str, label: str, op: str) -> None: + """Accept numbers or dates for ordered comparison; reject everything + else. Mixed number/date is caught at runtime by _apply_op.""" + if t in ("number", "date"): return raise _SemanticError( - f"'{op}' requires numbers, but '{label}' is {_singular(t)}." + f"'{op}' requires numbers or dates, but '{label}' is {_singular(t)}." ) @@ -1396,6 +1414,8 @@ def _resolve_value( ) -> tuple[str, str]: if isinstance(value_node, NumberLiteral): return "number", _fmt_number(value_node.value) + if isinstance(value_node, DateLiteral): + return "date", value_node.value.isoformat() if isinstance(value_node, BareWord): if value_node.word in symtab: entry = symtab[value_node.word] @@ -1739,23 +1759,23 @@ def _check_choose_condition( if cond.op in ("is", "equal_to"): return if cond.op in ("above", "below"): - _require_numeric(field_type, field_label, cond.op) - _require_numeric(value_type, value_label, cond.op) + _require_comparable(field_type, field_label, cond.op) + _require_comparable(value_type, value_label, cond.op) return if cond.op == "within": # Issue #19 — numeric tolerance, also valid in `choose if`. - _require_numeric(field_type, field_label, "within") - _require_numeric(value_type, value_label, "within") + _require_comparable(field_type, field_label, "within") + _require_comparable(value_type, value_label, "within") target_type, target_label = _resolve_choose_operand(cond.value2, symtab) - _require_numeric(target_type, target_label, "within") + _require_comparable(target_type, target_label, "within") return if cond.op in ("includes", "not_includes"): return if cond.op.startswith("not_"): inner = cond.op[len("not_"):] if inner in ("above", "below"): - _require_numeric(field_type, field_label, f"not {inner}") - _require_numeric(value_type, value_label, f"not {inner}") + _require_comparable(field_type, field_label, f"not {inner}") + _require_comparable(value_type, value_label, f"not {inner}") return raise _SemanticError(f"Unknown comparison operator '{cond.op}'.") @@ -2146,6 +2166,7 @@ def _check_finish( "list_of_numbers": "number", "list_of_strings": "string", "list_of_records": "record", + "list_of_dates": "date", } @@ -2258,6 +2279,8 @@ def _infer_add_item_type( iterated record resolves to that field's type.""" if isinstance(item, NumberLiteral): return "number", _fmt_number(item.value) + if isinstance(item, DateLiteral): + return "date", item.value.isoformat() if isinstance(item, QuotedString): return "string", item.content if isinstance(item, FieldAccessNode): @@ -2300,6 +2323,8 @@ def _resolve_choose_operand( access uses ` of ` explicitly (§100).""" if isinstance(node, NumberLiteral): return "number", _fmt_number(node.value) + if isinstance(node, DateLiteral): + return "date", node.value.isoformat() if isinstance(node, BareWord): if node.word in symtab: return symtab[node.word].type, node.word @@ -2361,7 +2386,9 @@ def _require_list( f"I can't find '{name}'. You might need to 'remember' it first." ) entry = symtab[name] - if entry.type not in ("list_of_numbers", "list_of_strings", "list_of_records"): + if entry.type not in ( + "list_of_numbers", "list_of_strings", "list_of_records", "list_of_dates", + ): if verb == "filter": msg = f"I can only filter a list. '{name}' is {_singular(entry.type)}." elif verb == "keep": @@ -2387,6 +2414,8 @@ def _make_iterator(name: str, entry: SymbolEntry) -> IteratorContext: return IteratorContext(collection_name=name, scalar_type="number") if entry.type == "list_of_strings": return IteratorContext(collection_name=name, scalar_type="string") + if entry.type == "list_of_dates": + return IteratorContext(collection_name=name, scalar_type="date") raise _SemanticError(f"'{name}' isn't a list I can iterate.") @@ -2398,6 +2427,8 @@ def _value_type(v: Any) -> str: return "number" if isinstance(v, str): return "string" + if isinstance(v, date): + return "date" if isinstance(v, dict): return "record" return "unknown" @@ -2407,9 +2438,11 @@ def _value_type(v: Any) -> str: "number": "a number", "string": "text", "record": "a record", + "date": "a date", "list_of_numbers": "a list of numbers", "list_of_strings": "a list of text", "list_of_records": "a list of records", + "list_of_dates": "a list of dates", "composition": "a composition", "unknown": "an unknown type", } @@ -2417,9 +2450,11 @@ def _value_type(v: Any) -> str: "number": "numbers", "string": "text", "record": "records", + "date": "dates", "list_of_numbers": "lists of numbers", "list_of_strings": "lists of text", "list_of_records": "lists of records", + "list_of_dates": "lists of dates", } diff --git a/src/liminate/cli.py b/src/liminate/cli.py index b67fa2e..028a22d 100644 --- a/src/liminate/cli.py +++ b/src/liminate/cli.py @@ -35,6 +35,7 @@ import json import sys +from datetime import datetime, timezone from importlib.metadata import version as _pkg_version from pathlib import Path @@ -375,12 +376,16 @@ def on_result(result, session, line_num, ts, dur): on_blank = (lambda: write("\n")) if quiet else None + # Calendar Era (v29) — `today` is a product-layer injected value, not + # vocabulary. Always available; programs that don't reference it are + # unaffected. result = run_program( content, domain_packs=domain_packs, auto_confirm_amber=auto_confirm_amber, on_result=on_result, on_blank=on_blank, + inject={"today": datetime.now(timezone.utc).date()}, ) return result.had_error diff --git a/src/liminate/interpreter.py b/src/liminate/interpreter.py index a987ee5..9fc6ff6 100644 --- a/src/liminate/interpreter.py +++ b/src/liminate/interpreter.py @@ -34,6 +34,7 @@ import re from contextvars import ContextVar from dataclasses import dataclass, field +from datetime import date, timedelta from typing import Any from .adapter import LiveValueRegistry @@ -76,6 +77,7 @@ CompoundConditionNode, ConditionNode, CountNode, + DateLiteral, EachNode, EachPronoun, ExpectNode, @@ -2064,6 +2066,8 @@ def _evaluate_expression( ) -> Any: if isinstance(expr, NumberLiteral): return expr.value + if isinstance(expr, DateLiteral): + return expr.value if isinstance(expr, BareWord): # If the word matches a symbol, copy its value (§24 line 486). # Otherwise treat the word as a string literal. @@ -2227,7 +2231,19 @@ def _within_tolerance(field_val: Any, tolerance: Any, target: Any) -> bool: """Issue #19 — numeric tolerance comparison: True when |field_val - target| <= tolerance. All three operands must be numbers; a non-number produces a friendly runtime error rather than a Python - TypeError.""" + TypeError. + + Calendar Era (v29) — when both field_val and target are dates, the + tolerance measures a day count instead: |field - target| in days. + """ + if isinstance(field_val, date) and isinstance(target, date): + if isinstance(tolerance, bool) or not isinstance(tolerance, (int, float)): + raise _RuntimeError( + f"'within' on dates needs a numeric day count, but the " + f"amount is {_format_scalar(tolerance)}." + ) + return abs((field_val - target).days) <= tolerance + for label, v in (("value", field_val), ("amount", tolerance), ("target", target)): if isinstance(v, bool) or not isinstance(v, (int, float)): raise _RuntimeError( @@ -2237,8 +2253,10 @@ def _within_tolerance(field_val: Any, tolerance: Any, target: Any) -> bool: return abs(field_val - target) <= tolerance -def _eval_extrema(node: ExtremaNode, symtab: dict[str, SymbolEntry]) -> int | float: - """v25 — evaluate `highest`/`lowest`. Numeric-only; errors on an +def _eval_extrema(node: ExtremaNode, symtab: dict[str, SymbolEntry]) -> int | float | date: + """v25 — evaluate `highest`/`lowest`. Numbers or dates (Calendar Era + v29 extends this to dates: `highest`/`lowest` on a date field or a + list of dates returns the latest/earliest date); errors on an empty list (deliberately asymmetric with `sum []` == 0 — an empty sum is 0, an empty extremum is undefined). Lists never hold DecayingValue elements (only scalar symbols do), so no decay @@ -2252,18 +2270,23 @@ def _eval_extrema(node: ExtremaNode, symtab: dict[str, SymbolEntry]) -> int | fl values = [] for item in the_list: v = item[node.field] + if isinstance(v, date): + values.append(v) + continue if isinstance(v, bool) or not isinstance(v, (int, float)): raise _RuntimeError( - f"'{node.word}' needs numbers. Field '{node.field}' in " + f"'{node.word}' needs numbers or dates. Field '{node.field}' in " f"'{node.target.name}' contains {_format_scalar(v)}." ) values.append(v) else: values = list(the_list) for v in values: + if isinstance(v, date): + continue if isinstance(v, bool) or not isinstance(v, (int, float)): raise _RuntimeError( - f"'{node.word}' needs numbers. '{node.target.name}' contains text." + f"'{node.word}' needs numbers or dates. '{node.target.name}' contains text." ) return max(values) if node.word == "highest" else min(values) @@ -2298,6 +2321,8 @@ def _eval_field(field_node: ASTNode, current_item: Any, symtab) -> Any: def _eval_value(value_node: ASTNode, current_item: Any, symtab) -> Any: if isinstance(value_node, NumberLiteral): return value_node.value + if isinstance(value_node, DateLiteral): + return value_node.value if isinstance(value_node, BareWord): if value_node.word in symtab: val = symtab[value_node.word].value @@ -2345,6 +2370,9 @@ def _eval_arithmetic( """ left = _eval_arithmetic_operand(node.left, symtab, current_item) right = _eval_arithmetic_operand(node.right, symtab, current_item) + # Calendar Era (v29) — date arithmetic dispatch. + if isinstance(left, date) or isinstance(right, date): + return _eval_date_arithmetic(left, right, node.op) if isinstance(left, bool) or not isinstance(left, (int, float)): raise _RuntimeError( f"I can only do arithmetic with numbers, but the left side " @@ -2371,6 +2399,57 @@ def _eval_arithmetic( raise _RuntimeError(f"Unknown arithmetic operator '{node.op}'.") +def _eval_date_arithmetic(left: Any, right: Any, op: str) -> date | int: + """Calendar Era (v29) — date arithmetic. + + date + number = date + N days (returns date) + date - number = date - N days (returns date) + date - date = day count (returns int) + date + date = error + date * / date / = error + Number must be a whole integer. + """ + if op in ("multiplied_by", "divided_by"): + raise _RuntimeError("Dates can't be multiplied or divided.") + + if isinstance(left, date) and isinstance(right, date): + if op == "minus": + return (left - right).days + raise _RuntimeError( + "I can't add two dates — did you mean to subtract? " + "Try: date minus date." + ) + + # One operand is a date, the other must be a number (day offset). + if isinstance(left, date): + the_date, the_number, date_is_left = left, right, True + else: + the_date, the_number, date_is_left = right, left, False + + if isinstance(the_number, bool) or not isinstance(the_number, (int, float)): + raise _RuntimeError( + "Date arithmetic works in whole days — " + f"the offset must be a number, not {_format_scalar(the_number)}." + ) + if the_number != int(the_number): + raise _RuntimeError( + f"Date arithmetic works in whole days — " + f"{_format_scalar(the_number)} isn't a whole number." + ) + days = int(the_number) + + if op == "plus": + return the_date + timedelta(days=days) + if op == "minus": + if date_is_left: + return the_date - timedelta(days=days) + raise _RuntimeError( + "I can't subtract a date from a number. " + "Try: date minus number." + ) + raise _RuntimeError(f"Unknown arithmetic operator '{op}' on dates.") + + def _eval_arithmetic_operand( operand: ASTNode, symtab: dict[str, SymbolEntry], @@ -2404,6 +2483,16 @@ def _apply_op(op: str, a: Any, b: Any) -> bool: # NOTE: this function is duplicated in listener.py to avoid a circular # import between interpreter and listener. Both copies must stay in # sync when adding new operators. + if op in ("above", "below", "not_above", "not_below"): + # Calendar Era (v29) — dates support ordered comparison natively, + # but a date compared against a number or string must raise a + # friendly runtime error instead of a Python TypeError. + if (isinstance(a, date) or isinstance(b, date)) and type(a) is not type(b): + other = b if isinstance(a, date) else a + kind = "a number" if isinstance(other, (int, float)) and not isinstance(other, bool) else "text" + raise _RuntimeError( + f"I can't compare a date to {kind}. Both sides must be dates." + ) if op == "is": return a == b if op == "above": @@ -2486,6 +2575,8 @@ def _infer_type_and_schema(v: Any) -> tuple[str, dict[str, str] | None]: return "number", None if isinstance(v, str): return "string", None + if isinstance(v, date): + return "date", None if isinstance(v, dict): return "record", {k: _scalar_type(val) for k, val in v.items()} if isinstance(v, list): @@ -2499,6 +2590,8 @@ def _infer_type_and_schema(v: Any) -> tuple[str, dict[str, str] | None]: return "list_of_records", None if isinstance(first, str): return "list_of_strings", None + if isinstance(first, date): + return "list_of_dates", None return "list_of_numbers", None return "unknown", None @@ -2510,6 +2603,8 @@ def _scalar_type(v: Any) -> str: return "number" if isinstance(v, str): return "string" + if isinstance(v, date): + return "date" if isinstance(v, dict): return "record" return "unknown" @@ -2545,6 +2640,8 @@ def _format_scalar(v: Any) -> str: return str(v) if isinstance(v, str): return v + if isinstance(v, date): + return v.isoformat() # "2025-07-01" if isinstance(v, dict): return _format_record(v) return str(v) diff --git a/src/liminate/lexer.py b/src/liminate/lexer.py index 3770dd6..82d703e 100644 --- a/src/liminate/lexer.py +++ b/src/liminate/lexer.py @@ -70,6 +70,7 @@ # hyphen (`total-dollars`) is NOT a number. Subtraction uses the `minus` word # operator, not the `-` symbol, so this does not collide with arithmetic. _NUMBER_RE = re.compile(r"^-?\d+(?:\.\d+)?$") +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") class LexError(Exception): @@ -273,6 +274,8 @@ def _classify(cleaned: list[tuple[str, int, bool]]) -> list[Token]: tokens.append(Token(TokenType.DECLARATION, word, pos)) elif _NUMBER_RE.match(word): tokens.append(Token(TokenType.NUMBER, word, pos)) + elif _DATE_RE.match(word): + tokens.append(Token(TokenType.DATE, word, pos)) else: tokens.append(Token(TokenType.UNKNOWN, word, pos)) j += 1 diff --git a/src/liminate/listener.py b/src/liminate/listener.py index 70ff958..7b549dd 100644 --- a/src/liminate/listener.py +++ b/src/liminate/listener.py @@ -27,6 +27,7 @@ import copy from dataclasses import dataclass, field +from datetime import date from queue import Empty, Queue from typing import Any, Iterator @@ -808,6 +809,14 @@ def _apply_op(op: str, a: Any, b: Any) -> bool: NOTE: this function is duplicated in interpreter.py. Both copies must stay in sync when adding new operators. """ + if op in ("above", "below", "not_above", "not_below"): + # Calendar Era (v29) — mirror interpreter.py's date-comparison guard. + if (isinstance(a, date) or isinstance(b, date)) and type(a) is not type(b): + other = b if isinstance(a, date) else a + kind = "a number" if isinstance(other, (int, float)) and not isinstance(other, bool) else "text" + raise _RuntimeError( + f"I can't compare a date to {kind}. Both sides must be dates." + ) if op == "is": return a == b if op == "above": diff --git a/src/liminate/parser.py b/src/liminate/parser.py index 19404e3..e954f2f 100644 --- a/src/liminate/parser.py +++ b/src/liminate/parser.py @@ -41,6 +41,7 @@ import re from dataclasses import dataclass, field +from datetime import date from typing import Any from .result import LiminateResult, ResultStatus @@ -68,6 +69,15 @@ class NumberLiteral(ASTNode): value: int | float +@dataclass +class DateLiteral(ASTNode): + """Calendar Era (v29) — a date literal. Stored as a Python + datetime.date. Validated as a real calendar date at parse time + (2025-02-30 is rejected). Enters every value position through + _parse_atom, parallel to NumberLiteral.""" + value: date + + @dataclass class BareWord(ASTNode): """An unknown word in a value position (after `with`, after `as`, list @@ -1390,13 +1400,19 @@ def _try_consume_starting_until( ): stream.consume() # eat `starting` date_tok = stream.consume() - if date_tok is None or date_tok.type is not TokenType.QUOTED_STRING: + if date_tok is None or date_tok.type not in ( + TokenType.QUOTED_STRING, TokenType.DATE, + ): got = date_tok.value if date_tok else "end of line" raise _ParseError( - f"'starting' needs a quoted date — try: " - f'starting "2025-07-01". Got: {got}.' + f"'starting' needs a date — try: " + f'starting "2025-07-01" or starting 2025-07-01. Got: {got}.' ) - _validate_iso_date(date_tok.value, "starting") + if date_tok.type is TokenType.QUOTED_STRING: + _validate_iso_date(date_tok.value, "starting") + else: + # Lexer-validated shape; parse-time calendar validation. + _validate_calendar_date(date_tok.value) starting_date = date_tok.value peek = stream.peek() @@ -1407,13 +1423,19 @@ def _try_consume_starting_until( ): stream.consume() # eat `until` date_tok = stream.consume() - if date_tok is None or date_tok.type is not TokenType.QUOTED_STRING: + if date_tok is None or date_tok.type not in ( + TokenType.QUOTED_STRING, TokenType.DATE, + ): got = date_tok.value if date_tok else "end of line" raise _ParseError( - f"'until' needs a quoted date — try: " - f'until "2025-12-31". Got: {got}.' + f"'until' needs a date — try: " + f'until "2025-12-31" or until 2025-12-31. Got: {got}.' ) - _validate_iso_date(date_tok.value, "until") + if date_tok.type is TokenType.QUOTED_STRING: + _validate_iso_date(date_tok.value, "until") + else: + # Lexer-validated shape; parse-time calendar validation. + _validate_calendar_date(date_tok.value) until_date = date_tok.value return starting_date, until_date @@ -1435,6 +1457,21 @@ def _validate_iso_date(date_str: str, keyword: str) -> None: ) +def _validate_calendar_date(date_str: str) -> date: + """Calendar Era (v29) — parse and validate a YYYY-MM-DD string as + a real calendar date. Returns a datetime.date on success. Raises + _ParseError with a friendly message on invalid dates (2025-02-30, + 2025-13-01, etc.). The lexer has already validated the shape + (^\\d{4}-\\d{2}-\\d{2}$); this validates the content.""" + try: + return date.fromisoformat(date_str) + except ValueError: + raise _ParseError( + f"'{date_str}' isn't a valid calendar date. " + f"Dates use the format YYYY-MM-DD — for example, 2025-07-01." + ) + + def _try_consume_because(stream: TokenStream) -> str | None: """Consume an optional `because ""` clause at the end of a verb statement (Meta-Structural Era batch 2, MS-Q2). @@ -3142,6 +3179,11 @@ def _parse_within_tolerance(stream: TokenStream) -> ASTNode: ) if tok.type is TokenType.NUMBER: return NumberLiteral(value=_parse_number(tok.value)) + if tok.type is TokenType.DATE: + raise _ParseError( + "'within' needs a numeric amount (the number of days), " + f"not a date. Got: {tok.value}." + ) if tok.type is TokenType.UNKNOWN: return BareWord(word=tok.value) cat = reserved_category(tok.value) @@ -3272,6 +3314,8 @@ def _parse_atom(stream: TokenStream) -> ASTNode: raise _ParseError("I expected a value here.") if tok.type is TokenType.NUMBER: return NumberLiteral(value=_parse_number(tok.value)) + if tok.type is TokenType.DATE: + return DateLiteral(value=_validate_calendar_date(tok.value)) if tok.type is TokenType.UNKNOWN: # v2b §77: ` of ` field-access value expression. # v25: this branch also covers tombstoned words (e.g. `combine`) @@ -3431,6 +3475,8 @@ def _consume_parameter_arg(stream: TokenStream, *, comp_name: str) -> str | ASTN ) if tok.type is TokenType.NUMBER: return NumberLiteral(value=_parse_number(tok.value)) + if tok.type is TokenType.DATE: + return DateLiteral(value=_validate_calendar_date(tok.value)) if tok.type is TokenType.QUOTED_STRING: return QuotedString(content=tok.value) if tok.type is not TokenType.UNKNOWN: diff --git a/src/liminate/renderer.py b/src/liminate/renderer.py index a6de01f..f127d5e 100644 --- a/src/liminate/renderer.py +++ b/src/liminate/renderer.py @@ -36,6 +36,7 @@ CompoundConditionNode, ConditionNode, CountNode, + DateLiteral, EachNode, EachPronoun, ExpectNode, @@ -144,6 +145,9 @@ def _render_node(node: ASTNode) -> str: return f"about {node.topic}" if isinstance(node, NumberLiteral): return _fmt_number(node.value) + if isinstance(node, DateLiteral): + # Calendar Era (v29) — dates render bare, no quotes. + return node.value.isoformat() # "2025-07-01" if isinstance(node, BareWord): # v2c §90: conditional quoting — quote multi-word or reserved-word # values to preserve round-trip integrity. Single-word non-reserved @@ -187,7 +191,7 @@ def _render_node(node: ASTNode) -> str: # re-parses as a copy reference, changing the statement's meaning # and breaking at runtime (issue #18). NumberLiteral/BareWord were # already routed through `with`; QuotedString was the gap. - if isinstance(node.value, (NumberLiteral, BareWord, QuotedString)): + if isinstance(node.value, (NumberLiteral, DateLiteral, BareWord, QuotedString)): return f"remember {art} {desc} called {node.name} with {render(node.value)}" return f"remember {art} {desc} called {node.name} from {render(node.value)}" if isinstance(node, RememberListNode): diff --git a/src/liminate/run.py b/src/liminate/run.py index d63204d..e48a2d8 100644 --- a/src/liminate/run.py +++ b/src/liminate/run.py @@ -28,7 +28,7 @@ from .adapter import DomainPack, LiveValueRegistry from .analyzer import SymbolEntry, detect_contradictions -from .interpreter import HandlerTable, execute +from .interpreter import HandlerTable, _infer_type_and_schema, execute from .lexer import LexError, leading_indent, tokenize from .listener import listen from .parser import _ParseError, parse, parse_about, parse_when_block @@ -450,6 +450,7 @@ def run( enter_phase2: bool = True, on_result: ResultSink | None = None, on_blank: Callable[[], None] | None = None, + inject: dict[str, Any] | None = None, ) -> ContractResult: """Execute a Liminate program from source text and return structured results. Performs NO I/O — no print, no input, no file reads, no @@ -480,12 +481,22 @@ def run( amber itself in that case. - `on_blank`: optional callback invoked for each blank/comment line skipped at top level (CLI uses it for quiet-mode blank mirroring). + - `inject`: Calendar Era (v29) — product-layer values (e.g. `today`) + bound into the symbol table before Phase 1 begins, so every source + line can reference them like any other name. Additive: a program + that never references an injected name is unaffected by its + presence. Each produced `LiminateResult` carries `line` (1-based source line) and `source` (raw line text) so embedders can serialize per-line without re-running the loop. """ session = Session(domain_packs=domain_packs) + for name, value in (inject or {}).items(): + type_, schema = _infer_type_and_schema(value) + session.symtab[name] = SymbolEntry( + name=name, value=value, type=type_, schema=schema, + ) results: list[LiminateResult] = [] amber_statuses = ( diff --git a/src/liminate/vocabulary.py b/src/liminate/vocabulary.py index 67357ca..fb93eaf 100644 --- a/src/liminate/vocabulary.py +++ b/src/liminate/vocabulary.py @@ -108,6 +108,10 @@ class TokenType(Enum): # distinct from verbs/connectives/operators. `about` is the first # declaration — it declares the program's topic as inert metadata. DECLARATION = "DECLARATION" + # Calendar Era (v29): a bare ISO 8601 date literal (YYYY-MM-DD). + # Recognized by lexer shape, not vocabulary lookup — same accounting + # as NUMBER. Not in ALL_RESERVED; the 60-word count is unaffected. + DATE = "DATE" @dataclass diff --git a/tests/test_date.py b/tests/test_date.py new file mode 100644 index 0000000..4310c19 --- /dev/null +++ b/tests/test_date.py @@ -0,0 +1,572 @@ +"""Calendar Era (v29) — dates as a first-class value type. + +Covers the full pipeline for the new `date` scalar type: lexer +classification, parser literal + calendar validation, `starting`/`until` +bare-date support, interpreter storage/display/comparison/arithmetic/ +tolerance, `today` injection, analyzer type-checking, and renderer +round-trips. Zero new reserved words — `TokenType.DATE` is a literal +type, accounted the same way as `TokenType.NUMBER`. +""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from liminate.cli import Session +from liminate.lexer import tokenize +from liminate.parser import ( + DateLiteral, + RememberListNode, + RememberRecordNode, + RememberValueNode, + parse, +) +from liminate.renderer import render +from liminate.result import ResultStatus +from liminate.run import run +from liminate.vocabulary import ALL_RESERVED, TokenType + + +def run_lines(lines): + session = Session() + results = [session.run_line(line) for line in lines] + return session, results + + +# --------------------------------------------------------------------------- +# 8a — Lexer +# --------------------------------------------------------------------------- + + +def test_lexer_classifies_bare_date(): + toks = tokenize("2025-07-01") + assert len(toks) == 1 + assert toks[0].type is TokenType.DATE + assert toks[0].value == "2025-07-01" + + +def test_lexer_accepts_shape_defers_content_validation(): + # The lexer only checks shape; 2025-13-01 lexes as DATE even though + # month 13 isn't real — the parser rejects the content. + toks = tokenize("2025-13-01") + assert toks[0].type is TokenType.DATE + + +def test_lexer_quoted_date_stays_quoted_string(): + toks = tokenize('"2025-07-01"') + assert toks[0].type is TokenType.QUOTED_STRING + assert toks[0].value == "2025-07-01" + + +def test_lexer_plain_number_not_date(): + toks = tokenize("2025") + assert toks[0].type is TokenType.NUMBER + + +def test_lexer_hyphenated_word_not_date(): + toks = tokenize("due-date") + assert toks[0].type is TokenType.UNKNOWN + + +def test_lexer_extra_hyphens_not_date(): + toks = tokenize("2025-07-01-extra") + assert toks[0].type is TokenType.UNKNOWN + + +def test_vocabulary_count_unaffected_by_date(): + # DATE is a literal type, not vocabulary — ALL_RESERVED is untouched. + assert "DATE" not in ALL_RESERVED + assert isinstance(len(ALL_RESERVED), int) # sanity: no crash importing + + +# --------------------------------------------------------------------------- +# 8b — Parser: DateLiteral +# --------------------------------------------------------------------------- + + +def test_parse_remember_date_value(): + node = parse(tokenize("remember a date called due-date with 2025-07-01")) + assert isinstance(node, RememberValueNode) + assert isinstance(node.value, DateLiteral) + assert node.value.value == date(2025, 7, 1) + + +def test_parse_remember_record_date_field(): + node = parse( + tokenize("remember an order called o1 with filed-date as 2025-07-01") + ) + assert isinstance(node, RememberRecordNode) + field_name, field_value = node.fields[0] + assert field_name == "filed-date" + assert isinstance(field_value, DateLiteral) + assert field_value.value == date(2025, 7, 1) + + +def test_parse_remember_list_of_dates(): + node = parse( + tokenize( + "remember a list called deadlines with 2025-07-01 and 2025-08-01" + ) + ) + assert isinstance(node, RememberListNode) + assert len(node.items) == 2 + assert all(isinstance(i, DateLiteral) for i in node.items) + assert node.items[0].value == date(2025, 7, 1) + assert node.items[1].value == date(2025, 8, 1) + + +def test_parse_invalid_calendar_date_february_30(): + result = parse(tokenize("remember a date called d with 2025-02-30")) + assert result.status is ResultStatus.ERROR_PARSE + assert "valid calendar date" in result.message + + +def test_parse_invalid_calendar_date_month_13(): + result = parse(tokenize("remember a date called d with 2025-13-01")) + assert result.status is ResultStatus.ERROR_PARSE + assert "valid calendar date" in result.message + + +# --------------------------------------------------------------------------- +# 8c — starting/until with bare dates +# --------------------------------------------------------------------------- + + +def test_starting_accepts_bare_date(): + node = parse(tokenize("starting 2025-07-01 require x is above 10")) + assert not hasattr(node, "status"), getattr(node, "message", node) + assert node.starting_date == "2025-07-01" + + +def test_until_accepts_bare_date(): + node = parse(tokenize("until 2025-12-31 forbid x is above 50")) + assert not hasattr(node, "status"), getattr(node, "message", node) + assert node.until_date == "2025-12-31" + + +def test_starting_and_until_both_bare_dates(): + node = parse( + tokenize("starting 2025-07-01 until 2025-12-31 require x is above 10") + ) + assert not hasattr(node, "status"), getattr(node, "message", node) + assert node.starting_date == "2025-07-01" + assert node.until_date == "2025-12-31" + + +def test_starting_still_accepts_quoted_date(): + node = parse(tokenize('starting "2025-07-01" require x is above 10')) + assert not hasattr(node, "status"), getattr(node, "message", node) + assert node.starting_date == "2025-07-01" + + +# --------------------------------------------------------------------------- +# 8d — Interpreter: storage and display +# --------------------------------------------------------------------------- + + +def test_date_storage_and_display(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "show d", + ]) + for r in results: + assert r.status is ResultStatus.SUCCESS, r.message + assert session.symtab["d"].type == "date" + assert session.symtab["d"].value == date(2025, 7, 1) + assert results[-1].output == ["2025-07-01"] + + +def test_date_record_field_display(): + session, results = run_lines([ + "remember an order called o1 with filed-date as 2025-07-01", + "show filed-date of o1", + ]) + for r in results: + assert r.status is ResultStatus.SUCCESS, r.message + assert results[-1].output == ["2025-07-01"] + + +# --------------------------------------------------------------------------- +# 8e — Interpreter: date comparison +# --------------------------------------------------------------------------- + + +def test_date_below_passes(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "require d is below 2025-12-31", + ]) + assert results[-1].status is ResultStatus.SUCCESS, results[-1].message + + +def test_date_above_passes(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "require d is above 2025-01-01", + ]) + assert results[-1].status is ResultStatus.SUCCESS, results[-1].message + + +def test_date_equal_to_passes(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "require d is equal to 2025-07-01", + ]) + assert results[-1].status is ResultStatus.SUCCESS, results[-1].message + + +def test_date_forbid_triggers(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "forbid d is above 2025-06-30", + ]) + assert results[-1].status is ResultStatus.PROHIBITION_VIOLATED + + +def test_date_vs_number_is_semantic_error(): + # Both operands individually pass _require_comparable (number, date + # are each an accepted category); the mismatch is only caught by the + # runtime guard in _apply_op, which interpreter.py surfaces as + # ERROR_SEMANTIC (this codebase's convention for _RuntimeError — + # see the divide-by-zero test in test_arithmetic.py). + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "require d is above 50", + ]) + assert results[-1].status is ResultStatus.ERROR_SEMANTIC + assert "compare a date" in results[-1].message + + +def test_date_vs_quoted_string_is_semantic_error(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + 'require d is above "2025-01-01"', + ]) + assert results[-1].status is ResultStatus.ERROR_SEMANTIC + + +# --------------------------------------------------------------------------- +# 8f — Interpreter: date lists +# --------------------------------------------------------------------------- + + +def test_homogeneous_date_list_type(): + session, results = run_lines([ + "remember a list called deadlines with 2025-07-01 and 2025-08-01", + ]) + assert results[-1].status is ResultStatus.SUCCESS, results[-1].message + assert session.symtab["deadlines"].type == "list_of_dates" + + +def test_mixed_date_and_number_list_is_semantic_error(): + session, results = run_lines([ + "remember a list called mixed with 2025-07-01 and 5", + ]) + assert results[-1].status is ResultStatus.ERROR_SEMANTIC + + +def test_sort_records_by_date_field(): + session, results = run_lines([ + "remember an order called o1 with filed-date as 2025-08-01", + "remember an order called o2 with filed-date as 2025-06-01", + "remember an order called o3 with filed-date as 2025-07-01", + "remember a list called orders with o1 and o2 and o3", + "sort the orders by filed-date", + "each the orders show filed-date", + ]) + for r in results: + assert r.status is ResultStatus.SUCCESS, r.message + assert results[-1].output == ["2025-06-01", "2025-07-01", "2025-08-01"] + + +def test_highest_date_field(): + session, results = run_lines([ + "remember an order called o1 with filed-date as 2025-08-01", + "remember an order called o2 with filed-date as 2025-06-01", + "remember a list called orders with o1 and o2", + "remember a date called latest from highest filed-date of orders", + "show latest", + ]) + for r in results: + assert r.status is ResultStatus.SUCCESS, r.message + assert results[-1].output == ["2025-08-01"] + + +def test_lowest_date_field(): + session, results = run_lines([ + "remember an order called o1 with filed-date as 2025-08-01", + "remember an order called o2 with filed-date as 2025-06-01", + "remember a list called orders with o1 and o2", + "remember a date called earliest from lowest filed-date of orders", + "show earliest", + ]) + for r in results: + assert r.status is ResultStatus.SUCCESS, r.message + assert results[-1].output == ["2025-06-01"] + + +def test_date_list_includes(): + session, results = run_lines([ + "remember a list called deadlines with 2025-07-01 and 2025-08-01", + "require deadlines includes 2025-07-01", + ]) + assert results[-1].status is ResultStatus.SUCCESS, results[-1].message + + +def test_add_date_to_date_list(): + session, results = run_lines([ + "remember a list called deadlines with 2025-07-01 and 2025-08-01", + "add 2025-10-01 to deadlines", + ]) + assert results[-1].status is ResultStatus.SUCCESS, results[-1].message + assert session.symtab["deadlines"].value == [ + date(2025, 7, 1), date(2025, 8, 1), date(2025, 10, 1), + ] + + +# --------------------------------------------------------------------------- +# 8g — Interpreter: date arithmetic +# --------------------------------------------------------------------------- + + +def test_date_plus_number(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "remember a date called later from d plus 30", + "show later", + ]) + for r in results: + assert r.status is ResultStatus.SUCCESS, r.message + assert results[-1].output == ["2025-07-31"] + + +def test_date_minus_number(): + session, results = run_lines([ + "remember a date called d with 2025-07-31", + "remember a date called earlier from d minus 30", + "show earlier", + ]) + for r in results: + assert r.status is ResultStatus.SUCCESS, r.message + assert results[-1].output == ["2025-07-01"] + + +def test_date_minus_date_yields_day_count(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "remember a value called gap from d minus 2025-01-01", + "show gap", + ]) + for r in results: + assert r.status is ResultStatus.SUCCESS, r.message + assert results[-1].output == ["181"] + + +def test_date_plus_date_is_semantic_error(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "remember a date called d2 with 2025-01-01", + "remember a value called bad from d plus d2", + ]) + assert results[-1].status is ResultStatus.ERROR_SEMANTIC + assert "can't add two dates" in results[-1].message + + +def test_date_multiplied_by_is_semantic_error(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "remember a value called bad from d multiplied by 2", + ]) + assert results[-1].status is ResultStatus.ERROR_SEMANTIC + assert "multiplied or divided" in results[-1].message + + +def test_date_plus_fractional_day_is_semantic_error(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "remember a value called bad from d plus 30.5", + ]) + assert results[-1].status is ResultStatus.ERROR_SEMANTIC + assert "whole" in results[-1].message + + +def test_number_minus_date_is_semantic_error(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "remember a value called bad from 5 minus d", + ]) + assert results[-1].status is ResultStatus.ERROR_SEMANTIC + + +def test_remember_date_from_field_plus_number(): + session, results = run_lines([ + "remember an order called o1 with filing-date as 2025-07-01", + "remember a date called deadline from filing-date of o1 plus 30", + "show deadline", + ]) + for r in results: + assert r.status is ResultStatus.SUCCESS, r.message + assert results[-1].output == ["2025-07-31"] + + +# --------------------------------------------------------------------------- +# 8h — Interpreter: date tolerance (within) +# --------------------------------------------------------------------------- + + +def test_within_date_tolerance_passes(): + session, results = run_lines([ + "remember a date called d with 2025-07-15", + "require d is within 30 of 2025-07-01", + ]) + assert results[-1].status is ResultStatus.SUCCESS, results[-1].message + + +def test_within_date_tolerance_fails(): + session, results = run_lines([ + "remember a date called d with 2025-09-01", + "require d is within 30 of 2025-07-01", + ]) + assert results[-1].status is ResultStatus.REQUIREMENT_NOT_MET + + +def test_within_date_tolerance_exact_match(): + session, results = run_lines([ + "remember a date called d with 2025-07-01", + "require d is within 0 of 2025-07-01", + ]) + assert results[-1].status is ResultStatus.SUCCESS, results[-1].message + + +def test_within_tolerance_rejects_bare_date_at_parse_time(): + result = parse(tokenize("require x is within 2025-07-01 of target")) + assert result.status is ResultStatus.ERROR_PARSE + assert "within" in result.message + + +# --------------------------------------------------------------------------- +# 8i — Interpreter: today injection +# --------------------------------------------------------------------------- + + +def test_today_injection_resolves_in_condition(): + source = ( + "remember a date called due-date with 2025-07-01\n" + "require due-date is below today\n" + ) + result = run(source, inject={"today": date(2026, 1, 1)}, enter_phase2=False) + assert not result.had_error + assert result.results[-1].status is ResultStatus.SUCCESS + + +def test_today_injection_fails_condition_when_in_past(): + # REQUIREMENT_NOT_MET means "the data violates a rule", not a program + # error — it deliberately doesn't set had_error (same as any other + # failed `require`; see run.Session.record_result). + source = ( + "remember a date called due-date with 2025-07-01\n" + "require due-date is below today\n" + ) + result = run(source, inject={"today": date(2020, 1, 1)}, enter_phase2=False) + assert result.results[-1].status is ResultStatus.REQUIREMENT_NOT_MET + + +def test_no_injection_today_is_unresolved_name(): + source = "remember a number called x with 5\nrequire x is below today\n" + result = run(source, enter_phase2=False) + assert result.had_error + last = result.results[-1] + assert last.status is ResultStatus.ERROR_SEMANTIC + assert "today" in last.message + + +def test_inject_additive_when_unreferenced(): + # A program that never mentions `today` is unaffected by its presence. + source = "remember a value called x with 5\nshow x\n" + result = run(source, inject={"today": date(2025, 7, 4)}, enter_phase2=False) + assert not result.had_error + assert result.results[-1].output == ["5"] + + +# --------------------------------------------------------------------------- +# 8j — Renderer round-trips +# --------------------------------------------------------------------------- + + +def _roundtrip(line: str) -> None: + ast1 = parse(tokenize(line)) + assert not hasattr(ast1, "status"), getattr(ast1, "message", ast1) + rendered = render(ast1) + assert rendered == line + ast2 = parse(tokenize(rendered)) + assert not hasattr(ast2, "status"), getattr(ast2, "message", ast2) + assert ast1 == ast2 + + +def test_render_date_literal_bare(): + node = DateLiteral(value=date(2025, 7, 1)) + assert render(node) == "2025-07-01" + + +@pytest.mark.parametrize( + "src", + [ + "remember a date called d with 2025-07-01", + "remember an order called o1 with filed-date as 2025-07-01", + "remember a list called deadlines with 2025-07-01 and 2025-08-01", + "require d is below 2025-12-31", + "require d is within 30 of 2025-07-01", + "remember a date called later from d plus 30", + ], +) +def test_date_programs_round_trip(src): + _roundtrip(src) + + +def test_starting_bare_date_round_trips_as_quoted_canonical_form(): + # starting/until always canonicalize to quoted form, whether the + # source used a bare date or a quoted one. + node = parse(tokenize("starting 2025-07-01 require x is above 10")) + rendered = render(node) + assert rendered == 'starting "2025-07-01" require x is above 10' + node2 = parse(tokenize(rendered)) + assert node2 == node + + +# --------------------------------------------------------------------------- +# 8k — Backward compatibility +# --------------------------------------------------------------------------- + + +def test_quoted_date_stays_string_type(): + session, results = run_lines([ + 'remember a value called s with "2025-07-01"', + "show s", + ]) + for r in results: + assert r.status is ResultStatus.SUCCESS, r.message + assert session.symtab["s"].type == "string" + assert session.symtab["s"].value == "2025-07-01" + assert results[-1].output == ["2025-07-01"] + + +def test_existing_numeric_arithmetic_unaffected(): + session, results = run_lines([ + "remember a value called price with 100", + "remember a value called tax with 15", + "remember a value called total from price plus tax", + "show total", + ]) + for r in results: + assert r.status is ResultStatus.SUCCESS, r.message + assert results[-1].output == ["115"] + + +def test_existing_string_list_unaffected(): + session, results = run_lines([ + "remember a list called tags with \"a\" and \"b\"", + ]) + assert results[-1].status is ResultStatus.SUCCESS, results[-1].message + assert session.symtab["tags"].type == "list_of_strings" diff --git a/tests/test_vocabulary.py b/tests/test_vocabulary.py index 5a23b1b..b4f6224 100644 --- a/tests/test_vocabulary.py +++ b/tests/test_vocabulary.py @@ -206,6 +206,8 @@ def test_token_type_enum_members(): "QUOTED_STRING", # Meta-Structural Era: `about` and future declarations. "DECLARATION", + # Calendar Era (v29): a bare ISO 8601 date literal. + "DATE", }