From 243bf500c7edd0a6a5f2c5fae91fae3bd2dc83dc Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Wed, 11 Feb 2026 07:54:00 -0800 Subject: [PATCH 1/3] Add Lua function call syntactic sugar and improve parser robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements three key parser enhancements to support more Lua 5.3 syntax: 1. **Function Call Syntactic Sugar** - Implement `func "string"` → `func("string")` pattern - Implement `func{table}` → `func({table})` pattern - Support for both regular function calls and method calls - Examples: `require "debug"`, `print{1,2,3}`, `obj:method "str"` 2. **Empty Statement Support** - Handle semicolons as empty statements (`;` `;;` etc) - Support multiple consecutive semicolons - Handle semicolons after comments correctly - Semicolons can appear at start of code, between statements, in blocks 3. **Comments in Expressions** - Parser now skips comments that appear within expressions - Supports comments after operators: `x ~= -- comment\nnil` - Enables more idiomatic Lua code with inline documentation **Testing** - Added 17 new parser tests covering all new functionality - All tests now properly use ExUnit instead of ad-hoc mix run -e - Tests organized into: - 5 tests for function call syntactic sugar (expr_test.exs) - 4 tests for comments in expressions (expr_test.exs) - 8 tests for empty statements (statement_test.exs) **Implementation Details** - Modified parse_block_acc to skip semicolons at block level - Removed recursive semicolon handling from parse_stmt_inner - Added comment skipping to parse_prefix for expression-level comments - Enhanced parse_infix to recognize string/table tokens after function names - Updated method call parsing to support syntactic sugar patterns All 1120 tests passing. Co-Authored-By: Claude Sonnet 4.5 --- lib/lua/parser.ex | 75 +++++++++++++++++++++---- test/lua/parser/expr_test.exs | 88 ++++++++++++++++++++++++++++++ test/lua/parser/statement_test.exs | 74 +++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 11 deletions(-) diff --git a/lib/lua/parser.ex b/lib/lua/parser.ex index 1dc7145..a37ac3a 100644 --- a/lib/lua/parser.ex +++ b/lib/lua/parser.ex @@ -105,9 +105,14 @@ defmodule Lua.Parser do {:eof, _} -> {:ok, Block.new(Enum.reverse(stmts)), tokens} + # Skip empty statements (semicolons) + {:delimiter, :semicolon, _} -> + {_, rest} = consume(tokens) + parse_block_acc(rest, stmts) + # Skip orphaned comments at end of block (before terminator) {:comment, _, _, _} -> - # Check if comments are orphaned (followed only by terminator/EOF) + # Check if comments are orphaned (followed only by terminator/EOF/semicolon) tokens_after_comments = skip_orphaned_comments(tokens) case peek(tokens_after_comments) do @@ -117,6 +122,11 @@ defmodule Lua.Parser do {:eof, _} -> {:ok, Block.new(Enum.reverse(stmts)), tokens_after_comments} + # Comments followed by semicolons - skip both and continue + {:delimiter, :semicolon, _} -> + {_, rest} = consume(tokens_after_comments) + parse_block_acc(rest, stmts) + _ -> # Not orphaned, parse as normal statement (comments will be collected) case parse_stmt(tokens) do @@ -199,11 +209,6 @@ defmodule Lua.Parser do {:delimiter, :double_colon, _} -> parse_label(tokens) - # Semicolon (statement separator, optional) - {:delimiter, :semicolon, _} -> - {_, rest} = consume(tokens) - parse_stmt(rest) - _ -> # Try to parse as assignment or function call parse_assign_or_call(tokens) @@ -657,6 +662,11 @@ defmodule Lua.Parser do # Parse prefix expressions (primary expressions and unary operators) defp parse_prefix(tokens) do case peek(tokens) do + # Skip comments in expressions + {:comment, _, _, _} -> + {_, rest} = consume(tokens) + parse_prefix(rest) + # Literals {:keyword, nil, pos} -> {_, rest} = consume(tokens) @@ -813,6 +823,24 @@ defmodule Lua.Parser do {:error, reason} end + # Postfix: function call with string literal (syntactic sugar) + {:string, value, pos} -> + string_arg = %Expr.String{value: value, meta: Meta.new(pos)} + {_, rest} = consume(tokens) + new_left = %Expr.Call{func: left, args: [string_arg], meta: nil} + parse_infix(new_left, rest, min_prec) + + # Postfix: function call with table constructor (syntactic sugar) + {:delimiter, :lbrace, _} -> + case parse_table(tokens) do + {:ok, table, rest} -> + new_left = %Expr.Call{func: left, args: [table], meta: nil} + parse_infix(new_left, rest, min_prec) + + {:error, reason} -> + {:error, reason} + end + # Postfix: indexing {:delimiter, :lbracket, _} -> case parse_index(tokens) do @@ -842,13 +870,38 @@ defmodule Lua.Parser do case expect(rest, :identifier) do {:ok, {_, method, _}, rest2} -> - case parse_call_args(rest2) do - {:ok, args, rest3} -> - new_left = %Expr.MethodCall{object: left, method: method, args: args, meta: nil} + # Method calls support syntactic sugar: obj:method"str" or obj:method{...} + case rest2 do + # Regular method call with parentheses + [{:delimiter, :lparen, _} | _] -> + case parse_call_args(rest2) do + {:ok, args, rest3} -> + new_left = %Expr.MethodCall{object: left, method: method, args: args, meta: nil} + parse_infix(new_left, rest3, min_prec) + + {:error, reason} -> + {:error, reason} + end + + # Method call with string literal (syntactic sugar) + [{:string, value, pos} | rest3] -> + string_arg = %Expr.String{value: value, meta: Meta.new(pos)} + new_left = %Expr.MethodCall{object: left, method: method, args: [string_arg], meta: nil} parse_infix(new_left, rest3, min_prec) - {:error, reason} -> - {:error, reason} + # Method call with table constructor (syntactic sugar) + [{:delimiter, :lbrace, _} | _] -> + case parse_table(rest2) do + {:ok, table, rest3} -> + new_left = %Expr.MethodCall{object: left, method: method, args: [table], meta: nil} + parse_infix(new_left, rest3, min_prec) + + {:error, reason} -> + {:error, reason} + end + + _ -> + {:error, "Expected '(', string, or table after method name"} end {:error, reason} -> diff --git a/test/lua/parser/expr_test.exs b/test/lua/parser/expr_test.exs index 8572df9..cac5316 100644 --- a/test/lua/parser/expr_test.exs +++ b/test/lua/parser/expr_test.exs @@ -84,4 +84,92 @@ defmodule Lua.Parser.ExprTest do assert {:ok, _} = parse_return_expr("return t.a.b.c") end end + + describe "function call syntactic sugar" do + test "parses function call with string literal" do + # f "str" is syntactic sugar for f("str") + assert {:ok, %Expr.Call{func: %Expr.Var{name: "f"}, args: [%Expr.String{value: "hello"}]}} = + parse_return_expr(~s(return f "hello")) + + # Works with require pattern + assert {:ok, %Expr.Call{func: %Expr.Var{name: "require"}, args: [%Expr.String{value: "debug"}]}} = + parse_return_expr(~s(return require "debug")) + end + + test "parses function call with long string literal" do + # f [[str]] is syntactic sugar for f([[str]]) + assert {:ok, %Expr.Call{func: %Expr.Var{name: "load"}, args: [%Expr.String{value: "return 1 + 2"}]}} = + parse_return_expr("return load[[return 1 + 2]]") + end + + test "parses function call with table constructor" do + # f{...} is syntactic sugar for f({...}) + assert {:ok, %Expr.Call{func: %Expr.Var{name: "f"}, args: [%Expr.Table{fields: []}]}} = + parse_return_expr("return f{}") + + assert {:ok, %Expr.Call{func: %Expr.Var{name: "print"}, args: [%Expr.Table{fields: [_, _, _]}]}} = + parse_return_expr("return print{1, 2, 3}") + + assert {:ok, %Expr.Call{func: %Expr.Var{name: "config"}, args: [%Expr.Table{}]}} = + parse_return_expr("return config{a = 1, b = 2}") + end + + test "parses chained function calls with syntactic sugar" do + # Can chain multiple calls + assert {:ok, %Expr.Call{}} = parse_return_expr(~s(return f "a" "b")) + + # Can mix syntactic sugar with regular calls + assert {:ok, %Expr.Call{}} = parse_return_expr(~s[return f "a"(x)]) + end + + test "parses method calls with syntactic sugar" do + # obj:method "str" is valid + assert {:ok, %Expr.MethodCall{object: %Expr.Var{name: "obj"}, method: "m"}} = + parse_return_expr(~s(return obj:m "hello")) + + # obj:method{} is valid + assert {:ok, %Expr.MethodCall{object: %Expr.Var{name: "obj"}, method: "m"}} = + parse_return_expr("return obj:m{1, 2}") + end + end + + describe "comments in expressions" do + test "parses comments before expressions" do + code = """ + return -- comment + 42 + """ + + assert {:ok, %Expr.Number{value: 42}} = parse_return_expr(code) + end + + test "parses comments in binary expressions" do + code = """ + return 1 + -- comment + 2 + """ + + assert {:ok, %Expr.BinOp{op: :add}} = parse_return_expr(code) + end + + test "parses comments after operators" do + code = """ + return f(1,2,'a') + ~= -- force SETLINE before nil + nil + """ + + assert {:ok, %Expr.BinOp{op: :ne}} = parse_return_expr(code) + end + + test "parses multiple comments in complex expressions" do + code = """ + return (1 + -- comment 1 + 2) * -- comment 2 + 3 + """ + + assert {:ok, %Expr.BinOp{op: :mul}} = parse_return_expr(code) + end + end end diff --git a/test/lua/parser/statement_test.exs b/test/lua/parser/statement_test.exs index c1093a0..f855742 100644 --- a/test/lua/parser/statement_test.exs +++ b/test/lua/parser/statement_test.exs @@ -590,4 +590,78 @@ defmodule Lua.Parser.StatementTest do """) end end + + describe "empty statements (semicolons)" do + test "parses multiple semicolons at start of code" do + assert {:ok, chunk} = Parser.parse(~s(;;print "hi";;)) + assert %{block: %{stmts: [%Statement.CallStmt{}]}} = chunk + end + + test "parses semicolons in do-end blocks" do + assert {:ok, _chunk} = Parser.parse("do ;;; end") + assert {:ok, _chunk} = Parser.parse("do ; a = 3; assert(a == 3) end") + end + + test "parses semicolons after comments" do + assert {:ok, chunk} = Parser.parse("-- comment\n\n;;print \"hi\"") + assert %{block: %{stmts: [%Statement.CallStmt{}]}} = chunk + end + + test "parses semicolons between statements" do + assert {:ok, chunk} = Parser.parse("a = 1; b = 2; c = 3") + + assert %{ + block: %{ + stmts: [ + %Statement.Assign{}, + %Statement.Assign{}, + %Statement.Assign{} + ] + } + } = chunk + end + + test "parses multiple consecutive semicolons between statements" do + assert {:ok, chunk} = Parser.parse("a = 1;; b = 2;;; c = 3") + + assert %{ + block: %{ + stmts: [ + %Statement.Assign{}, + %Statement.Assign{}, + %Statement.Assign{} + ] + } + } = chunk + end + + test "parses semicolons at end of file" do + assert {:ok, chunk} = Parser.parse("print \"test\";;") + assert %{block: %{stmts: [%Statement.CallStmt{}]}} = chunk + end + + test "parses only semicolons as empty program" do + assert {:ok, chunk} = Parser.parse(";;;") + assert %{block: %{stmts: []}} = chunk + end + + test "parses semicolons with various statement types" do + code = """ + ;local x = 1; + ;if x then; return x; end; + ; + """ + + assert {:ok, chunk} = Parser.parse(code) + + assert %{ + block: %{ + stmts: [ + %Statement.Local{}, + %Statement.If{} + ] + } + } = chunk + end + end end From 5c2dad4ce7039ba78adb0fc1556274fae4357bfd Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Wed, 11 Feb 2026 08:10:37 -0800 Subject: [PATCH 2/3] Fix dialyzer errors in load() function Remove unreachable error handling code for Lua.Compiler.compile/1 since the compiler never returns errors in the current implementation. This fixes 3 dialyzer errors: - pattern_match: {:error, _reason} can never match - pattern_match_cov: variable_error pattern is unreachable - unused_fun: format_compile_error/1 is never called Co-Authored-By: Claude Sonnet 4.5 --- lib/lua/vm/stdlib.ex | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/lib/lua/vm/stdlib.ex b/lib/lua/vm/stdlib.ex index 6c5e423..a6c1152 100644 --- a/lib/lua/vm/stdlib.ex +++ b/lib/lua/vm/stdlib.ex @@ -339,16 +339,11 @@ defmodule Lua.VM.Stdlib do defp lua_load([chunk | _rest], state) when is_binary(chunk) do case Lua.Parser.parse(chunk) do {:ok, ast} -> - case Lua.Compiler.compile(ast) do - {:ok, prototype} -> - # Create a closure from the compiled prototype - closure = {:lua_closure, prototype, %{}} - {[closure], state} - - {:error, reason} -> - error_msg = format_compile_error(reason) - {[nil, error_msg], state} - end + # Compiler currently never returns errors, always succeeds + {:ok, prototype} = Lua.Compiler.compile(ast) + # Create a closure from the compiled prototype + closure = {:lua_closure, prototype, %{}} + {[closure], state} {:error, reason} -> error_msg = format_parse_error(reason) @@ -369,9 +364,6 @@ defmodule Lua.VM.Stdlib do defp format_parse_error(error) when is_binary(error), do: error defp format_parse_error(error), do: inspect(error) - defp format_compile_error(error) when is_binary(error), do: error - defp format_compile_error(error), do: inspect(error) - # setmetatable(table, metatable) — sets the metatable for a table defp lua_setmetatable([{:tref, _} = tref, metatable], state) do # Validate metatable is nil or a table From 5b843daaf264f40d1c8217d31c457afb9e22947e Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Wed, 11 Feb 2026 09:40:54 -0800 Subject: [PATCH 3/3] Remove unreachable clause in format_parse_error/1 Parser always returns errors as strings, so the fallback clause that calls inspect/1 is unreachable. Fixes dialyzer pattern_match_cov warning. Co-Authored-By: Claude Sonnet 4.5 --- lib/lua/vm/stdlib.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/lua/vm/stdlib.ex b/lib/lua/vm/stdlib.ex index a6c1152..fb40a14 100644 --- a/lib/lua/vm/stdlib.ex +++ b/lib/lua/vm/stdlib.ex @@ -362,7 +362,6 @@ defmodule Lua.VM.Stdlib do end defp format_parse_error(error) when is_binary(error), do: error - defp format_parse_error(error), do: inspect(error) # setmetatable(table, metatable) — sets the metatable for a table defp lua_setmetatable([{:tref, _} = tref, metatable], state) do