Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion src/liminate/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
ConditionNode,
CountNode,
DateLiteral,
DefineNode,
EachNode,
EachPronoun,
ExpectNode,
Expand All @@ -73,6 +74,7 @@
NameRef,
NumberLiteral,
PackVerbNode,
PredicateApplicationNode,
RemoveNode,
QuotedString,
RememberCompositionNode,
Expand Down Expand Up @@ -144,7 +146,7 @@ class SymbolEntry:
_TYPE_NAMES = frozenset({
"number", "string", "record", "date",
"list_of_numbers", "list_of_strings", "list_of_records", "list_of_dates",
"composition",
"composition", "predicate",
})


Expand Down Expand Up @@ -433,6 +435,8 @@ def _check(
)
elif isinstance(node, RememberCompositionNode):
pass # §23 line 466: names checked at call time, not here.
elif isinstance(node, DefineNode):
_check_define(node, symtab)
elif isinstance(node, ShowNode):
_check_show(node, symtab, iterator)
elif isinstance(node, FilterNode):
Expand Down Expand Up @@ -1292,6 +1296,10 @@ def _check_condition(
_check_condition(cond.left, symtab, iterator)
_check_condition(cond.right, symtab, iterator)
return
if isinstance(cond, PredicateApplicationNode):
_check_predicate_application(cond, symtab)
_resolve_field(cond.subject, symtab, iterator)
return
if not isinstance(cond, ConditionNode):
raise _SemanticError("Unexpected condition shape.")

Expand Down Expand Up @@ -1752,6 +1760,10 @@ def _check_choose_condition(
_check_choose_condition(cond.left, symtab)
_check_choose_condition(cond.right, symtab)
return
if isinstance(cond, PredicateApplicationNode):
_check_predicate_application(cond, symtab)
_resolve_choose_operand(cond.subject, symtab)
return
if not isinstance(cond, ConditionNode):
raise _SemanticError("Unexpected condition shape.")
field_type, field_label = _resolve_choose_operand(cond.field, symtab)
Expand Down Expand Up @@ -1780,6 +1792,67 @@ def _check_choose_condition(
raise _SemanticError(f"Unknown comparison operator '{cond.op}'.")


# ---------------------------------------------------------------------------
# define / predicate application (Definitional Era, v31)
# ---------------------------------------------------------------------------

_VALID_CONDITION_OPS = frozenset({
"is", "above", "below", "equal_to", "within", "includes", "not_includes",
"not_above", "not_below", "not_equal_to",
})


def _check_predicate_application(
cond: PredicateApplicationNode,
symtab: dict[str, SymbolEntry],
) -> None:
"""v31 §87 — a predicate application must reference a name already
registered by an earlier `define` (forward-declaration only, mirroring
`_check_composition_call_shape`'s existence check for compositions —
both rely on the symbol table already reflecting every statement
executed so far)."""
if (
cond.predicate_name not in symtab
or symtab[cond.predicate_name].type != "predicate"
):
raise _SemanticError(
f"I don't know a definition for '{cond.predicate_name}'. "
f"Use 'define {cond.predicate_name}: ...' to create one."
)


def _check_define(node: DefineNode, symtab: dict[str, SymbolEntry]) -> None:
"""v31 §87 — validate a predicate definition's body structurally.

The subject is unknown until application time (it could later be a
record, a scalar, or a list element), so this checks only condition
shape and operator validity — the same deferral `require each` gives
a right-hand BareWord that isn't yet in the symbol table. It does
NOT resolve the body's field/value types against the symbol table
(there is no iterator context to resolve them against).
"""
_check_define_condition(node.condition, symtab)


def _check_define_condition(
cond: ASTNode,
symtab: dict[str, SymbolEntry],
) -> None:
if isinstance(cond, CompoundConditionNode):
_check_define_condition(cond.left, symtab)
_check_define_condition(cond.right, symtab)
return
if isinstance(cond, PredicateApplicationNode):
# v31 §84 — predicate composition: a predicate body may reference
# another predicate. Forward-declaration applies here too.
_check_predicate_application(cond, symtab)
return
if not isinstance(cond, ConditionNode):
raise _SemanticError("Unexpected condition shape.")
if cond.op not in _VALID_CONDITION_OPS:
raise _SemanticError(f"Unknown comparison operator '{cond.op}'.")


# ---------------------------------------------------------------------------
# when / finish (v3a §108, §109, §112)
# ---------------------------------------------------------------------------
Expand Down
43 changes: 37 additions & 6 deletions src/liminate/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@

from .lexer import LexError, leading_indent, tokenize
from .parser import (
DefineNode,
RememberCompositionNode,
SequenceNode,
WhenNode,
Expand Down Expand Up @@ -145,6 +146,7 @@ def _validate_and_render(source: str) -> tuple[list[str], list[Any], str | None]
canonical: list[str] = []
asts: list[Any] = []
composition_names: set[str] = set()
predicate_names: set[str] = set()
topic: str | None = None
first_eligible_seen = False

Expand All @@ -160,12 +162,21 @@ def _validate_and_render(source: str) -> tuple[list[str], list[Any], str | None]
# Meta-Structural Era: an `about` declaration is consumed as
# metadata before the normal pipeline. It must be the first
# eligible (non-blank, non-comment) line and may appear at most
# once (MS-Q1).
# once (MS-Q1). Definitional Era (v31): `define` also lexes as
# TokenType.DECLARATION but is NOT first-line-only — it's a
# normal program statement, so this check is narrowed to `about`
# specifically and `define` falls through to the regular
# tokenize/reorder/parse pipeline below (mirrors run.py's
# identical first-line dispatch).
try:
decl_tokens = tokenize(line)
except LexError as e:
raise BuildError(f"line {i + 1}: {e.message}") from None
if decl_tokens and decl_tokens[0].type is TokenType.DECLARATION:
if (
decl_tokens
and decl_tokens[0].type is TokenType.DECLARATION
and decl_tokens[0].value == "about"
):
if first_eligible_seen or topic is not None:
raise BuildError(
f"line {i + 1}: 'about' is a declaration that must be "
Expand Down Expand Up @@ -216,7 +227,8 @@ def _validate_and_render(source: str) -> tuple[list[str], list[Any], str | None]
if is_when_header:
action_lines, next_i = _collect_when_block(lines, i)
ast = _parse_when_block_from_source(
line, action_lines, composition_names, line_no=i + 1,
line, action_lines, composition_names, predicate_names,
line_no=i + 1,
)
asts.append(ast)
try:
Expand All @@ -226,11 +238,12 @@ def _validate_and_render(source: str) -> tuple[list[str], list[Any], str | None]
f"line {i + 1}: failed to render canonical form ({e})"
) from None
_collect_composition_names(ast, composition_names)
_collect_predicate_names(ast, predicate_names)
i = next_i
continue

# Regular statement.
ast = _parse_line(line, composition_names, line_no=i + 1)
ast = _parse_line(line, composition_names, predicate_names, line_no=i + 1)
asts.append(ast)
try:
canonical.append(render(ast))
Expand All @@ -239,20 +252,23 @@ def _validate_and_render(source: str) -> tuple[list[str], list[Any], str | None]
f"line {i + 1}: failed to render canonical form ({e})"
) from None
_collect_composition_names(ast, composition_names)
_collect_predicate_names(ast, predicate_names)
i += 1

return canonical, asts, topic


def _parse_line(line: str, comp_names: set[str], *, line_no: int) -> Any:
def _parse_line(
line: str, comp_names: set[str], pred_names: set[str], *, line_no: int,
) -> Any:
try:
tokens = tokenize(line)
except LexError as e:
raise BuildError(f"line {line_no}: {e.message}") from None
reordered = reorder(tokens)
if isinstance(reordered, LiminateResult):
raise BuildError(_fmt_result(reordered, line_no))
ast = parse(reordered, composition_names=comp_names)
ast = parse(reordered, composition_names=comp_names, predicate_names=pred_names)
if isinstance(ast, LiminateResult):
raise BuildError(_fmt_result(ast, line_no))
return ast
Expand All @@ -262,6 +278,7 @@ def _parse_when_block_from_source(
header_line: str,
action_lines: list[str],
comp_names: set[str],
pred_names: set[str],
*,
line_no: int,
) -> Any:
Expand Down Expand Up @@ -289,6 +306,7 @@ def _parse_when_block_from_source(
ast = parse_when_block(
header_reordered, action_token_lists,
composition_names=comp_names,
predicate_names=pred_names,
)
if isinstance(ast, LiminateResult):
raise BuildError(_fmt_result(ast, line_no))
Expand Down Expand Up @@ -350,6 +368,19 @@ def _collect_composition_names(ast: Any, sink: set[str]) -> None:
_collect_composition_names(ast.action, sink)


def _collect_predicate_names(ast: Any, sink: set[str]) -> None:
"""Definitional Era (v31) — walk an AST collecting DefineNode.name
values so later statements that apply those predicates parse
successfully. Mirrors _collect_composition_names exactly."""
if isinstance(ast, DefineNode):
sink.add(ast.name)
elif isinstance(ast, SequenceNode):
for op in ast.operations:
_collect_predicate_names(op, sink)
elif isinstance(ast, WhenNode):
_collect_predicate_names(ast.action, sink)


def _contains_when(asts: list[Any]) -> bool:
return any(isinstance(a, WhenNode) for a in asts)

Expand Down
52 changes: 52 additions & 0 deletions src/liminate/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
ConditionNode,
CountNode,
DateLiteral,
DefineNode,
EachNode,
EachPronoun,
ExpectNode,
Expand All @@ -90,6 +91,7 @@
NameRef,
NumberLiteral,
PackVerbNode,
PredicateApplicationNode,
QuotedString,
RemoveNode,
RememberCompositionNode,
Expand Down Expand Up @@ -250,6 +252,13 @@ def _walk_dependencies(
if node.target.name not in seen:
seen.add(node.target.name)
ordered.append(node.target.name)
elif isinstance(node, PredicateApplicationNode):
# Definitional Era (v31) — a `when <subject> is <predicate>`
# handler depends on its subject, same as any other condition
# leaf. The predicate body itself is not walked: its fields
# resolve against the subject at application time, not against
# top-level symbols, so they aren't independent dependencies.
_walk_dependencies(node.subject, seen, ordered)
# Literals (NumberLiteral, BareWord, QuotedString, EachPronoun)
# contribute no dependencies — they evaluate to themselves.

Expand Down Expand Up @@ -642,6 +651,8 @@ def _exec_op(
return _exec_remember_record(node, symtab, current_item)
if isinstance(node, RememberCompositionNode):
return _exec_remember_composition(node, symtab)
if isinstance(node, DefineNode):
return _exec_define(node, symtab)
if isinstance(node, ShowNode):
return _exec_show(node, symtab, current_item)
if isinstance(node, FilterNode):
Expand Down Expand Up @@ -1326,6 +1337,30 @@ def _exec_remember_composition(
return []


def _exec_define(node: DefineNode, symtab: dict[str, SymbolEntry]) -> list[str]:
"""Definitional Era (v31) — register a named predicate. Stores the
condition-body AST under the predicate name (type "predicate"),
mirroring how `remember how to` stores a composition body.
Redefinition overwrites (v1d §58 mutation semantics). The warning is
emitted here, not in the analyzer: `_check`'s per-statement functions
only ever raise or pass silently, with no channel for a non-blocking
informational signal, while `_exec_op` functions already return
`list[str]` output lines for exactly this purpose (the same channel
`permit` uses to emit without halting)."""
output: list[str] = []
if node.name in symtab and symtab[node.name].type == "predicate":
output.append(
f"Predicate '{node.name}' is being redefined — the earlier "
f"definition will be replaced."
)
symtab[node.name] = SymbolEntry(
name=node.name,
value=node.condition,
type="predicate",
)
return output


# ---------------------------------------------------------------------------
# show
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2224,6 +2259,23 @@ def _eval_condition(
return _within_tolerance(field_val, tolerance, target)
value_val = _eval_value(cond.value, current_item, symtab)
return _apply_op(cond.op, field_val, value_val)
if isinstance(cond, PredicateApplicationNode):
# Definitional Era (v31) — apply a named predicate. The body is
# re-evaluated on every application (never cached), so a
# predicate stays live: redefining a field it reads (e.g.
# `cutoff`) changes the verdict of later applications. The
# subject resolves to the current-item context for the nested
# evaluation: EachPronoun -> current_item unchanged; a NameRef to
# a record -> that record's dict, so the body's field references
# bind to the record's own fields, not the outer current_item.
entry = symtab.get(cond.predicate_name)
if entry is None or entry.type != "predicate":
raise _RuntimeError(
f"I don't have a definition for '{cond.predicate_name}'."
)
subject_val = _eval_field(cond.subject, current_item, symtab)
result = _eval_condition(entry.value, subject_val, symtab)
return (not result) if cond.negated else result
raise _RuntimeError(f"Can't evaluate condition {type(cond).__name__}.")


Expand Down
Loading