From ac8aa15c61c476f6bb07dffb6ea22289ca27bf2d Mon Sep 17 00:00:00 2001 From: Marek Kaput Date: Tue, 17 Feb 2026 15:13:29 +0100 Subject: [PATCH 1/5] 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 | 3 +++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/lib/spitfire.ex b/lib/spitfire.ex index 7d8688f..7eaf3cf 100644 --- a/lib/spitfire.ex +++ b/lib/spitfire.ex @@ -2904,14 +2904,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) @@ -2937,6 +2941,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 9b0109f..232d28a 100644 --- a/test/spitfire_test.exs +++ b/test/spitfire_test.exs @@ -2305,6 +2305,9 @@ defmodule SpitfireTest do # with/else stab body with leading semicolon after newline assert Spitfire.parse("with x <- 1 do :ok else _ -> \n;a end") == s2q("with x <- 1 do :ok else _ -> \n;a end") + # 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 9223c20aa2195087e199bdfadb0167816448fa7f Mon Sep 17 00:00:00 2001 From: Marek Kaput Date: Wed, 18 Feb 2026 12:35:11 +0100 Subject: [PATCH 2/5] 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 7eaf3cf..3769111 100644 --- a/lib/spitfire.ex +++ b/lib/spitfire.ex @@ -2944,7 +2944,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 232d28a..bdf9ed3 100644 --- a/test/spitfire_test.exs +++ b/test/spitfire_test.exs @@ -2308,6 +2308,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 0a0191224e41bb0892c4f3eb03c30e0b7e5232ad Mon Sep 17 00:00:00 2001 From: Marek Kaput Date: Fri, 20 Feb 2026 07:42:41 +0100 Subject: [PATCH 3/5] 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 3769111..7d8688f 100644 --- a/lib/spitfire.ex +++ b/lib/spitfire.ex @@ -2904,18 +2904,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) @@ -2941,22 +2937,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 192697d8b87a6fc61b4ceafc3cd84295147778f5 Mon Sep 17 00:00:00 2001 From: Marek Kaput Date: Fri, 20 Feb 2026 10:05:02 +0100 Subject: [PATCH 4/5] 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 7d8688f..c9e415c 100644 --- a/lib/spitfire.ex +++ b/lib/spitfire.ex @@ -1404,8 +1404,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]}]} @@ -2911,7 +2931,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 bdf9ed3..6a0c4a6 100644 --- a/test/spitfire_test.exs +++ b/test/spitfire_test.exs @@ -2325,6 +2325,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 = """ < Date: Tue, 3 Mar 2026 21:53:25 -0500 Subject: [PATCH 5/5] chore: format --- test/spitfire_test.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/spitfire_test.exs b/test/spitfire_test.exs index 6a0c4a6..483b4d6 100644 --- a/test/spitfire_test.exs +++ b/test/spitfire_test.exs @@ -2305,6 +2305,7 @@ defmodule SpitfireTest do # with/else stab body with leading semicolon after newline assert Spitfire.parse("with x <- 1 do :ok else _ -> \n;a end") == s2q("with x <- 1 do :ok else _ -> \n;a end") + # 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")