From c96069ef9f08f5384c11eb4e52df5ceb139c63f3 Mon Sep 17 00:00:00 2001 From: Marek Kaput Date: Tue, 17 Feb 2026 15:13:29 +0100 Subject: [PATCH 1/4] fix: parse newline ternary continuation after ellipsis Ellipsis was treated as standalone before `//` when the ternary operator arrived with newline metadata, producing a mismatched AST for forms like `x...\n//y`. Detect newline-associated ternary continuation and keep existing semicolon-separated behavior intact (e.g. `x...;//y`). Adds a regression assertions in the property-regression test block. --- lib/spitfire.ex | 36 ++++++++++++++++++++++++++++-------- test/spitfire_test.exs | 4 ++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/lib/spitfire.ex b/lib/spitfire.ex index b426836..919f5bb 100644 --- a/lib/spitfire.ex +++ b/lib/spitfire.ex @@ -2920,14 +2920,18 @@ defmodule Spitfire do defp parse_ellipsis_op(parser) do trace "parse_ellipsis_op", trace_meta(parser) do - peek = peek_token_type(parser) - - # `...` is standalone when followed by a terminal, stab op, keyword - # or binary operators (except :dual_op) - if MapSet.member?(@terminals_with_comma, peek_token(parser)) or - peek_token(parser) == :";" or - peek in [:stab_op, :do, :end, :block_identifier] or - (is_binary_op?(peek) and peek != :dual_op) do + peek_type = peek_token_type(parser) + peek = peek_token(parser) + + standalone? = + MapSet.member?(@terminals_with_comma, peek) or + peek == :";" or + peek_type in [:stab_op, :do, :end, :block_identifier] or + (is_binary_op?(peek_type) and peek_type != :dual_op) + + # `...` is standalone when followed by a terminal, stab op, keyword, + # or binary operator (except :dual_op), unless it continues a newline ternary (`//`). + if standalone? and not newline_ternary_continuation?(parser) do {{:..., current_meta(parser), []}, parser} else meta = current_meta(parser) @@ -2953,6 +2957,22 @@ defmodule Spitfire do end end + defp newline_ternary_continuation?(parser) do + cond do + peek_token(parser) == :eol -> + peek_token_skip_eoe(parser) == :ternary_op + + peek_token_type(parser) == :ternary_op -> + case parser |> next_token() |> current_newlines() do + nl when is_integer(nl) and nl > 0 -> true + _ -> false + end + + true -> + false + end + end + # Formats a struct type AST to a string for error messages defp format_struct_type({:__aliases__, _, parts}) do Enum.map_join(parts, ".", fn diff --git a/test/spitfire_test.exs b/test/spitfire_test.exs index 87eb6f4..4b53f6d 100644 --- a/test/spitfire_test.exs +++ b/test/spitfire_test.exs @@ -2300,6 +2300,10 @@ defmodule SpitfireTest do assert Spitfire.parse("%e.(){}") == s2q("%e.(){}") assert Spitfire.parse("%e.(1){}") == s2q("%e.(1){}") assert Spitfire.parse("%e.(a, b){}") == s2q("%e.(a, b){}") + + # Ellipsis + ternary edge cases (newline and semicolon-separated) + assert Spitfire.parse("x...\n//y") == s2q("x...\n//y") + assert Spitfire.parse("x...;//y") == s2q("x...;//y") end end From 154c6ba80647f57715ea3f75de5a46183d732f2f Mon Sep 17 00:00:00 2001 From: Marek Kaput Date: Wed, 18 Feb 2026 12:35:11 +0100 Subject: [PATCH 2/4] fix: preserve semicolon break after ellipsis Fix ellipsis continuation detection for newline ternary parsing so semicolons still terminate the previous expression. The previous newline ternary check skipped both newlines and semicolons, which caused inputs like x...\n;//y and x...\n;\n//y to be parsed as ellipsis continuation and emit an unknown eol token error. Use newline-only lookahead for the continuation check, and add regression assertions for semicolon-separated newline forms (including an intervening comment) next to the existing x...;//y coverage. --- lib/spitfire.ex | 2 +- test/spitfire_test.exs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/spitfire.ex b/lib/spitfire.ex index 919f5bb..8236405 100644 --- a/lib/spitfire.ex +++ b/lib/spitfire.ex @@ -2960,7 +2960,7 @@ defmodule Spitfire do defp newline_ternary_continuation?(parser) do cond do peek_token(parser) == :eol -> - peek_token_skip_eoe(parser) == :ternary_op + peek_token_skip_eol(parser) == :ternary_op peek_token_type(parser) == :ternary_op -> case parser |> next_token() |> current_newlines() do diff --git a/test/spitfire_test.exs b/test/spitfire_test.exs index 4b53f6d..34c6715 100644 --- a/test/spitfire_test.exs +++ b/test/spitfire_test.exs @@ -2304,6 +2304,9 @@ defmodule SpitfireTest do # Ellipsis + ternary edge cases (newline and semicolon-separated) assert Spitfire.parse("x...\n//y") == s2q("x...\n//y") assert Spitfire.parse("x...;//y") == s2q("x...;//y") + assert Spitfire.parse("x...\n;//y") == s2q("x...\n;//y") + assert Spitfire.parse("x...\n;\n//y") == s2q("x...\n;\n//y") + assert Spitfire.parse("x...\n;\n# comment\n//y") == s2q("x...\n;\n# comment\n//y") end end From e4f928b611d673a17c729e2431872897245e9245 Mon Sep 17 00:00:00 2001 From: Marek Kaput Date: Fri, 20 Feb 2026 07:42:41 +0100 Subject: [PATCH 3/4] go back to 0 state --- lib/spitfire.ex | 36 ++++++++---------------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/lib/spitfire.ex b/lib/spitfire.ex index 8236405..b426836 100644 --- a/lib/spitfire.ex +++ b/lib/spitfire.ex @@ -2920,18 +2920,14 @@ defmodule Spitfire do defp parse_ellipsis_op(parser) do trace "parse_ellipsis_op", trace_meta(parser) do - peek_type = peek_token_type(parser) - peek = peek_token(parser) - - standalone? = - MapSet.member?(@terminals_with_comma, peek) or - peek == :";" or - peek_type in [:stab_op, :do, :end, :block_identifier] or - (is_binary_op?(peek_type) and peek_type != :dual_op) - - # `...` is standalone when followed by a terminal, stab op, keyword, - # or binary operator (except :dual_op), unless it continues a newline ternary (`//`). - if standalone? and not newline_ternary_continuation?(parser) do + peek = peek_token_type(parser) + + # `...` is standalone when followed by a terminal, stab op, keyword + # or binary operators (except :dual_op) + if MapSet.member?(@terminals_with_comma, peek_token(parser)) or + peek_token(parser) == :";" or + peek in [:stab_op, :do, :end, :block_identifier] or + (is_binary_op?(peek) and peek != :dual_op) do {{:..., current_meta(parser), []}, parser} else meta = current_meta(parser) @@ -2957,22 +2953,6 @@ defmodule Spitfire do end end - defp newline_ternary_continuation?(parser) do - cond do - peek_token(parser) == :eol -> - peek_token_skip_eol(parser) == :ternary_op - - peek_token_type(parser) == :ternary_op -> - case parser |> next_token() |> current_newlines() do - nl when is_integer(nl) and nl > 0 -> true - _ -> false - end - - true -> - false - end - end - # Formats a struct type AST to a string for error messages defp format_struct_type({:__aliases__, _, parts}) do Enum.map_join(parts, ".", fn From e48edd0488705ce69dd32e1dda2dc2e805345cf0 Mon Sep 17 00:00:00 2001 From: Marek Kaput Date: Fri, 20 Feb 2026 10:05:02 +0100 Subject: [PATCH 4/4] fix: align ellipsis and range-step parsing with Elixir Update parser semantics around ellipsis and range-step handling to match Elixir behavior for edge cases involving //. What changed: - In parse_infix_expression/2, handle infix // as a range-step operator only when the lhs is a range node, producing :..//. - For non-range lhs, record the same range-step diagnostic intent Elixir uses, instead of silently accepting a generic binary //. - In parse_ellipsis_op/1, treat :ternary_op as a continuation candidate rather than a standalone-ellipsis stop token, which fixes newline forms like x...\n//y. - Add a regression test asserting non-range // produces an error path for x...//y. Why: The previous behavior diverged from Elixir in two directions: it accepted non-range infix // and parsed x...\n//y as ternary continuation instead of preserving ellipsis unary continuation semantics. This change resolves both at grammar/parse semantics level rather than adding context-specific lookahead hacks. Special considerations: - Tokenizer behavior was already aligned with Elixir for these inputs; the fix is parser-only. - Existing semicolon-separated ellipsis cases remain covered and passing. --- lib/spitfire.ex | 22 +++++++++++++++++++++- test/spitfire_test.exs | 14 ++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/spitfire.ex b/lib/spitfire.ex index b426836..0dbb370 100644 --- a/lib/spitfire.ex +++ b/lib/spitfire.ex @@ -1423,8 +1423,28 @@ defmodule Spitfire do {rhs, parser} end + parser = + if token == :"//" and not match?({:.., _, [_, _]}, lhs) do + put_error( + pre_parser, + {meta, + "the range step operator (//) must immediately follow the range definition operator (..), for example: 1..9//2. If you wanted to define a default argument, use (\\\\) instead. Syntax error before: '//'"} + ) + else + parser + end + ast = case token do + :"//" -> + case lhs do + {:.., lhs_meta, [left, middle]} -> + {:..//, lhs_meta, [left, middle, rhs]} + + _ -> + {token, newlines ++ meta, [lhs, rhs]} + end + :"not in" -> {:not, meta, [{:in, meta, [lhs, rhs]}]} @@ -2927,7 +2947,7 @@ defmodule Spitfire do if MapSet.member?(@terminals_with_comma, peek_token(parser)) or peek_token(parser) == :";" or peek in [:stab_op, :do, :end, :block_identifier] or - (is_binary_op?(peek) and peek != :dual_op) do + (is_binary_op?(peek) and peek not in [:dual_op, :ternary_op]) do {{:..., current_meta(parser), []}, parser} else meta = current_meta(parser) diff --git a/test/spitfire_test.exs b/test/spitfire_test.exs index 34c6715..7023904 100644 --- a/test/spitfire_test.exs +++ b/test/spitfire_test.exs @@ -2321,6 +2321,20 @@ defmodule SpitfireTest do [{[line: 1, column: 5], "unknown token: %"}]} end + test "range step operator requires a range lhs" do + code = "x...//y" + + assert {:error, _} = s2q(code) + assert {:error, _ast, errors} = Spitfire.parse(code) + + assert Enum.any?(errors, fn {_meta, message} -> + String.contains?( + message, + "the range step operator (//) must immediately follow the range definition operator (..)" + ) + end) + end + test "missing bitstring brackets" do code = """ <