diff --git a/conformance/core.json b/conformance/core.json index 4162daf..138d4de 100644 --- a/conformance/core.json +++ b/conformance/core.json @@ -294,5 +294,10 @@ "name": "typed Remember field statements", "source": "Create a class called Counter:\n When creating a Counter with initial:\n Remember initial as this.value.\n End constructor.\n To increment:\n Remember this.value plus 1 as this.value.\n End function.\nEnd class.\nLet counter be a new Counter with 4.\nTell counter to increment.\nSay counter.value.", "output": "5\n" + }, + { + "name": "typed Call and Run statements", + "source": "To combine with left and right:\n Say left plus right.\nEnd function.\nCall combine with 2 and 3.\nRun combine with 4 and 1.", + "output": "5\n5\n" } ] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bf1445a..d3cc0cb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,7 +24,7 @@ Tell source -> runtime environment and built-ins ``` -Common assignment nodes now also include indexed collection assignment (`Set values[index] to value.`, `Set the item at position index in values to value.`, and `Set the key key in mapping to value.`), with the same typed node executed by both source interpreters and lowered to a verified `SET_INDEX` bytecode operation for list and dictionary writes. Import statements for local Tell modules, Python modules, JavaScript modules, and built-in libraries are also represented as typed nodes; the compiler reuses its existing embedded-module and interop instructions for those nodes. Constructor and method `Remember expression as this.field.` assignments are also typed; the source runtimes update the active instance directly, while bytecode emits the existing `LOAD this`/`SET_FIELD` sequence. +Common assignment nodes now also include indexed collection assignment (`Set values[index] to value.`, `Set the item at position index in values to value.`, and `Set the key key in mapping to value.`), with the same typed node executed by both source interpreters and lowered to a verified `SET_INDEX` bytecode operation for list and dictionary writes. Import statements for local Tell modules, Python modules, JavaScript modules, and built-in libraries are also represented as typed nodes; the compiler reuses its existing embedded-module and interop instructions for those nodes. Constructor and method `Remember expression as this.field.` assignments are also typed; the source runtimes update the active instance directly, while bytecode emits the existing `LOAD this`/`SET_FIELD` sequence. `Call function with arguments.` and `Run function with arguments.` are typed call statements that evaluate a call AST and discard its result, matching the existing statement semantics in source and bytecode execution. The source interpreter path is shown above. The native CLI also has an early compiler path for a limited statement subset: diff --git a/docs/GRAMMAR_V1.ebnf b/docs/GRAMMAR_V1.ebnf index c4bb19f..ef65400 100644 --- a/docs/GRAMMAR_V1.ebnf +++ b/docs/GRAMMAR_V1.ebnf @@ -53,7 +53,10 @@ The logical words and, or, and not are operators. The word in and phrase is in a return-statement = ("Give back" | "Return"), [ expression ], "." ; raise-statement = "Raise an error saying", expression, "." ; call-statement = "Tell", identifier, "to", identifier, - [ "with", argument-list ], "." ; + [ "with", argument-list ], "." + | ("Call" | "Run"), identifier, + [ "with", call-arguments ], "." ; + call-arguments = expression, { "and", expression } ; import-statement = "Use the module", string, [ "as", identifier ], "." | "Use the Python library called", identifier, [ "as", identifier ], "." | "Use the JavaScript library called", string, [ "as", identifier ], "." diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7803750..679bbc2 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -66,6 +66,7 @@ The v0.3.6 implementation is committed to `main` and published at https://github ## 2026-11-12 modernization branch checkpoint +- Continued on `modernization/0.4-call-run-ast`: `Call` and `Run` function statements are now represented as typed call-expression nodes in both interpreters; bytecode lowers them to CALL followed by POP so results are discarded like the statement runtime. Added source/bytecode tests and a shared conformance case. - Continued on `modernization/0.4-remember-ast`: `Remember expression as this.field.` is parsed and executed as a typed statement in Python and Go, compiled through `SET_FIELD`, and covered by constructor/method source tests, bytecode tests, and the shared cross-runtime corpus. The following work is on `modernization/0.4-foundation`; it has not been merged to `main` or published as a release. diff --git a/native/internal/tell/bytecode.go b/native/internal/tell/bytecode.go index b0b1d37..4dea1ec 100644 --- a/native/internal/tell/bytecode.go +++ b/native/internal/tell/bytecode.go @@ -397,6 +397,8 @@ func (c *bytecodeCompiler) compileSimpleStatement(statement *simpleStatement) er c.code[instruction].Name = statement.names[0] c.code[instruction].Type = statement.annotation c.code[instruction].Constant = statement.constant + case "call": + c.emit("POP") case "set": if len(statement.names) > 1 { instruction := c.emit("MULTI_ASSIGN") diff --git a/native/internal/tell/bytecode_test.go b/native/internal/tell/bytecode_test.go index 1ab71d8..a2e3018 100644 --- a/native/internal/tell/bytecode_test.go +++ b/native/internal/tell/bytecode_test.go @@ -462,6 +462,17 @@ func TestBytecodePreservesRuntimeTypeAndConstantRules(t *testing.T) { } } +func TestBytecodeCompilesCallAndRunStatements(t *testing.T) { + source := `To combine with left and right: + Say left plus right. +End function. +Call combine with 2 and 3. +Run combine with 4 and 1.` + if got, want := runCompiled(t, source), "5\n5\n"; got != want { + t.Fatalf("compiled Call/Run output = %q, want %q", got, want) + } +} + func TestBytecodeCompilesRememberFieldStatements(t *testing.T) { source := `Create a class called Counter: When creating a Counter with initial: diff --git a/native/internal/tell/execute.go b/native/internal/tell/execute.go index 1d3f31e..20baa64 100644 --- a/native/internal/tell/execute.go +++ b/native/internal/tell/execute.go @@ -229,6 +229,8 @@ func (r *runtime) executeSimpleStatement(statement *simpleStatement, e *env) err return r.declare(e, statement.names[0], value, statement.annotation, statement.constant) } return r.assign(e, statement.names[0], value) + case "call": + return nil case "set": if len(statement.names) > 1 { values, ok := sequenceValues(value) diff --git a/native/internal/tell/parser.go b/native/internal/tell/parser.go index 2471475..a867efe 100644 --- a/native/internal/tell/parser.go +++ b/native/internal/tell/parser.go @@ -368,6 +368,35 @@ func parseSimpleStatement(source string, line, column int) *simpleStatement { } } } + for _, prefix := range []string{"call ", "run "} { + rest, ok := prefixFold(source, prefix) + if !ok { continue } + name, tail := firstWord(rest) + if !identifier(name) { continue } + arguments := []callArgument{} + valid := true + if strings.TrimSpace(tail) != "" { + argumentText, hasWith := prefixFold(tail, "with ") + if !hasWith || strings.TrimSpace(argumentText) == "" { + valid = false + } else { + for _, argument := range splitAnd(argumentText) { + parsed, parsedOK := parseExpression(argument) + if !parsedOK { valid = false; break } + arguments = append(arguments, callArgument{value:parsed}) + } + } + } + if valid { + nameOffset := strings.Index(source, name) + if nameOffset < 0 { nameOffset = len(prefix) } + callee := nameExpression{name:name, line:line, column:column+utf8.RuneCountInString(source[:nameOffset])} + callOffset := strings.Index(strings.ToLower(source), strings.TrimSpace(prefix)) + if callOffset < 0 { callOffset = 0 } + call := callExpression{callee:callee, args:arguments, line:line, column:column+utf8.RuneCountInString(source[:callOffset])} + return &simpleStatement{kind:"call", source:source, expression:call, hasExpression:true} + } + } if rest, ok := prefixFold(source, "remember "); ok { expressionText, field, separated := splitFoldOnce(rest, " as this.") if separated && identifier(field) { diff --git a/native/internal/tell/tell_test.go b/native/internal/tell/tell_test.go index 1415edb..71c2f83 100644 --- a/native/internal/tell/tell_test.go +++ b/native/internal/tell/tell_test.go @@ -538,6 +538,29 @@ func TestIndexedAssignmentsUseTypedASTNodesAndExecute(t *testing.T) { } } +func TestCallAndRunStatementsUseTypedASTNodesAndExecute(t *testing.T) { + source := "To combine with left and right:\n Say left plus right.\nEnd function.\nCall combine with 2 and 3.\nRun combine with 4 and 1." + nodes, err := parse(source, "call-statements.tell") + if err != nil { + t.Fatal(err) + } + for _, index := range []int{1, 2} { + if nodes[index].simple == nil || nodes[index].simple.kind != "call" { + t.Fatalf("call statement %d was not parsed into typed call node: %#v", index, nodes[index]) + } + if _, ok := nodes[index].simple.expression.(callExpression); !ok { + t.Fatalf("call statement expression = %T, want callExpression", nodes[index].simple.expression) + } + } + output, err := runTest(t, source, "") + if err != nil { + t.Fatal(err) + } + if got, want := output, "5\n5\n"; got != want { + t.Fatalf("Call/Run output = %q, want %q", got, want) + } +} + func TestRememberFieldStatementsUseTypedASTNodesAndExecute(t *testing.T) { nodes, err := parse("Remember value as this.name.", "remember.tell") if err != nil { diff --git a/tell/interpreter.py b/tell/interpreter.py index 8057feb..e5c8092 100644 --- a/tell/interpreter.py +++ b/tell/interpreter.py @@ -765,6 +765,35 @@ def parsed_expression(expression: str) -> TellExpr | None: + for prefix in ("call ", "run "): + rest = _take_prefix(text, prefix) + if rest is None: + continue + name, tail = _first_identifier(rest) + if not name or not re.fullmatch(r"(?:[^\W\d]|_)\w*", name): + continue + arguments = [] + valid = True + if tail: + argument_text = _take_prefix(tail, "with ") + if not argument_text: + valid = False + else: + try: + arguments = [ + parse_expression(_translate_expression_text(argument)) + for argument in _split_top_level_text(argument_text, " and ") + ] + except ExpressionSyntaxError: + valid = False + if valid: + callee = TellExpr("name", name) + call = TellExpr( + "call", children=(callee, *arguments), + names=(None,) * len(arguments), + ) + return {"kind": "call", "source": text, "expression": call} + remember_rest = _take_prefix(text, "remember ") remember_parts = _split_once_casefold(remember_rest, " as this.") if remember_rest is not None else None if remember_parts and re.fullmatch(r"(?:[^\W\d]|_)\w*", remember_parts[1]): @@ -1712,6 +1741,8 @@ def execute_node(self, node: Node, env: Environment) -> None: if not isinstance(target, TellInstance): raise TellError("Property assignment requires a Tell instance.") target.fields[simple["target"].value] = value + elif simple["kind"] == "call": + pass elif simple["kind"] == "remember_field": try: instance = env.get("this") diff --git a/tests/test_tell.py b/tests/test_tell.py index 548409b..d25b7aa 100644 --- a/tests/test_tell.py +++ b/tests/test_tell.py @@ -209,6 +209,21 @@ def test_indexed_assignments_are_typed_nodes_and_execute(self): self.assertEqual(env.get("mapping"), {"name": "updated"}) + + def test_call_and_run_statements_are_typed_nodes(self): + source = """ +To combine with left and right: + Say left plus right. +End function. +Call combine with 2 and 3. +Run combine with 4 and 1. +""" + parsed = Parser(source).parse() + self.assertEqual([parsed[index].data["simple"]["kind"] for index in (1, 2)], ["call", "call"]) + self.assertEqual(parsed[1].data["simple"]["expression"].kind, "call") + output, _ = self.run_tell(source) + self.assertEqual(output, ["5", "5"]) + def test_remember_field_is_a_typed_node_and_updates_instance(self): parsed = Parser("Remember value as this.name.").parse() simple = parsed[0].data["simple"]