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
34 changes: 34 additions & 0 deletions src/liminate/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1857,6 +1857,15 @@ def _check_define(node: DefineNode, symtab: dict[str, SymbolEntry]) -> None:
NOT resolve the body's field/value types against the symbol table
(there is no iterator context to resolve them against).
"""
# Surface-form self-reference runs first and regardless of whether
# `node.name` is already in `symtab`. When `p` has never been defined,
# `is p` parses to a BareWord-equality ConditionNode, not a
# PredicateApplicationNode — so the reference-graph walk below never
# sees it (there is no predicate reference in the AST to walk). Left
# unchecked, `define p: is p` on a first-ever definition silently
# means "equals the text 'p'" instead of erroring, even though the
# line reads as self-referential to a human.
_check_self_referential_define(node.name, node.condition)
_check_define_condition(node.condition, symtab)
# Cycle check runs only after the forward-declaration check above has
# passed clean — every predicate_name reachable in one hop is already
Expand All @@ -1867,6 +1876,31 @@ def _check_define(node: DefineNode, symtab: dict[str, SymbolEntry]) -> None:
_check_predicate_definition_cycle(node.name, node.condition, symtab)


def _check_self_referential_define(name: str, cond: ASTNode) -> None:
"""Reject `define p: is p` — a body that compares against a bare word
identical to the name being defined.

When `p` is not yet a known predicate, the parser resolves `is p` to
a string-equality ConditionNode with value=BareWord('p'), not to a
PredicateApplicationNode — so the reference-graph walk in
_find_predicate_cycle never sees it. The line reads as a self-
reference and silently means "equals the text 'p'", so it is rejected
here on the surface form, before that ambiguity can resolve either
way.
"""
if isinstance(cond, CompoundConditionNode):
_check_self_referential_define(name, cond.left)
_check_self_referential_define(name, cond.right)
return
if not isinstance(cond, ConditionNode):
return
for operand in (cond.field, cond.value, cond.value2):
if isinstance(operand, BareWord) and operand.word == name:
raise _SemanticError(f"Definition '{name}' can't refer to itself.")
if isinstance(operand, NameRef) and operand.name == name:
raise _SemanticError(f"Definition '{name}' can't refer to itself.")


def _check_define_condition(
cond: ASTNode,
symtab: dict[str, SymbolEntry],
Expand Down
70 changes: 70 additions & 0 deletions tests/test_define.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,73 @@ def test_legal_ten_deep_predicate_chain_still_passes():
s.run_line("remember a number called x with 5")
verdict = s.run_line("require x is p10")
assert verdict.status is ResultStatus.SUCCESS


# ---------------------------------------------------------------------------
# Self-referential define on a first-ever definition (no prior `p`).
#
# `_find_predicate_cycle` above only ever sees a PredicateApplicationNode —
# it cannot catch this case, because when `p` has never been defined, `is p`
# parses to a BareWord-equality ConditionNode instead (see
# test_direct_self_reference_on_first_definition_hits_forward_declaration_check
# for the decoupled construction that demonstrates why). Real `Session` usage
# hits string equality, not a predicate reference — so it needs its own
# surface-form check, `_check_self_referential_define`, run unconditionally
# in `_check_define` regardless of whether `p` is already known.
# ---------------------------------------------------------------------------


def test_self_reference_on_first_definition_is_rejected():
s = Session()
r = s.run_line("define p: is p")
assert r.status is ResultStatus.ERROR_SEMANTIC
assert r.message == "Definition 'p' can't refer to itself."


def test_self_reference_in_field_position_is_rejected():
s = Session()
r = s.run_line("define p: p is above 1")
assert r.status is ResultStatus.ERROR_SEMANTIC
assert r.message == "Definition 'p' can't refer to itself."


def test_self_reference_in_compound_and_branch_is_rejected():
s = Session()
r = s.run_line("define p: is above 1 and is p")
assert r.status is ResultStatus.ERROR_SEMANTIC
assert r.message == "Definition 'p' can't refer to itself."


def test_self_reference_in_compound_or_branch_is_rejected():
s = Session()
r = s.run_line("define p: is p or is above 1")
assert r.status is ResultStatus.ERROR_SEMANTIC
assert r.message == "Definition 'p' can't refer to itself."


def test_is_not_self_reference_is_a_pre_existing_parse_error_not_semantic():
# `is not <bareword>` never parses when the word isn't already a known
# predicate name — this is pre-existing, unrelated grammar behavior
# (identical for any unknown word, e.g. `define p: is not grownup`),
# not something this fix introduces or can reach. It is out of scope:
# the fix is analyzer-only and must not touch parsing (see
# `_check_self_referential_define`'s docstring). Documented here so the
# self-reference test group doesn't imply this line is unhandled.
s = Session()
r = s.run_line("define p: is not p")
assert r.status is ResultStatus.ERROR_PARSE
assert r.message == "After 'not' I expected 'above', 'below', or 'equal to'."


def test_bareword_differing_from_defined_name_stays_string_equality():
s = Session()
s.run_line("define p: is above 1")
r = s.run_line("define q: is p")
assert r.status is ResultStatus.SUCCESS


def test_predicate_named_after_an_unrelated_fact_still_works():
s = Session()
s.run_line('remember a string called status with "p"')
r = s.run_line("define matches-p: is p")
assert r.status is ResultStatus.SUCCESS