From 8963d13dd0edce128e44a5985b3c16cdb9982577 Mon Sep 17 00:00:00 2001 From: rmichaelthomas Date: Sat, 12 Sep 2026 01:38:51 -0700 Subject: [PATCH] =?UTF-8?q?fix(renderer):=20=C2=A782's=20escape=20hatch=20?= =?UTF-8?q?has=20to=20survive=20canonical=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `require total is "large"`, where `large` is a defined predicate, rendered canonically as `require total is large` — which re-parses as a predicate application, not a string equality. Same text, different program. The two halves were each right on their own. v31 §82 makes quoting the escape hatch for a value that collides with a predicate name, and the parser implements it exactly: the `is ` branch tests for an UNKNOWN token, so a QUOTED_STRING never reaches it. v2c §90's conditional quoting then drops quotes around any "safe single word", where safe means *not reserved* — and a predicate name is never reserved. It is declared. Neither checkpoint could see the other, and the gap between them is a rendering that means something else. The fix follows the arrangement v2d §96 already reached for composition-call arguments, which are name-resolvable in the same way and for which a QuotedString has always kept its quotes. The parser is the only stage that holds the predicate table, so it marks the collision on the literal and the renderer honours the mark. The flag is `compare=False`, matching this file's idiom for inert metadata: two `"large"` literals are the same value whether or not one of them must be written with quotes to stay one, so `parse(render(ast)) == ast` still means what it meant. Scoped to the collision. A quoted single word that shadows nothing still normalises to bare — asserted, so a later widening has to be deliberate. Corpus case added, because this is precisely the class of defect the behavioural corpus exists to catch: `require total is large` and `require total is "large"` are one character apart and produce different ASTs, which no word-list gate and no text comparison can see. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E6qcDj1e1dEYvc8jLnpGZy --- grammar/errors.json | 308 ++++++++++++------------- grammar/rules.json | 96 ++++---- src/liminate/parser.py | 25 ++ src/liminate/renderer.py | 10 + tests/fixtures/conformance-0.18.1.json | 34 +++ tests/fixtures/conformance_corpus.txt | 9 + tests/test_define.py | 32 +++ 7 files changed, 312 insertions(+), 202 deletions(-) diff --git a/grammar/errors.json b/grammar/errors.json index a9d4739..2128f2c 100644 --- a/grammar/errors.json +++ b/grammar/errors.json @@ -2137,7 +2137,7 @@ { "id": "parser._ParseError.parse", "class": "_ParseError", - "source": "parser.py:1100", + "source": "parser.py:1120", "raised_in": "parse", "template": "I didn't expect '{unexpected.value}' here.", "slots": [ @@ -2147,7 +2147,7 @@ { "id": "parser._ParseError.parse_about-1", "class": "_ParseError", - "source": "parser.py:1153", + "source": "parser.py:1173", "raised_in": "parse_about", "template": "'about' needs a topic \u2014 try: about \"expense authorization\" or about expense-authorization.", "slots": [] @@ -2155,7 +2155,7 @@ { "id": "parser._ParseError.parse_about-2", "class": "_ParseError", - "source": "parser.py:1162", + "source": "parser.py:1182", "raised_in": "parse_about", "template": "I didn't expect anything after the quoted topic in 'about'. Try: about \"{tokens[1].value}\".", "slots": [ @@ -2165,7 +2165,7 @@ { "id": "parser._ParseError.parse_when_block-1", "class": "_ParseError", - "source": "parser.py:1265", + "source": "parser.py:1285", "raised_in": "parse_when_block", "template": "I expected a condition after 'when'. Try: when is above .", "slots": [] @@ -2173,7 +2173,7 @@ { "id": "parser._ParseError.parse_when_block-2", "class": "_ParseError", - "source": "parser.py:1280", + "source": "parser.py:1300", "raised_in": "parse_when_block", "template": "I expected a guard condition after 'unless'.", "slots": [] @@ -2181,7 +2181,7 @@ { "id": "parser._ParseError.parse_when_block-3", "class": "_ParseError", - "source": "parser.py:1300", + "source": "parser.py:1320", "raised_in": "parse_when_block", "template": "'from' attribution on a 'when' header needs the 'inherited' prefix \u2014 try: inherited when from .", "slots": [] @@ -2189,7 +2189,7 @@ { "id": "parser._ParseError.parse_when_block-4", "class": "_ParseError", - "source": "parser.py:1313", + "source": "parser.py:1333", "raised_in": "parse_when_block", "template": "I didn't expect '{unexpected.value}' in the 'when' header.", "slots": [ @@ -2199,7 +2199,7 @@ { "id": "parser._ParseError._parse_operation_sequence", "class": "_ParseError", - "source": "parser.py:1427", + "source": "parser.py:1447", "raised_in": "_parse_operation_sequence", "template": "I expected a verb after 'then'. Try: then .", "slots": [] @@ -2207,7 +2207,7 @@ { "id": "parser._ParseError._try_consume_starting_until-1", "class": "_ParseError", - "source": "parser.py:1471", + "source": "parser.py:1491", "raised_in": "_try_consume_starting_until", "template": "'starting' needs a date \u2014 try: starting \"2025-07-01\" or starting 2025-07-01. Got: {got}.", "slots": [ @@ -2217,7 +2217,7 @@ { "id": "parser._ParseError._try_consume_starting_until-2", "class": "_ParseError", - "source": "parser.py:1494", + "source": "parser.py:1514", "raised_in": "_try_consume_starting_until", "template": "'until' needs a date \u2014 try: until \"2025-12-31\" or until 2025-12-31. Got: {got}.", "slots": [ @@ -2227,7 +2227,7 @@ { "id": "parser._ParseError._validate_iso_date", "class": "_ParseError", - "source": "parser.py:1518", + "source": "parser.py:1538", "raised_in": "_validate_iso_date", "template": "'{keyword}' needs an ISO 8601 date (YYYY-MM-DD) \u2014 try: {keyword} \"2025-07-01\". Got: \"{date_str}\".", "slots": [ @@ -2239,7 +2239,7 @@ { "id": "parser._ParseError._validate_calendar_date", "class": "_ParseError", - "source": "parser.py:1533", + "source": "parser.py:1553", "raised_in": "_validate_calendar_date", "template": "'{date_str}' isn't a valid calendar date. Dates use the format YYYY-MM-DD \u2014 for example, 2025-07-01.", "slots": [ @@ -2249,7 +2249,7 @@ { "id": "parser._ParseError._try_consume_because", "class": "_ParseError", - "source": "parser.py:1561", + "source": "parser.py:1581", "raised_in": "_try_consume_because", "template": "'because' needs a quoted rationale \u2014 try: because \"your reason here\". Got: {got}.", "slots": [ @@ -2259,7 +2259,7 @@ { "id": "parser._ParseError._try_consume_unless_exception", "class": "_ParseError", - "source": "parser.py:1590", + "source": "parser.py:1610", "raised_in": "_try_consume_unless_exception", "template": "I expected a condition after 'unless'.", "slots": [] @@ -2267,7 +2267,7 @@ { "id": "parser._ParseError._try_consume_inherited_from-1", "class": "_ParseError", - "source": "parser.py:1617", + "source": "parser.py:1637", "raised_in": "_try_consume_inherited_from", "template": "'from' in an inherited statement needs an agent name \u2014 try: inherited ... from .", "slots": [] @@ -2275,7 +2275,7 @@ { "id": "parser._ParseError._try_consume_inherited_from-2", "class": "_ParseError", - "source": "parser.py:1622", + "source": "parser.py:1642", "raised_in": "_try_consume_inherited_from", "template": "Agent names can't have spaces. Try a hyphenated name like '{_hyphenate(agent_tok.value)}' instead.", "slots": [ @@ -2285,7 +2285,7 @@ { "id": "parser._ParseError._try_consume_inherited_from-3", "class": "_ParseError", - "source": "parser.py:1629", + "source": "parser.py:1649", "raised_in": "_try_consume_inherited_from", "template": "The word '{agent_tok.value}' is reserved in Liminate \u2014 it's used as a {cat}. Please use a name for the agent, like 'agent-compliance'.", "slots": [ @@ -2296,7 +2296,7 @@ { "id": "parser._ParseError._try_consume_inherited_from-4", "class": "_ParseError", - "source": "parser.py:1634", + "source": "parser.py:1654", "raised_in": "_try_consume_inherited_from", "template": "I expected an agent name after 'from', not '{agent_tok.value}'.", "slots": [ @@ -2306,7 +2306,7 @@ { "id": "parser._ParseError._parse_one_operation_inner-1", "class": "_ParseError", - "source": "parser.py:1719", + "source": "parser.py:1739", "raised_in": "_parse_one_operation_inner", "template": "I expected an operation here.", "slots": [] @@ -2314,7 +2314,7 @@ { "id": "parser._ParseError._parse_one_operation_inner-2", "class": "_ParseError", - "source": "parser.py:1739", + "source": "parser.py:1759", "raised_in": "_parse_one_operation_inner", "template": "The word '{t.value}' was renamed \u2014 use '{TOMBSTONES[t.value]}'.", "slots": [ @@ -2325,7 +2325,7 @@ { "id": "parser._ParseError._parse_one_operation_inner-3", "class": "_ParseError", - "source": "parser.py:1742", + "source": "parser.py:1762", "raised_in": "_parse_one_operation_inner", "template": "I don't recognize a command here. Every sentence needs a verb like 'remember', 'show', 'filter', 'count', 'gather', 'sum', 'each', or 'choose'.", "slots": [] @@ -2333,7 +2333,7 @@ { "id": "parser._ParseError._parse_one_operation_inner-4", "class": "_ParseError", - "source": "parser.py:1751", + "source": "parser.py:1771", "raised_in": "_parse_one_operation_inner", "template": "'when' is a top-level statement and starts its own indented action block. It can't appear inside compositions, 'each' bodies, or another 'when' action block.", "slots": [] @@ -2341,7 +2341,7 @@ { "id": "parser._ParseError._parse_one_operation_inner-5", "class": "_ParseError", - "source": "parser.py:1758", + "source": "parser.py:1778", "raised_in": "_parse_one_operation_inner", "template": "'unless' is a guard clause that follows a 'when' condition \u2014 it can't introduce a statement on its own. Try: when unless .", "slots": [] @@ -2349,7 +2349,7 @@ { "id": "parser._ParseError._parse_one_operation_inner-6", "class": "_ParseError", - "source": "parser.py:1771", + "source": "parser.py:1791", "raised_in": "_parse_one_operation_inner", "template": "'{t.value}' is a declaration that must be the first line of the program (after any comments). It can't appear here.", "slots": [ @@ -2359,7 +2359,7 @@ { "id": "parser._ParseError._parse_one_operation_inner-7", "class": "_ParseError", - "source": "parser.py:1775", + "source": "parser.py:1795", "raised_in": "_parse_one_operation_inner", "template": "I didn't expect '{t.value}' at the start of an operation.", "slots": [ @@ -2369,7 +2369,7 @@ { "id": "parser._ParseError._parse_verb_statement-1", "class": "_ParseError", - "source": "parser.py:1816", + "source": "parser.py:1836", "raised_in": "_parse_verb_statement", "template": "'choose' can't appear inside 'each'. To handle items differently, use 'keep' to separate them by condition.", "slots": [] @@ -2377,7 +2377,7 @@ { "id": "parser._ParseError._parse_verb_statement-2", "class": "_ParseError", - "source": "parser.py:1853", + "source": "parser.py:1873", "raised_in": "_parse_verb_statement", "template": "Unknown verb '{verb.value}'.", "slots": [ @@ -2387,7 +2387,7 @@ { "id": "parser._ParseError._parse_pack_verb-1", "class": "_ParseError", - "source": "parser.py:1882", + "source": "parser.py:1902", "raised_in": "_parse_pack_verb", "template": null, "slots": [], @@ -2396,7 +2396,7 @@ { "id": "parser._ParseError._parse_pack_verb-2", "class": "_ParseError", - "source": "parser.py:1896", + "source": "parser.py:1916", "raised_in": "_parse_pack_verb", "template": null, "slots": [], @@ -2405,7 +2405,7 @@ { "id": "parser._ParseError._parse_pack_verb-3", "class": "_ParseError", - "source": "parser.py:1900", + "source": "parser.py:1920", "raised_in": "_parse_pack_verb", "template": null, "slots": [], @@ -2414,7 +2414,7 @@ { "id": "parser._ParseError._parse_pack_slot_value-1", "class": "_ParseError", - "source": "parser.py:1928", + "source": "parser.py:1948", "raised_in": "_parse_pack_slot_value", "template": null, "slots": [], @@ -2423,7 +2423,7 @@ { "id": "parser._ParseError._parse_pack_slot_value-2", "class": "_ParseError", - "source": "parser.py:1930", + "source": "parser.py:1950", "raised_in": "_parse_pack_slot_value", "template": "Names can't have spaces. Try a hyphenated name like '{_hyphenate(value_tok.value)}' instead.", "slots": [ @@ -2433,7 +2433,7 @@ { "id": "parser._ParseError._parse_pack_slot_value-3", "class": "_ParseError", - "source": "parser.py:1937", + "source": "parser.py:1957", "raised_in": "_parse_pack_slot_value", "template": "The word '{value_tok.value}' is reserved in Liminate \u2014 it's used as a {cat}. Please use a name you've created.", "slots": [ @@ -2444,7 +2444,7 @@ { "id": "parser._ParseError._parse_pack_slot_value-4", "class": "_ParseError", - "source": "parser.py:1947", + "source": "parser.py:1967", "raised_in": "_parse_pack_slot_value", "template": "I expected a name {intro}, not '{value_tok.value}'.", "slots": [ @@ -2455,7 +2455,7 @@ { "id": "parser._ParseError._parse_remember-1", "class": "_ParseError", - "source": "parser.py:2009", + "source": "parser.py:2029", "raised_in": "_parse_remember", "template": "I expected the word 'called' to introduce the name.", "slots": [] @@ -2463,7 +2463,7 @@ { "id": "parser._ParseError._parse_remember-2", "class": "_ParseError", - "source": "parser.py:2019", + "source": "parser.py:2039", "raised_in": "_parse_remember", "template": "I expected 'with' or 'from' after '{name}'.", "slots": [ @@ -2473,7 +2473,7 @@ { "id": "parser._ParseError._parse_remember-3", "class": "_ParseError", - "source": "parser.py:2021", + "source": "parser.py:2041", "raised_in": "_parse_remember", "template": "I expected 'with' or 'from', not '{intro.value}'.", "slots": [ @@ -2483,7 +2483,7 @@ { "id": "parser._ParseError._parse_composition_definition-1", "class": "_ParseError", - "source": "parser.py:2044", + "source": "parser.py:2064", "raised_in": "_parse_composition_definition", "template": "I expected 'to' after 'how'.", "slots": [] @@ -2491,7 +2491,7 @@ { "id": "parser._ParseError._parse_composition_definition-2", "class": "_ParseError", - "source": "parser.py:2060", + "source": "parser.py:2080", "raised_in": "_parse_composition_definition", "template": "I expected ':' after the composition name.", "slots": [] @@ -2499,7 +2499,7 @@ { "id": "parser._ParseError._parse_define", "class": "_ParseError", - "source": "parser.py:2089", + "source": "parser.py:2109", "raised_in": "_parse_define", "template": "I expected ':' after the predicate name.", "slots": [] @@ -2507,7 +2507,7 @@ { "id": "parser._ParseError._consume_remember_intro-1", "class": "_ParseError", - "source": "parser.py:2119", + "source": "parser.py:2139", "raised_in": "_consume_remember_intro", "template": "I expected 'called' to introduce the name.", "slots": [] @@ -2515,7 +2515,7 @@ { "id": "parser._ParseError._consume_remember_intro-2", "class": "_ParseError", - "source": "parser.py:2134", + "source": "parser.py:2154", "raised_in": "_consume_remember_intro", "template": "The word '{t.value}' is reserved in Liminate \u2014 it's used as a {cat}. Please choose a different name.", "slots": [ @@ -2526,7 +2526,7 @@ { "id": "parser._ParseError._consume_remember_intro-3", "class": "_ParseError", - "source": "parser.py:2138", + "source": "parser.py:2158", "raised_in": "_consume_remember_intro", "template": "I didn't expect '{t.value}' before 'called'.", "slots": [ @@ -2536,7 +2536,7 @@ { "id": "parser._ParseError._parse_remember_with", "class": "_ParseError", - "source": "parser.py:2163", + "source": "parser.py:2183", "raised_in": "_parse_remember_with", "template": "I expected a value after 'with'.", "slots": [] @@ -2544,7 +2544,7 @@ { "id": "parser._ParseError._parse_record_field-1", "class": "_ParseError", - "source": "parser.py:2236", + "source": "parser.py:2256", "raised_in": "_parse_record_field", "template": "I expected a field name.", "slots": [] @@ -2552,7 +2552,7 @@ { "id": "parser._ParseError._parse_record_field-2", "class": "_ParseError", - "source": "parser.py:2239", + "source": "parser.py:2259", "raised_in": "_parse_record_field", "template": "Field names can't have spaces. Try a hyphenated name like '{_hyphenate(field_tok.value)}' instead.", "slots": [ @@ -2562,7 +2562,7 @@ { "id": "parser._ParseError._parse_record_field-3", "class": "_ParseError", - "source": "parser.py:2246", + "source": "parser.py:2266", "raised_in": "_parse_record_field", "template": "The word '{field_tok.value}' is reserved in Liminate \u2014 it's used as a {cat}. Please choose a different name.", "slots": [ @@ -2573,7 +2573,7 @@ { "id": "parser._ParseError._parse_record_field-4", "class": "_ParseError", - "source": "parser.py:2250", + "source": "parser.py:2270", "raised_in": "_parse_record_field", "template": "I expected a field name, not '{field_tok.value}'.", "slots": [ @@ -2583,7 +2583,7 @@ { "id": "parser._ParseError._parse_record_field-5", "class": "_ParseError", - "source": "parser.py:2256", + "source": "parser.py:2276", "raised_in": "_parse_record_field", "template": "I expected a value after '{field_name}'. Try: '{field_name} as [value]'.", "slots": [ @@ -2594,7 +2594,7 @@ { "id": "parser._ParseError._parse_remember_from", "class": "_ParseError", - "source": "parser.py:2280", + "source": "parser.py:2300", "raised_in": "_parse_remember_from", "template": "I expected an expression after 'from'.", "slots": [] @@ -2602,7 +2602,7 @@ { "id": "parser._ParseError._parse_show-1", "class": "_ParseError", - "source": "parser.py:2378", + "source": "parser.py:2398", "raised_in": "_parse_show", "template": "Field names can't have spaces. Try 'show {_hyphenate(peek.value)} of {rec}' instead.", "slots": [ @@ -2613,7 +2613,7 @@ { "id": "parser._ParseError._parse_show-2", "class": "_ParseError", - "source": "parser.py:2383", + "source": "parser.py:2403", "raised_in": "_parse_show", "template": "Field names can't have spaces. Try a hyphenated name like '{_hyphenate(peek.value)}' instead.", "slots": [ @@ -2623,7 +2623,7 @@ { "id": "parser._ParseError._parse_show-3", "class": "_ParseError", - "source": "parser.py:2399", + "source": "parser.py:2419", "raised_in": "_parse_show", "template": "I expected a record name after 'of'.", "slots": [] @@ -2631,7 +2631,7 @@ { "id": "parser._ParseError._parse_show-4", "class": "_ParseError", - "source": "parser.py:2403", + "source": "parser.py:2423", "raised_in": "_parse_show", "template": "The word '{rec_tok.value}' is reserved in Liminate \u2014 it's used as a {rcat} and can't be used as a record name.", "slots": [ @@ -2642,7 +2642,7 @@ { "id": "parser._ParseError._parse_show-5", "class": "_ParseError", - "source": "parser.py:2408", + "source": "parser.py:2428", "raised_in": "_parse_show", "template": "I expected a record name after 'of', not '{rec_tok.value}'.", "slots": [ @@ -2652,7 +2652,7 @@ { "id": "parser._ParseError._parse_show-6", "class": "_ParseError", - "source": "parser.py:2416", + "source": "parser.py:2436", "raised_in": "_parse_show", "template": "Field access uses one record at a time: of . Chained forms (a of b of c) need nested records, which v2b doesn't yet have.", "slots": [] @@ -2660,7 +2660,7 @@ { "id": "parser._ParseError._parse_show-7", "class": "_ParseError", - "source": "parser.py:2444", + "source": "parser.py:2464", "raised_in": "_parse_show", "template": "Field names can't have spaces. Try a hyphenated name like '{_hyphenate(nx2.value)}' instead.", "slots": [ @@ -2670,7 +2670,7 @@ { "id": "parser._ParseError._parse_show-8", "class": "_ParseError", - "source": "parser.py:2471", + "source": "parser.py:2491", "raised_in": "_parse_show", "template": "'each' is a verb in Liminate \u2014 it iterates a list, or acts as a self-reference pronoun inside a 'where' clause. It can't appear as a target on its own.", "slots": [] @@ -2678,7 +2678,7 @@ { "id": "parser._ParseError._parse_show-9", "class": "_ParseError", - "source": "parser.py:2476", + "source": "parser.py:2496", "raised_in": "_parse_show", "template": "I expected a target after 'show', but '{peek.value}' is a {cat} in Liminate. Targets must be names you've created with 'remember' or 'gather'.", "slots": [ @@ -2689,7 +2689,7 @@ { "id": "parser._ParseError._parse_show-10", "class": "_ParseError", - "source": "parser.py:2481", + "source": "parser.py:2501", "raised_in": "_parse_show", "template": "I didn't expect '{peek.value}' after 'show'.", "slots": [ @@ -2699,7 +2699,7 @@ { "id": "parser._ParseError._parse_add-1", "class": "_ParseError", - "source": "parser.py:2513", + "source": "parser.py:2533", "raised_in": "_parse_add", "template": "'add' needs an item and a target list \u2014 try: add to .", "slots": [] @@ -2707,7 +2707,7 @@ { "id": "parser._ParseError._parse_add-2", "class": "_ParseError", - "source": "parser.py:2520", + "source": "parser.py:2540", "raised_in": "_parse_add", "template": "'add' needs a target list \u2014 try: add to .", "slots": [] @@ -2715,7 +2715,7 @@ { "id": "parser._ParseError._parse_remove-1", "class": "_ParseError", - "source": "parser.py:2537", + "source": "parser.py:2557", "raised_in": "_parse_remove", "template": "'remove' needs an item and a target list \u2014 try: remove from .", "slots": [] @@ -2723,7 +2723,7 @@ { "id": "parser._ParseError._parse_remove-2", "class": "_ParseError", - "source": "parser.py:2547", + "source": "parser.py:2567", "raised_in": "_parse_remove", "template": "'remove' needs a source list \u2014 try: remove from .", "slots": [] @@ -2731,7 +2731,7 @@ { "id": "parser._ParseError._parse_weakens-1", "class": "_ParseError", - "source": "parser.py:2565", + "source": "parser.py:2585", "raised_in": "_parse_weakens", "template": "'weakens' needs a target and a decay period \u2014 try: weakens over .", "slots": [] @@ -2739,7 +2739,7 @@ { "id": "parser._ParseError._parse_weakens-2", "class": "_ParseError", - "source": "parser.py:2577", + "source": "parser.py:2597", "raised_in": "_parse_weakens", "template": "'weakens' needs a decay period \u2014 try: weakens over .", "slots": [] @@ -2747,7 +2747,7 @@ { "id": "parser._ParseError._parse_weakens-3", "class": "_ParseError", - "source": "parser.py:2584", + "source": "parser.py:2604", "raised_in": "_parse_weakens", "template": "I expected a number after 'over' \u2014 the decay period. Try: weakens over 30.", "slots": [] @@ -2755,7 +2755,7 @@ { "id": "parser._ParseError._parse_require", "class": "_ParseError", - "source": "parser.py:2613", + "source": "parser.py:2633", "raised_in": "_parse_require", "template": "'require' needs a condition \u2014 try: require is .", "slots": [] @@ -2763,7 +2763,7 @@ { "id": "parser._ParseError._parse_require_each-1", "class": "_ParseError", - "source": "parser.py:2653", + "source": "parser.py:2673", "raised_in": "_parse_require_each", "template": "'require each' needs a list \u2014 try: require each {binding_name} in .", "slots": [ @@ -2773,7 +2773,7 @@ { "id": "parser._ParseError._parse_require_each-2", "class": "_ParseError", - "source": "parser.py:2658", + "source": "parser.py:2678", "raised_in": "_parse_require_each", "template": "I expected 'in' after 'require each {binding_name}', not '{in_tok.value}'. Try: require each {binding_name} in .", "slots": [ @@ -2785,7 +2785,7 @@ { "id": "parser._ParseError._parse_require_each-3", "class": "_ParseError", - "source": "parser.py:2670", + "source": "parser.py:2690", "raised_in": "_parse_require_each", "template": "'require each {binding_name} in {collection.name}' needs a condition \u2014 try: require each {binding_name} in {collection.name} is .", "slots": [ @@ -2798,7 +2798,7 @@ { "id": "parser._ParseError._parse_forbid", "class": "_ParseError", - "source": "parser.py:2701", + "source": "parser.py:2721", "raised_in": "_parse_forbid", "template": "'forbid' needs a condition \u2014 try: forbid is .", "slots": [] @@ -2806,7 +2806,7 @@ { "id": "parser._ParseError._parse_permit", "class": "_ParseError", - "source": "parser.py:2727", + "source": "parser.py:2747", "raised_in": "_parse_permit", "template": "'permit' needs a condition \u2014 try: permit is .", "slots": [] @@ -2814,7 +2814,7 @@ { "id": "parser._ParseError._parse_assign-1", "class": "_ParseError", - "source": "parser.py:2749", + "source": "parser.py:2769", "raised_in": "_parse_assign", "template": "'assign' needs an item and a recipient \u2014 try: assign to .", "slots": [] @@ -2822,7 +2822,7 @@ { "id": "parser._ParseError._parse_assign-2", "class": "_ParseError", - "source": "parser.py:2759", + "source": "parser.py:2779", "raised_in": "_parse_assign", "template": "'assign' needs a recipient \u2014 try: assign to .", "slots": [] @@ -2830,7 +2830,7 @@ { "id": "parser._ParseError._parse_assign-3", "class": "_ParseError", - "source": "parser.py:2764", + "source": "parser.py:2784", "raised_in": "_parse_assign", "template": "I expected a recipient after 'to'. Try: assign to .", "slots": [] @@ -2838,7 +2838,7 @@ { "id": "parser._ParseError._parse_expect", "class": "_ParseError", - "source": "parser.py:2785", + "source": "parser.py:2805", "raised_in": "_parse_expect", "template": "'expect' needs a condition \u2014 try: expect is .", "slots": [] @@ -2846,7 +2846,7 @@ { "id": "parser._ParseError._parse_sort-1", "class": "_ParseError", - "source": "parser.py:2813", + "source": "parser.py:2833", "raised_in": "_parse_sort", "template": "'sort' needs a target list and a field \u2014 try: sort by .", "slots": [] @@ -2854,7 +2854,7 @@ { "id": "parser._ParseError._parse_sort-2", "class": "_ParseError", - "source": "parser.py:2825", + "source": "parser.py:2845", "raised_in": "_parse_sort", "template": "'sort' needs a field to sort by \u2014 try: sort by .", "slots": [] @@ -2862,7 +2862,7 @@ { "id": "parser._ParseError._parse_sort-3", "class": "_ParseError", - "source": "parser.py:2832", + "source": "parser.py:2852", "raised_in": "_parse_sort", "template": "I expected a field name after 'by'.", "slots": [] @@ -2870,7 +2870,7 @@ { "id": "parser._ParseError._parse_sort-4", "class": "_ParseError", - "source": "parser.py:2834", + "source": "parser.py:2854", "raised_in": "_parse_sort", "template": "Field names can't have spaces. Try a hyphenated name like '{_hyphenate(field_tok.value)}' instead.", "slots": [ @@ -2880,7 +2880,7 @@ { "id": "parser._ParseError._parse_sort-5", "class": "_ParseError", - "source": "parser.py:2841", + "source": "parser.py:2861", "raised_in": "_parse_sort", "template": "The word '{field_tok.value}' is reserved in Liminate \u2014 it's used as a {cat}. Please use a field name from your records.", "slots": [ @@ -2891,7 +2891,7 @@ { "id": "parser._ParseError._parse_sort-6", "class": "_ParseError", - "source": "parser.py:2846", + "source": "parser.py:2866", "raised_in": "_parse_sort", "template": "I expected a field name after 'by', not '{field_tok.value}'.", "slots": [ @@ -2901,7 +2901,7 @@ { "id": "parser._ParseError._parse_compare-1", "class": "_ParseError", - "source": "parser.py:2889", + "source": "parser.py:2909", "raised_in": "_parse_compare", "template": "'compare' needs two values \u2014 try: compare to .", "slots": [] @@ -2909,7 +2909,7 @@ { "id": "parser._ParseError._parse_compare-2", "class": "_ParseError", - "source": "parser.py:2901", + "source": "parser.py:2921", "raised_in": "_parse_compare", "template": "'compare' needs a second value \u2014 try: compare to .", "slots": [] @@ -2917,7 +2917,7 @@ { "id": "parser._ParseError._parse_transform-1", "class": "_ParseError", - "source": "parser.py:2928", + "source": "parser.py:2948", "raised_in": "_parse_transform", "template": "'transform' needs a target \u2014 try: transform by , or transform of by .", "slots": [] @@ -2925,7 +2925,7 @@ { "id": "parser._ParseError._parse_transform-2", "class": "_ParseError", - "source": "parser.py:2936", + "source": "parser.py:2956", "raised_in": "_parse_transform", "template": "I expected a name after 'transform'.", "slots": [] @@ -2933,7 +2933,7 @@ { "id": "parser._ParseError._parse_transform-3", "class": "_ParseError", - "source": "parser.py:2938", + "source": "parser.py:2958", "raised_in": "_parse_transform", "template": "Names can't have spaces. Try a hyphenated name like '{_hyphenate(first_tok.value)}' instead.", "slots": [ @@ -2943,7 +2943,7 @@ { "id": "parser._ParseError._parse_transform-4", "class": "_ParseError", - "source": "parser.py:2945", + "source": "parser.py:2965", "raised_in": "_parse_transform", "template": "The word '{first_tok.value}' is reserved in Liminate \u2014 it's used as a {cat}. Please use a name you've created.", "slots": [ @@ -2954,7 +2954,7 @@ { "id": "parser._ParseError._parse_transform-5", "class": "_ParseError", - "source": "parser.py:2950", + "source": "parser.py:2970", "raised_in": "_parse_transform", "template": "I expected a name after 'transform', not '{first_tok.value}'.", "slots": [ @@ -2964,7 +2964,7 @@ { "id": "parser._ParseError._parse_transform-6", "class": "_ParseError", - "source": "parser.py:2969", + "source": "parser.py:2989", "raised_in": "_parse_transform", "template": "'transform' needs an expression after 'by' \u2014 try: transform {field_name} of {target.name} by .", "slots": [ @@ -2975,7 +2975,7 @@ { "id": "parser._ParseError._parse_transform-7", "class": "_ParseError", - "source": "parser.py:2974", + "source": "parser.py:2994", "raised_in": "_parse_transform", "template": "I expected an expression after 'by'.", "slots": [] @@ -2983,7 +2983,7 @@ { "id": "parser._ParseError._parse_transform-8", "class": "_ParseError", - "source": "parser.py:2989", + "source": "parser.py:3009", "raised_in": "_parse_transform", "template": "I expected an expression after 'by'.", "slots": [] @@ -2991,7 +2991,7 @@ { "id": "parser._ParseError._parse_transform-9", "class": "_ParseError", - "source": "parser.py:2998", + "source": "parser.py:3018", "raised_in": "_parse_transform", "template": "I expected 'of' or 'by' after 'transform {first_tok.value}', not '{got}'. Try: transform {first_tok.value} of by , or transform {first_tok.value} by .", "slots": [ @@ -3004,7 +3004,7 @@ { "id": "parser._ParseError._parse_gather-1", "class": "_ParseError", - "source": "parser.py:3021", + "source": "parser.py:3041", "raised_in": "_parse_gather", "template": "I expected a name after 'gather'.", "slots": [] @@ -3012,7 +3012,7 @@ { "id": "parser._ParseError._parse_gather-2", "class": "_ParseError", - "source": "parser.py:3025", + "source": "parser.py:3045", "raised_in": "_parse_gather", "template": "The word '{name_tok.value}' is reserved in Liminate \u2014 it's used as a {cat}. Please choose a different name.", "slots": [ @@ -3023,7 +3023,7 @@ { "id": "parser._ParseError._parse_gather-3", "class": "_ParseError", - "source": "parser.py:3029", + "source": "parser.py:3049", "raised_in": "_parse_gather", "template": "I expected a name after 'gather', not '{name_tok.value}'.", "slots": [ @@ -3033,7 +3033,7 @@ { "id": "parser._ParseError._parse_gather-4", "class": "_ParseError", - "source": "parser.py:3034", + "source": "parser.py:3054", "raised_in": "_parse_gather", "template": "I expected 'from' in 'gather'.", "slots": [] @@ -3041,7 +3041,7 @@ { "id": "parser._ParseError._parse_gather-5", "class": "_ParseError", - "source": "parser.py:3038", + "source": "parser.py:3058", "raised_in": "_parse_gather", "template": "I expected a number after 'from'.", "slots": [] @@ -3049,7 +3049,7 @@ { "id": "parser._ParseError._parse_gather-6", "class": "_ParseError", - "source": "parser.py:3043", + "source": "parser.py:3063", "raised_in": "_parse_gather", "template": "I expected 'to' after the range start.", "slots": [] @@ -3057,7 +3057,7 @@ { "id": "parser._ParseError._parse_gather-7", "class": "_ParseError", - "source": "parser.py:3047", + "source": "parser.py:3067", "raised_in": "_parse_gather", "template": "I expected a number after 'to'.", "slots": [] @@ -3065,7 +3065,7 @@ { "id": "parser._ParseError._parse_gather-8", "class": "_ParseError", - "source": "parser.py:3058", + "source": "parser.py:3078", "raised_in": "_parse_gather", "template": "I expected a number after 'by' \u2014 the step value.", "slots": [] @@ -3073,7 +3073,7 @@ { "id": "parser._ParseError._parse_gather-9", "class": "_ParseError", - "source": "parser.py:3061", + "source": "parser.py:3081", "raised_in": "_parse_gather", "template": "The step value must be positive \u2014 got {step_val}. The direction is determined by the range (from/to), not the step.", "slots": [ @@ -3083,7 +3083,7 @@ { "id": "parser._ParseError._parse_each-1", "class": "_ParseError", - "source": "parser.py:3088", + "source": "parser.py:3108", "raised_in": "_parse_each", "template": "I expected a collection after 'each'.", "slots": [] @@ -3091,7 +3091,7 @@ { "id": "parser._ParseError._parse_each-2", "class": "_ParseError", - "source": "parser.py:3092", + "source": "parser.py:3112", "raised_in": "_parse_each", "template": "I expected a collection after 'each', but '{coll_tok.value}' is a {cat} in Liminate. Collections must be names you've created with 'remember' or 'gather'.", "slots": [ @@ -3102,7 +3102,7 @@ { "id": "parser._ParseError._parse_each-3", "class": "_ParseError", - "source": "parser.py:3097", + "source": "parser.py:3117", "raised_in": "_parse_each", "template": "I expected a collection after 'each'.", "slots": [] @@ -3110,7 +3110,7 @@ { "id": "parser._ParseError._parse_each-4", "class": "_ParseError", - "source": "parser.py:3101", + "source": "parser.py:3121", "raised_in": "_parse_each", "template": "I expected an action for 'each'.", "slots": [] @@ -3118,7 +3118,7 @@ { "id": "parser._ParseError._parse_choose", "class": "_ParseError", - "source": "parser.py:3132", + "source": "parser.py:3152", "raised_in": "_parse_choose", "template": "I expected 'if' after 'choose'. Try: choose if : .", "slots": [] @@ -3126,7 +3126,7 @@ { "id": "parser._ParseError._parse_choose_branch", "class": "_ParseError", - "source": "parser.py:3175", + "source": "parser.py:3195", "raised_in": "_parse_choose_branch", "template": "I expected ':' after the {leader} condition, not '{got}'.", "slots": [ @@ -3137,7 +3137,7 @@ { "id": "parser._ParseError._parse_filter_shape", "class": "_ParseError", - "source": "parser.py:3214", + "source": "parser.py:3234", "raised_in": "_parse_filter_shape", "template": "I expected 'where' to introduce the condition.", "slots": [] @@ -3145,7 +3145,7 @@ { "id": "parser._ParseError._parse_simple_condition-1", "class": "_ParseError", - "source": "parser.py:3326", + "source": "parser.py:3346", "raised_in": "_parse_simple_condition", "template": "I expected a field after 'where'.", "slots": [] @@ -3153,7 +3153,7 @@ { "id": "parser._ParseError._parse_simple_condition-2", "class": "_ParseError", - "source": "parser.py:3362", + "source": "parser.py:3382", "raised_in": "_parse_simple_condition", "template": "I expected a record name after 'of'.", "slots": [] @@ -3161,7 +3161,7 @@ { "id": "parser._ParseError._parse_simple_condition-3", "class": "_ParseError", - "source": "parser.py:3366", + "source": "parser.py:3386", "raised_in": "_parse_simple_condition", "template": "The word '{rec_tok.value}' is reserved in Liminate \u2014 it's used as a {rcat} and can't be used as a record name.", "slots": [ @@ -3172,7 +3172,7 @@ { "id": "parser._ParseError._parse_simple_condition-4", "class": "_ParseError", - "source": "parser.py:3371", + "source": "parser.py:3391", "raised_in": "_parse_simple_condition", "template": "I expected a record name after 'of', not '{rec_tok.value}'.", "slots": [ @@ -3182,7 +3182,7 @@ { "id": "parser._ParseError._parse_simple_condition-5", "class": "_ParseError", - "source": "parser.py:3377", + "source": "parser.py:3397", "raised_in": "_parse_simple_condition", "template": "Field access uses one record at a time: of . Chained forms (a of b of c) need nested records, which v2b doesn't yet have.", "slots": [] @@ -3190,7 +3190,7 @@ { "id": "parser._ParseError._parse_simple_condition-6", "class": "_ParseError", - "source": "parser.py:3387", + "source": "parser.py:3407", "raised_in": "_parse_simple_condition", "template": "Field names can't have spaces. Try a hyphenated name like '{_hyphenate(head.value)}' instead.", "slots": [ @@ -3200,7 +3200,7 @@ { "id": "parser._ParseError._parse_simple_condition-7", "class": "_ParseError", - "source": "parser.py:3394", + "source": "parser.py:3414", "raised_in": "_parse_simple_condition", "template": "The word '{head.value}' is reserved in Liminate \u2014 it's used as a {cat} and can't be used as a field name.", "slots": [ @@ -3211,7 +3211,7 @@ { "id": "parser._ParseError._parse_simple_condition-8", "class": "_ParseError", - "source": "parser.py:3398", + "source": "parser.py:3418", "raised_in": "_parse_simple_condition", "template": "I didn't expect '{head.value}' as a field name.", "slots": [ @@ -3221,7 +3221,7 @@ { "id": "parser._ParseError._finish_simple_condition-1", "class": "_ParseError", - "source": "parser.py:3431", + "source": "parser.py:3451", "raised_in": "_finish_simple_condition", "template": "I expected 'is' in this condition.", "slots": [] @@ -3229,7 +3229,7 @@ { "id": "parser._ParseError._finish_simple_condition-2", "class": "_ParseError", - "source": "parser.py:3437", + "source": "parser.py:3457", "raised_in": "_finish_simple_condition", "template": "I expected a value or comparison after 'is'.", "slots": [] @@ -3237,7 +3237,7 @@ { "id": "parser._ParseError._finish_simple_condition-3", "class": "_ParseError", - "source": "parser.py:3448", + "source": "parser.py:3468", "raised_in": "_finish_simple_condition", "template": "'within' needs a target \u2014 try: is within of . Got: {got}.", "slots": [ @@ -3247,7 +3247,7 @@ { "id": "parser._ParseError._finish_simple_condition-4", "class": "_ParseError", - "source": "parser.py:3482", + "source": "parser.py:3502", "raised_in": "_finish_simple_condition", "template": "After 'not' I expected 'above', 'below', or 'equal to'.", "slots": [] @@ -3255,7 +3255,7 @@ { "id": "parser._ParseError._finish_simple_condition-5", "class": "_ParseError", - "source": "parser.py:3492", + "source": "parser.py:3512", "raised_in": "_finish_simple_condition", "template": "I didn't expect '{nxt.value}' after 'is'.", "slots": [ @@ -3265,7 +3265,7 @@ { "id": "parser._ParseError._parse_within_tolerance-1", "class": "_ParseError", - "source": "parser.py:3520", + "source": "parser.py:3545", "raised_in": "_parse_within_tolerance", "template": "'within' needs an amount \u2014 try: is within of .", "slots": [] @@ -3273,7 +3273,7 @@ { "id": "parser._ParseError._parse_within_tolerance-2", "class": "_ParseError", - "source": "parser.py:3527", + "source": "parser.py:3552", "raised_in": "_parse_within_tolerance", "template": "'within' needs a numeric amount (the number of days), not a date. Got: {tok.value}.", "slots": [ @@ -3283,7 +3283,7 @@ { "id": "parser._ParseError._parse_within_tolerance-3", "class": "_ParseError", - "source": "parser.py:3535", + "source": "parser.py:3560", "raised_in": "_parse_within_tolerance", "template": "'within' needs a numeric amount, but '{tok.value}' is a {cat} in Liminate.", "slots": [ @@ -3294,7 +3294,7 @@ { "id": "parser._ParseError._parse_within_tolerance-4", "class": "_ParseError", - "source": "parser.py:3539", + "source": "parser.py:3564", "raised_in": "_parse_within_tolerance", "template": "'within' needs a numeric amount, not '{tok.value}'.", "slots": [ @@ -3304,7 +3304,7 @@ { "id": "parser._ParseError._parse_extrema-1", "class": "_ParseError", - "source": "parser.py:3642", + "source": "parser.py:3667", "raised_in": "_parse_extrema", "template": "'{word}' needs a list \u2014 try: {word} of , or {word} of .", "slots": [ @@ -3316,7 +3316,7 @@ { "id": "parser._ParseError._parse_extrema-2", "class": "_ParseError", - "source": "parser.py:3650", + "source": "parser.py:3675", "raised_in": "_parse_extrema", "template": "Field access uses one record at a time: of . Chained forms (a of b of c) need nested records, which v2b doesn't yet have.", "slots": [] @@ -3324,7 +3324,7 @@ { "id": "parser._ParseError._parse_atom-1", "class": "_ParseError", - "source": "parser.py:3670", + "source": "parser.py:3695", "raised_in": "_parse_atom", "template": "I expected a value here.", "slots": [] @@ -3332,7 +3332,7 @@ { "id": "parser._ParseError._parse_atom-2", "class": "_ParseError", - "source": "parser.py:3689", + "source": "parser.py:3714", "raised_in": "_parse_atom", "template": "Field names can't have spaces. Try '{_hyphenate(tok.value)}' instead of \"{tok.value}\".", "slots": [ @@ -3343,7 +3343,7 @@ { "id": "parser._ParseError._parse_atom-3", "class": "_ParseError", - "source": "parser.py:3703", + "source": "parser.py:3728", "raised_in": "_parse_atom", "template": "'each' is a verb in Liminate \u2014 it iterates a list, or acts as a self-reference pronoun inside a 'where' clause. It can't appear as a value on its own.", "slots": [] @@ -3351,7 +3351,7 @@ { "id": "parser._ParseError._parse_atom-4", "class": "_ParseError", - "source": "parser.py:3710", + "source": "parser.py:3735", "raised_in": "_parse_atom", "template": "The word '{tok.value}' is a {cat} in Liminate and can't be used as a value. Try a different word, or wrap it in quotes: \"{tok.value}\".", "slots": [ @@ -3363,7 +3363,7 @@ { "id": "parser._ParseError._parse_atom-5", "class": "_ParseError", - "source": "parser.py:3715", + "source": "parser.py:3740", "raised_in": "_parse_atom", "template": "I didn't expect '{tok.value}' as a value.", "slots": [ @@ -3373,7 +3373,7 @@ { "id": "parser._ParseError._maybe_field_access-1", "class": "_ParseError", - "source": "parser.py:3738", + "source": "parser.py:3763", "raised_in": "_maybe_field_access", "template": "I expected a record name after 'of'.", "slots": [] @@ -3381,7 +3381,7 @@ { "id": "parser._ParseError._maybe_field_access-2", "class": "_ParseError", - "source": "parser.py:3742", + "source": "parser.py:3767", "raised_in": "_maybe_field_access", "template": "The word '{rec_tok.value}' is reserved in Liminate \u2014 it's used as a {rcat} and can't be used as a record name.", "slots": [ @@ -3392,7 +3392,7 @@ { "id": "parser._ParseError._maybe_field_access-3", "class": "_ParseError", - "source": "parser.py:3747", + "source": "parser.py:3772", "raised_in": "_maybe_field_access", "template": "I expected a record name after 'of', not '{rec_tok.value}'.", "slots": [ @@ -3402,7 +3402,7 @@ { "id": "parser._ParseError._maybe_field_access-4", "class": "_ParseError", - "source": "parser.py:3752", + "source": "parser.py:3777", "raised_in": "_maybe_field_access", "template": "Field access uses one record at a time: of . Chained forms (a of b of c) need nested records, which v2b doesn't yet have.", "slots": [] @@ -3410,7 +3410,7 @@ { "id": "parser._ParseError._consume_target-1", "class": "_ParseError", - "source": "parser.py:3794", + "source": "parser.py:3819", "raised_in": "_consume_target", "template": "'{verb}' is a list operation \u2014 it can't appear inside 'each'. To {verb} only some items, use '{verb} where ' directly.", "slots": [ @@ -3422,7 +3422,7 @@ { "id": "parser._ParseError._consume_target-2", "class": "_ParseError", - "source": "parser.py:3800", + "source": "parser.py:3825", "raised_in": "_consume_target", "template": "I expected a target after '{verb}'.", "slots": [ @@ -3432,7 +3432,7 @@ { "id": "parser._ParseError._consume_target-3", "class": "_ParseError", - "source": "parser.py:3807", + "source": "parser.py:3832", "raised_in": "_consume_target", "template": "'each' is a verb in Liminate \u2014 it iterates a list, or acts as a self-reference pronoun inside a 'where' clause. It can't appear as a target on its own.", "slots": [] @@ -3440,7 +3440,7 @@ { "id": "parser._ParseError._consume_target-4", "class": "_ParseError", - "source": "parser.py:3812", + "source": "parser.py:3837", "raised_in": "_consume_target", "template": "I expected a target after '{verb}', but '{tok.value}' is a {cat} in Liminate. Targets must be names you've created with 'remember' or 'gather'.", "slots": [ @@ -3452,7 +3452,7 @@ { "id": "parser._ParseError._consume_target-5", "class": "_ParseError", - "source": "parser.py:3817", + "source": "parser.py:3842", "raised_in": "_consume_target", "template": "I expected a target after '{verb}', not '{tok.value}'.", "slots": [ @@ -3463,7 +3463,7 @@ { "id": "parser._ParseError._consume_parameter_arg-1", "class": "_ParseError", - "source": "parser.py:3835", + "source": "parser.py:3860", "raised_in": "_consume_parameter_arg", "template": "I expected a name after '{comp_name} from' \u2014 compositions take a single name as input.", "slots": [ @@ -3473,7 +3473,7 @@ { "id": "parser._ParseError._consume_parameter_arg-2", "class": "_ParseError", - "source": "parser.py:3848", + "source": "parser.py:3873", "raised_in": "_consume_parameter_arg", "template": "The word '{tok.value}' is reserved in Liminate \u2014 it's used as a {cat}. Please use a name you've created.", "slots": [ @@ -3484,7 +3484,7 @@ { "id": "parser._ParseError._consume_parameter_arg-3", "class": "_ParseError", - "source": "parser.py:3852", + "source": "parser.py:3877", "raised_in": "_consume_parameter_arg", "template": "I expected a name after '{comp_name} from', not '{tok.value}'.", "slots": [ @@ -3495,7 +3495,7 @@ { "id": "parser._ParseError._consume_name-1", "class": "_ParseError", - "source": "parser.py:3861", + "source": "parser.py:3886", "raised_in": "_consume_name", "template": "I expected a name after {after}.", "slots": [ @@ -3505,7 +3505,7 @@ { "id": "parser._ParseError._consume_name-2", "class": "_ParseError", - "source": "parser.py:3868", + "source": "parser.py:3893", "raised_in": "_consume_name", "template": "Composition names can't have spaces. Try a hyphenated name like '{suggestion}' instead.", "slots": [ @@ -3515,7 +3515,7 @@ { "id": "parser._ParseError._consume_name-3", "class": "_ParseError", - "source": "parser.py:3872", + "source": "parser.py:3897", "raised_in": "_consume_name", "template": "Names can't have spaces. Try a hyphenated name like '{suggestion}' instead.", "slots": [ @@ -3525,7 +3525,7 @@ { "id": "parser._ParseError._consume_name-4", "class": "_ParseError", - "source": "parser.py:3879", + "source": "parser.py:3904", "raised_in": "_consume_name", "template": "The word '{tok.value}' is reserved in Liminate \u2014 it's used as a {cat}. Please choose a different name.", "slots": [ @@ -3536,7 +3536,7 @@ { "id": "parser._ParseError._consume_name-5", "class": "_ParseError", - "source": "parser.py:3883", + "source": "parser.py:3908", "raised_in": "_consume_name", "template": "I expected a name after {after}, not '{tok.value}'.", "slots": [ @@ -3547,7 +3547,7 @@ { "id": "parser._ParseError._consume_name-6", "class": "_ParseError", - "source": "parser.py:3889", + "source": "parser.py:3914", "raised_in": "_consume_name", "template": "The word '{tok.value}' is reserved in Liminate \u2014 it's used as a {cat}. Please choose a different name.", "slots": [ @@ -3558,7 +3558,7 @@ { "id": "renderer.TypeError._render_node", "class": "TypeError", - "source": "renderer.py:387", + "source": "renderer.py:397", "raised_in": "_render_node", "template": "render() has no rule for {type(node).__name__}", "slots": [ @@ -3568,7 +3568,7 @@ { "id": "renderer.ValueError._render_condition", "class": "ValueError", - "source": "renderer.py:590", + "source": "renderer.py:600", "raised_in": "_render_condition", "template": "unknown condition operator '{node.op}'", "slots": [ diff --git a/grammar/rules.json b/grammar/rules.json index f490f2b..15c6707 100644 --- a/grammar/rules.json +++ b/grammar/rules.json @@ -40,7 +40,7 @@ "_contains_mixed_precedence", "_parse_operation_sequence" ], - "source": "parser.py:1071", + "source": "parser.py:1091", "surface": "Parse a canonically-ordered token list into an AST.\n\nReturns:\n - ASTNode on success.\n - LiminateResult with status AMBER_PRECEDENCE if a `where` clause\n uses both `and` and `or` (v1a \u00a730). The pending AST is attached\n so the caller can resume after confirmation.\n - LiminateResult with status ERROR_PARSE on any other parse failure." }, { @@ -52,7 +52,7 @@ ], "produces": "AboutNode", "calls": [], - "source": "parser.py:1132", + "source": "parser.py:1152", "surface": "Parse an `about` declaration from the first non-blank, non-comment\nline of a program.\n\nReturns AboutNode if the line is an `about` declaration, None if it\nis not (the line should then be fed to the normal parse pipeline).\nRaises _ParseError (surfaced as ERROR_PARSE by the caller) if the\nline starts with `about` but has malformed content.\n\nDesign: single, first-line-only (MS-Q1). The CLI calls this on the\nfirst eligible line; if it returns an AboutNode, the line is consumed\nand not passed to the normal pipeline. If it returns None, the line\nis a normal statement." }, { @@ -70,7 +70,7 @@ "parse", "_try_consume_inherited_from" ], - "source": "parser.py:1181", + "source": "parser.py:1201", "surface": "Parse a `when [unless ] [:]` header plus its\nindented action block (v3a \u00a7108/\u00a7109/\u00a7110).\n\n`header_tokens` is the reordered token sequence for the `when` line\n(must begin with the `when` connective). `action_token_lists` is one\nreordered token list per indented action line; blank lines have been\nelided by the caller (v1c \u00a748 / v3a \u00a7110). Indentation has already\nbeen validated at the CLI boundary \u2014 `parse_when_block` itself does\nnot see leading whitespace.\n\nReturns:\n - WhenNode on success.\n - LiminateResult AMBER_PRECEDENCE if any condition or action\n sub-statement contains mixed `and`/`or` (v3a \u00a7123).\n - LiminateResult ERROR_PARSE on grammar errors." }, { @@ -82,7 +82,7 @@ "_parse_one_operation", "_starts_operation" ], - "source": "parser.py:1386", + "source": "parser.py:1406", "surface": "` [and | then ...]` \u2014 one or more\noperations chained on a single line, the top of the per-line grammar.\n\nA single operation returns unwrapped (the caller sees the bare\n`_parse_one_operation` result); two or more return a `SequenceNode`\ncarrying both the operations and the connector used between each pair.\n\n`and` sequences only when the token after it starts a new operation\n(`_starts_operation`) \u2014 otherwise `and` belongs to a condition being\nparsed elsewhere (e.g. `where x is above 1 and y is below 2`), and this\nfunction stops and lets the caller handle it. `then` always sequences;\nunlike `and` it has no other meaning, so anything after it that isn't\nthe start of an operation is a hard parse error, not a silent stop." }, { @@ -98,7 +98,7 @@ "_try_consume_because", "_try_consume_inherited_from" ], - "source": "parser.py:1641", + "source": "parser.py:1661", "surface": "One full statement: optional `starting`/`until` dates, optional\n`inherited`, the operation itself (delegated to\n`_parse_one_operation_inner`), an optional trailing `because \"\"`,\nand \u2014 only when `inherited` was present \u2014 a trailing `from `.\n\nThis is the chokepoint that attaches rationale to whatever statement\nnode was just parsed, in every calling context (top-level, `and`/`then`\nsequences, `choose` branches, `each` bodies), without each verb parser\nhandling `because` itself. `starting`/`until` dates are inert metadata,\nnever read by execution \u2014 only rendered back out in canonical order." }, { @@ -116,7 +116,7 @@ "_parse_define", "_consume_parameter_arg" ], - "source": "parser.py:1703", + "source": "parser.py:1723", "surface": "Dispatch on the next token's type to the right operation parser:\na VERB token routes to `_parse_verb_statement`; an UNKNOWN token that\nnames a known composition (`comp`) becomes a `CompositionCallNode`\n(with an optional `from ` parameter); a DECLARATION token routes\nto `_parse_define` when it's `define`, and is otherwise a parse error\n(declarations besides `define` are first-line-only and handled before\nthe normal pipeline reaches here). `when`, `unless`, and any other\nunrecognized token each raise a specific parse error naming why that\ntoken can't start an operation here, rather than falling through to a\ngeneric message." }, { @@ -147,7 +147,7 @@ "_parse_transform", "_parse_pack_verb" ], - "source": "parser.py:1778", + "source": "parser.py:1798", "surface": "Dispatch table over a leading VERB token's value to that verb's own\nparse function \u2014 the second-level fan-out beneath `_parse_verb_statement`\nfor every base-language verb (`remember`, `show`, `filter`, `keep`,\n`count`, `gather`, `sum`, `each`, `choose`, `add`, `remove`, `weakens`,\n`require`, `forbid`, `permit`, `assign`, `expect`, `sort`, `compare`,\n`transform`, `finish`). A verb not in this fixed set falls through to\n`get_active_pack_verb` \u2014 a domain pack may have registered it \u2014 and\nonly raises `Unknown verb` if neither the base language nor an active\npack recognizes it. `finish` is the one slot-less verb: it parses to a\nbare `FinishNode` here, with context validity (only inside an action\nblock) deferred to the analyzer." }, { @@ -161,7 +161,7 @@ "_parse_pack_slot_value", "_pack_verb_missing_slot_error" ], - "source": "parser.py:1861", + "source": "parser.py:1881", "surface": "Fill the pack verb's slots in source order.\n\nv2 (pack verb contract extension):\n- Positional slots (`connective is None`) consume the next value\n token directly. `value_type` determines what's accepted.\n- Connective-introduced slots peek for the connective; if found,\n consume it then the value per `value_type`.\n- `value_type == \"value\"` routes through `_parse_value` (NUMBER,\n UNKNOWN, QUOTED_STRING, FieldAccessNode).\n- `value_type == \"name\"` consumes a single UNKNOWN as NameRef." }, { @@ -180,7 +180,7 @@ "_pack_verb_missing_slot_error", "_hyphenate" ], - "source": "parser.py:1905", + "source": "parser.py:1925", "surface": "v2 \u2014 consume one slot value per the slot's `value_type`." }, { @@ -197,7 +197,7 @@ "_parse_composition_definition", "_parse_remember_with" ], - "source": "parser.py:1990", + "source": "parser.py:2010", "surface": "remember , where is one of:\nhow to : (composition definition)\n
? * called with \n
? * called with \n
? * called with \n
? * called from \n
? * called from " }, { @@ -212,7 +212,7 @@ "_consume_name", "_parse_operation_sequence" ], - "source": "parser.py:2028", + "source": "parser.py:2048", "surface": "`how to [from ]: ` \u2014 registers a named,\nreusable operation sequence, optionally taking one parameter.\n\n`` follows the same reserved-word-excluded, hyphen-allowed rules\nas every other user-defined name (`_consume_name`). The optional\n`from ` sits between the name and the colon and accepts at most\none parameter \u2014 anything else there, including a second `from`, is a\nparse error. The body is a full `_parse_operation_sequence`, so a\ncomposition can itself sequence multiple operations with `and`/`then`." }, { @@ -226,7 +226,7 @@ "_consume_name", "_parse_or_condition" ], - "source": "parser.py:2071", + "source": "parser.py:2091", "surface": "`define : ` \u2014 registers a named, reusable predicate.\n\nMirrors `_parse_composition_definition`'s name + ':' handling exactly\n(same name-consumption rules: UNKNOWN token, hyphens allowed, reserved\nwords rejected). The body is a condition (not an operation sequence)\nparsed with the `define-body` clause pushed so a field-elided leaf\n(`define big: is above 100`) binds to an implicit `each` pronoun,\nexactly like `require-each` (v31 \u00a790 \u2014 see the elision guard in\n`_parse_simple_condition`)." }, { @@ -246,7 +246,7 @@ "_parse_value", "_parse_record_fields" ], - "source": "parser.py:2141", + "source": "parser.py:2161", "surface": "The tail of `remember a called with ...` \u2014\nthree shapes decided by lookahead on the tokens after `with`:\n\n with as [and as ...] -> record\n with and [and ...] -> list\n with -> scalar\n\nRecord vs. value/list is disambiguated up front: an UNKNOWN token\nimmediately followed by `as` means a record (delegated to\n`_parse_record_fields`); anything else parses as one or more values\nvia `_parse_value`, becoming a `RememberListNode` if `and` chains more\nthan one value or the caller already saw a `[...]` list literal\n(`saw_list`), otherwise a plain `RememberValueNode`. As in\n`_parse_operation_sequence`, `and` stops chaining when the token after\nit starts a new operation \u2014 that `and` belongs to the next statement,\nnot this list." }, { @@ -260,7 +260,7 @@ "calls": [ "_parse_record_field" ], - "source": "parser.py:2204", + "source": "parser.py:2224", "surface": "One or more ` as ` pairs, `and`-separated \u2014 the body\nof a record literal (`remember a record called r with x as 1 and y as\n2`). Delegates each pair to `_parse_record_field`; stops chaining on an\n`and` that starts a new operation, the same rule `_parse_record_fields`'\nsibling collectors (`_parse_operation_sequence`, `_parse_remember_with`)\nuse for the same reason: a trailing `and require ...` belongs to the\nnext statement, not this field list." }, { @@ -275,7 +275,7 @@ "_parse_value", "_hyphenate" ], - "source": "parser.py:2227", + "source": "parser.py:2247", "surface": "` as ` \u2014 one field of a record literal. `` must\nbe an unquoted, non-reserved name (a quoted field name is a specific\nparse error pointing at the hyphenated form instead, since field names\ncan't contain spaces); the value after `as` is a full `_parse_value`\nexpression, so a field may hold arithmetic, not just a bare literal." }, { @@ -293,7 +293,7 @@ "_parse_verb_statement", "_consume_parameter_arg" ], - "source": "parser.py:2265", + "source": "parser.py:2285", "surface": "`from` in `remember` (v1b \u00a743):\nnext token is VERB -> result capture via recursive descent\nnext token is a known comp -> composition call (with optional param)\notherwise -> value expression, which may include\n arithmetic (Infrastructure Era)" }, { @@ -312,7 +312,7 @@ "_parse_extrema", "_hyphenate" ], - "source": "parser.py:2330", + "source": "parser.py:2350", "surface": "`show []` \u2014 the display verb, with the widest fan-out of\ntarget shapes of any verb parser:\n\n show -> current iterator item (target=None)\n show highest/lowest ... -> delegates to _parse_extrema\n show \"\" -> literal display (QuotedString)\n show -> a remembered name\n show of -> single field access\n show and ... -> multi-field (only inside `each`)\n show each -> current item, `each`-as-pronoun form\n\n`in_each` gates two shapes that only make sense inside an `each` body:\nmulti-field display (`and`-chained field names collecting into\n`extra_fields`, itself stopping the same way `_parse_operation_sequence`\ndoes when the next `and` starts a new statement) and the `each` pronoun\ntarget. Every quoted-string target position that could plausibly be a\nfield name instead raises the same \"field names can't have spaces, try\na hyphenated name\" error the record and condition parsers raise for the\nidentical mistake." }, { @@ -324,7 +324,7 @@ "_consume_optional_article", "_consume_target" ], - "source": "parser.py:2484", + "source": "parser.py:2504", "surface": "`count [a/an/the] ` \u2014 the number of items in a list.\n`_consume_optional_article` eats a leading `a`/`an`/`the` if present\n(purely cosmetic, e.g. `count the orders`); the target name itself is\nresolved by `_consume_target`, shared with `sum`/`sort`/`compare`/etc." }, { @@ -336,7 +336,7 @@ "_consume_optional_article", "_consume_target" ], - "source": "parser.py:2495", + "source": "parser.py:2515", "surface": "`sum [a/an/the] ` \u2014 the arithmetic total of a numeric list.\nSame optional-article and target-resolution shape as `_parse_count`." }, { @@ -351,7 +351,7 @@ "_parse_value", "_consume_target" ], - "source": "parser.py:2509", + "source": "parser.py:2529", "surface": "`add [article]? to [article]? `." }, { @@ -366,7 +366,7 @@ "_parse_value", "_consume_target" ], - "source": "parser.py:2533", + "source": "parser.py:2553", "surface": "`remove [article]? from [article]? `." }, { @@ -382,7 +382,7 @@ "_consume_target", "_parse_number" ], - "source": "parser.py:2561", + "source": "parser.py:2581", "surface": "`weakens [article]? over `." }, { @@ -397,7 +397,7 @@ "_parse_or_condition", "_try_consume_unless_exception" ], - "source": "parser.py:2597", + "source": "parser.py:2617", "surface": "`require ` \u2014 enforcement verb.\n\nThe condition uses the same parser path as `choose if` /\n`where` \u2014 `_parse_or_condition`, which supports compound\n`and`/`or`, `not`, `includes`, field access via `of`, and the\nfull set of comparison operators. The clause-context stack is\npushed so condition sub-parsers can see they're inside a\n`require` clause if they ever need to.\n\nv8a \u00a749 \u2014 a second parse shape, `require each {name} in {list}\n{condition}`, is selected when the next token is the `each` verb." }, { @@ -413,7 +413,7 @@ "_consume_target", "_parse_or_condition" ], - "source": "parser.py:2632", + "source": "parser.py:2652", "surface": "Parse `require each {name} in {list} {condition}` (v8a \u00a749).\n\n`each` has been peeked but not consumed. Consume it, then the\nbinding name (UNKNOWN, reserved-word-excluded), the positional `in`\n(UNKNOWN token, not a connective), the collection name, and finally\nthe condition via the unified `_parse_or_condition` path.\n\nThe `require-each` clause is pushed so `_parse_simple_condition`\ntreats a field-elided condition (one that begins with `is` /\n`includes`) as referring to the current element via `EachPronoun`." }, { @@ -425,7 +425,7 @@ "_parse_or_condition", "_try_consume_unless_exception" ], - "source": "parser.py:2693", + "source": "parser.py:2713", "surface": "`forbid ` \u2014 prohibition verb.\n\nSame condition grammar as `require`. The difference is purely\nin runtime behavior: `require` halts on false, `forbid` halts\non true." }, { @@ -437,7 +437,7 @@ "_parse_or_condition", "_try_consume_unless_exception" ], - "source": "parser.py:2719", + "source": "parser.py:2739", "surface": "`permit ` \u2014 explicit permission verb.\n\nSame condition grammar as `require`/`forbid`. The difference\nis purely in runtime behavior: `permit` emits on true, never\nhalts." }, { @@ -452,7 +452,7 @@ "_consume_target", "_parse_value" ], - "source": "parser.py:2745", + "source": "parser.py:2765", "surface": "`assign [article]? to `." }, { @@ -464,7 +464,7 @@ "_parse_or_condition", "_try_consume_unless_exception" ], - "source": "parser.py:2777", + "source": "parser.py:2797", "surface": "`expect ` \u2014 tracked anticipation verb.\n\nSame condition grammar as `require`. The difference is purely\nin runtime behavior: `require` halts on failure, `expect` reports\nand continues." }, { @@ -482,7 +482,7 @@ "_consume_target", "_hyphenate" ], - "source": "parser.py:2803", + "source": "parser.py:2823", "surface": "`sort [article]? by [in]? [reverse]?`.\n\nThe optional `in` before `reverse` is NOT a reserved word \u2014 the\nparser consumes it as an UNKNOWN token only when `reverse` follows.\nThis keeps the natural English phrasing (`sort by total in reverse`)\nwhile leaving `in` available as a user variable name elsewhere." }, { @@ -496,7 +496,7 @@ "_consume_optional_article", "_consume_target" ], - "source": "parser.py:2885", + "source": "parser.py:2905", "surface": "`compare [article]? to [article]? `." }, { @@ -513,7 +513,7 @@ "_parse_value", "_hyphenate" ], - "source": "parser.py:2917", + "source": "parser.py:2937", "surface": "`transform of by ` (record-field mode)\nor `transform by ` (scalar-list mode).\n\nDisambiguation: after consuming the first name, peek for `of`. If\npresent, the first name is the field and the name after `of` is the\ntarget (record-field mode). If absent, the first name is the target\n(scalar-list mode)." }, { @@ -527,7 +527,7 @@ "_consume_optional_article", "_parse_number" ], - "source": "parser.py:3010", + "source": "parser.py:3030", "surface": "`gather [a/an/the] from to [by ]` \u2014\ngenerates a numeric list bound to ``. `from`/`to` bounds are\nrequired NUMBER tokens; the optional `by ` step must be\nstrictly positive \u2014 direction comes from whether `from` or `to` is\nlarger, not from the step's sign, so a non-positive step is rejected\nwith an error naming that constraint rather than silently reversing." }, { @@ -539,7 +539,7 @@ "_consume_optional_article", "_parse_one_operation" ], - "source": "parser.py:3074", + "source": "parser.py:3094", "surface": "`each [a/an/the] ` \u2014 iterates ``,\nrunning `` (one full operation, via `_parse_one_operation`) per\nitem. Pushes the `\"each\"` clause context around the action parse so\ndownstream parsers can detect they're inside an `each` body \u2014\n`_parse_show`'s multi-field and `each`-pronoun target forms, and\n`_parse_simple_condition`'s implicit-subject elision, both key off this\nflag rather than a separate parameter threaded through every call." }, { @@ -553,7 +553,7 @@ "_parse_choose_branch", "_parse_operation_sequence" ], - "source": "parser.py:3118", + "source": "parser.py:3138", "surface": "Parse `choose if : [otherwise [if :] ]*`.\n\nThe colon is the context switch between condition mode and action\nmode (\u00a7101). Conditions reuse the where-clause condition path\n(`_parse_or_condition`) so `and`/`or` compose as in `where` \u2014 they\nterminate at `:`. Actions reuse `_parse_operation_sequence` so `and\n` sequences operations within a branch \u2014 it terminates at\n`otherwise` and at end of input." }, { @@ -567,7 +567,7 @@ "_parse_operation_sequence", "_parse_or_condition" ], - "source": "parser.py:3161", + "source": "parser.py:3181", "surface": "Parse `: ` for a single `choose` branch. The\n`leader` argument feeds the error message so the user sees whether\nthey were inside the initial `choose if` or a chained `otherwise if`." }, { @@ -578,7 +578,7 @@ "calls": [ "_parse_filter_shape" ], - "source": "parser.py:3187", + "source": "parser.py:3207", "surface": "`filter [a/an/the] where ` \u2014 delegates entirely\nto `_parse_filter_shape`, the shape `filter` and `keep` share\nstructurally at parse time (v2a \u00a767); the two verbs differ only in\nthe interpreter, not in what's parsed here." }, { @@ -589,7 +589,7 @@ "calls": [ "_parse_filter_shape" ], - "source": "parser.py:3197", + "source": "parser.py:3217", "surface": "v2a \u00a767. Shares the target + where + condition shape with filter." }, { @@ -604,7 +604,7 @@ "_consume_target", "_parse_or_condition" ], - "source": "parser.py:3203", + "source": "parser.py:3223", "surface": "Shared parser for the filter/keep shape: optional article + target\n+ 'where' + condition. v2a \u00a767 keeps the two verbs structurally\nidentical at parse time; the difference lives in the interpreter." }, { @@ -618,7 +618,7 @@ "calls": [ "_parse_and_condition" ], - "source": "parser.py:3224", + "source": "parser.py:3244", "surface": "` or [or ...]` \u2014 the loosest level\nof the condition grammar; the entry point every `where`/`keep`/`filter`/\n`require`/`require each`/`forbid`/`permit`/`expect`/`choose if`/`when`/\n`unless`/`define` body condition parses through.\n\nLeft-associative: builds a left-leaning chain of `CompoundConditionNode`s\nwith `connector=\"or\"` over one or more `_parse_and_condition` operands,\nso `and` (parsed one level down) always binds tighter than `or` \u2014 an\n`or` clause never straddles an `and` boundary. Stops before a following\n`or` when the token after it is a VERB, since that shape is operation\nsequencing (`... or require ...`), not a further condition operand.\nMixing `and` and `or` within one clause is legal to parse but resolved\nby the amber confirmation flow at the caller (v1a \u00a730), not decided here." }, { @@ -632,7 +632,7 @@ "calls": [ "_parse_simple_condition" ], - "source": "parser.py:3253", + "source": "parser.py:3273", "surface": "` and [and ...]` \u2014 the middle level\nof the condition grammar, called only from `_parse_or_condition`.\n\nLeft-associative, the same shape as `_parse_or_condition` one level up:\nbuilds a left-leaning chain of `CompoundConditionNode`s with\n`connector=\"and\"` over one or more `_parse_simple_condition` operands \u2014\nthe leaves. Binds tighter than `or` purely by virtue of being the level\n`_parse_or_condition` delegates to before looking for its own connective.\nStops before a following `and` when the token after it is a VERB, the\nsame operation-sequencing guard `_parse_or_condition` applies for `or`." }, { @@ -651,7 +651,7 @@ "_parse_extrema", "_hyphenate" ], - "source": "parser.py:3279", + "source": "parser.py:3299", "surface": "` is ` \u2014 the leaf of the condition grammar.\n\nShared by `where`, `keep`, `filter`, `require`, `require each`, `forbid`,\n`permit`, `expect`, `choose if`, `when`, `unless`, and `define` bodies.\nThe subject may be a field name, `each` (the iterator pronoun), or a\n` of ` access. Inside a `define` body or `require each`,\nthe subject may be elided and binds to the implicit `each` pronoun.\n\nComparison forms, with boundary behavior stated because it is the thing\nmost often gotten wrong:\n\n is equality (bare `is`, no following operator)\n is equal to equality, explicit\n is not equal to inequality\n is above STRICT > \u2014 FALSE at exactly \n is below STRICT < \u2014 FALSE at exactly \n is not above INCLUSIVE <= \u2014 TRUE at exactly \n is not below INCLUSIVE >= \u2014 TRUE at exactly \n is within of |subject - | <= ; the bound is inclusive\n includes membership\n not includes non-membership\n is applies a `define`d predicate\n is not applies its negation\n\nThere is no `is above or equal to`. `is not above` IS that operator, and\n`is not below` is its mirror. An inclusive threshold must be written with\nthe inclusive operator and the source's own number \u2014 never by shifting the\nnumber by one. `is above ` is equivalent to `is not below ` only\nwhen the field is integer-valued, and the parser cannot know that it is.\n\nOrdered comparisons require both operands to be the same ordered type;\nnumbers and dates each compare within their own type and never across.\n`is within` measures whole days when the subject is a date.\n\nA `define`d predicate is never a standalone condition: it is always applied\nas ` is ` or ` is not `, including after\n`unless`. A bare predicate name where a condition is expected is a parse\nerror.\n\nCompound conditions are assembled by the callers `_parse_or_condition` and\n`_parse_and_condition`; `and` binds tighter than `or`, and mixing them in\none clause raises the amber confirmation prompt rather than guessing." }, { @@ -669,7 +669,7 @@ "calls": [ "_parse_number" ], - "source": "parser.py:3508", + "source": "parser.py:3533", "surface": "Parse the tolerance operand of `is within of `\n(issue #19) as a single bare atom \u2014 a number literal or a name.\n\nThis deliberately does NOT go through `_parse_value`: that would let\n`_maybe_field_access` swallow the structural `of` (reading the\ntolerance and target as a single ` of ` field access),\nso `within tol of target` would lose its `of`. A name resolves to its\nnumeric value at evaluation time (a `BareWord`, like any other\nright-hand condition operand)." }, { @@ -680,7 +680,7 @@ "calls": [ "_parse_additive" ], - "source": "parser.py:3553", + "source": "parser.py:3578", "surface": "Parse a value expression, which may include arithmetic.\n\nPrecedence (Infrastructure Era \u2014 PEMDAS):\n Tier 1 (lowest): plus, minus \u2014 left-associative\n Tier 2 (highest): multiplied_by, divided_by \u2014 left-associative\n Atoms: NUMBER, UNKNOWN (with optional `of`), QUOTED_STRING\n\nEvery call site that previously consumed a single value via\n`_parse_value` now transparently supports arithmetic expressions in\nthat position." }, { @@ -693,7 +693,7 @@ "calls": [ "_parse_multiplicative" ], - "source": "parser.py:3568", + "source": "parser.py:3593", "surface": "Tier 1 (loosest) arithmetic: `plus`/`minus`, left-associative over\none or more `_parse_multiplicative` operands. Called only from\n`_parse_value`; see that function's docstring for the full precedence\ntable. `_ADDITIVE_OPS` is the operator-token value set this level\nmatches on." }, { @@ -706,7 +706,7 @@ "calls": [ "_parse_atom" ], - "source": "parser.py:3590", + "source": "parser.py:3615", "surface": "Tier 2 (tightest) arithmetic: `multiplied by`/`divided by`,\nleft-associative over one or more `_parse_atom` operands \u2014 binds\ntighter than `_parse_additive` purely by sitting one level below it in\nthis same recursive-descent chain. `_MULTIPLICATIVE_OPS` is the\noperator-token value set this level matches on." }, { @@ -721,7 +721,7 @@ "_consume_optional_article", "_consume_target" ], - "source": "parser.py:3612", + "source": "parser.py:3637", "surface": "v25 VW-Q1/Q2 \u2014 `highest`/`lowest` list-extrema selector.\n\nForm A (flat lists): highest of \nForm B (record lists): highest of \n\nThe OPERATOR token (`highest`/`lowest`) has been peeked, not\nconsumed, by the caller." }, { @@ -749,7 +749,7 @@ "_validate_calendar_date", "_hyphenate" ], - "source": "parser.py:3658", + "source": "parser.py:3683", "surface": "Consume a single value token (NUMBER, UNKNOWN, or QUOTED_STRING),\noptionally extended by `of ` for field access (v2b \u00a777).\n\nVocabulary words in value position are rejected per v1c \u00a746 (extended\nby v2c \u00a789 \u2014 the error message now suggests quoting as the fix)." }, { @@ -758,7 +758,7 @@ "opens_with": [], "produces": [], "calls": [], - "source": "parser.py:3760", + "source": "parser.py:3785", "surface": "Convert a NUMBER token's raw text to a Python `int` or `float` \u2014 a\ndecimal point in the source text is the sole signal for `float`; any\nother numeric text (including scientific-notation forms without a\nliteral `.`) becomes `int`. Not itself a grammar production \u2014 it\ntakes a plain string, not a `TokenStream`, and every caller has\nalready matched a NUMBER token before reaching here." } ] diff --git a/src/liminate/parser.py b/src/liminate/parser.py index 330ac6e..e7bdd49 100644 --- a/src/liminate/parser.py +++ b/src/liminate/parser.py @@ -213,6 +213,26 @@ class QuotedString(ASTNode): displays the literal text (§88).""" content: str + # v31 §82 — set when this literal sits in a position where dropping + # its quotes would not merely change its spelling but change what it + # *is*: the right of `is`, where a bare word matching a declared + # predicate applies that predicate instead of testing equality. + # + # v2c §90's conditional quoting asks whether a word is *reserved*, + # which a predicate name never is — it is declared, not reserved. So + # the quotes that made `is "large"` mean equality were dropped on the + # way out and the canonical form re-parsed as a predicate + # application. The parser is the only place that knows the predicate + # table, so it records the collision here and the renderer honours it + # (the same arrangement v2d §96 reached for composition-call args, + # which are name-resolvable in exactly the same way). + # + # Inert for equality (`compare=False`): two `"large"` literals are + # the same value whether or not one of them has to be written with + # quotes to stay that way. This keeps `parse(render(ast)) == ast` + # meaning what it meant before. + shadows_a_predicate: bool = field(default=False, compare=False) + @dataclass class FieldAccessNode(ASTNode): @@ -3502,6 +3522,11 @@ def _finish_simple_condition( # `is` as equality operator: consume a value. value = _parse_value(stream) + if isinstance(value, QuotedString) and value.content in preds: + # The quotes are what stopped the branch above from reading this + # as a predicate application, so they are load-bearing and the + # canonical form has to keep them (§82; see QuotedString). + value.shadows_a_predicate = True return ConditionNode(field=field_node, op="is", value=value) diff --git a/src/liminate/renderer.py b/src/liminate/renderer.py index 671d52c..4127259 100644 --- a/src/liminate/renderer.py +++ b/src/liminate/renderer.py @@ -166,6 +166,16 @@ def _render_node(node: ASTNode) -> str: # v2c §90: same conditional-quoting rule applies regardless of # whether the source used quotes — `"active"` and `active` both # render bare; `"in progress"` keeps its quotes. + # + # Unless the parser marked the quotes load-bearing: on the right + # of `is`, a bare word that names a declared predicate applies + # that predicate, so normalising `is "large"` to `is large` + # would emit a different program, not a tidier spelling of the + # same one. §90's safety test cannot see this on its own — it + # asks whether a word is reserved, and a predicate name is + # declared rather than reserved. + if node.shadows_a_predicate: + return f'"{node.content}"' return _emit_string(node.content) if isinstance(node, NameRef): return node.name diff --git a/tests/fixtures/conformance-0.18.1.json b/tests/fixtures/conformance-0.18.1.json index 3a4425c..f85592f 100644 --- a/tests/fixtures/conformance-0.18.1.json +++ b/tests/fixtures/conformance-0.18.1.json @@ -1160,6 +1160,40 @@ ], "source": "remember a number called total with 200\ndefine large: is above 50\ndefine enormous: is large\nrequire total is enormous" }, + { + "id": "a quoted string that shadows a predicate keeps its quotes", + "results": [ + { + "canonical": "remember a string called total with large", + "nodes": [ + "QuotedString", + "RememberValueNode" + ], + "status": "success" + }, + { + "canonical": "define large: each is above 50", + "nodes": [ + "ConditionNode", + "DefineNode", + "EachPronoun", + "NumberLiteral" + ], + "status": "success" + }, + { + "canonical": "require total is \"large\"", + "nodes": [ + "ConditionNode", + "NameRef", + "QuotedString", + "RequireNode" + ], + "status": "success" + } + ], + "source": "remember a string called total with \"large\"\ndefine large: is above 50\nrequire total is \"large\"" + }, { "id": "makes define non-breaking for every program written before it", "results": [ diff --git a/tests/fixtures/conformance_corpus.txt b/tests/fixtures/conformance_corpus.txt index af583f8..937c9d8 100644 --- a/tests/fixtures/conformance_corpus.txt +++ b/tests/fixtures/conformance_corpus.txt @@ -182,6 +182,15 @@ define large: is above 50 define enormous: is large require total is enormous +# quoting is the escape hatch of v31 §82 — the quotes are what stop this from +# being a predicate application, and v2c §90's conditional quoting dropped +# them, so the canonical form re-parsed as a different program from the same +# text. Only the last comment line before a program becomes the case id. +# a quoted string that shadows a predicate keeps its quotes +remember a string called total with "large" +define large: is above 50 +require total is "large" + # a bareword that is not a predicate stays string equality, which is what # makes define non-breaking for every program written before it remember a number called total with 75 diff --git a/tests/test_define.py b/tests/test_define.py index f327b6a..acbf43c 100644 --- a/tests/test_define.py +++ b/tests/test_define.py @@ -321,6 +321,38 @@ def test_render_predicate_condition_round_trip(): assert again == ast +def test_quoted_predicate_collision_keeps_its_quotes_through_canonical(): + """The escape hatch of §82 has to survive rendering, or it is not one. + + `is "large"` parses as string equality precisely because the quotes + suppress predicate application (see + test_quoted_string_forces_equality_even_if_predicate_name_matches). + v2c §90's conditional quoting then dropped those quotes on the way + out, because its safety test predates v31 and asks only whether the + word is *reserved* — and a predicate name is not reserved, it is + declared. The canonical form re-parsed as a predicate application: + the same text, a different program. + """ + ast = _parse('require total is "large"', {"large"}) + assert isinstance(ast.condition, ConditionNode) + + rendered = render(ast) + assert rendered == 'require total is "large"' + + again = _parse(rendered, {"large"}) + assert isinstance(again.condition, ConditionNode), ( + f"canonical form re-parsed as {type(again.condition).__name__}" + ) + assert again == ast + + +def test_conditional_quoting_is_untouched_where_no_predicate_collides(): + """The fix is scoped to the collision. A quoted single word that + shadows nothing still normalises to bare, exactly as v2c §90 says.""" + ast = _parse('require status is "active"', {"large"}) + assert render(ast) == "require status is active" + + def test_define_with_because_round_trips(): ast = _parse('define overdue: days-late is above 30 because "policy 4.2"') rendered = render(ast)