From d62ef6b7a96b121dc9122a31fa1a12424d94d95f Mon Sep 17 00:00:00 2001 From: doorgan Date: Sat, 17 Jan 2026 05:24:24 -0300 Subject: [PATCH] fix: recover from incomplete keyword lists --- lib/spitfire.ex | 43 ++++++++++++++++++++++++++++-------------- test/spitfire_test.exs | 25 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/lib/spitfire.ex b/lib/spitfire.ex index 9db95c1..35ecb5d 100644 --- a/lib/spitfire.ex +++ b/lib/spitfire.ex @@ -625,13 +625,7 @@ defmodule Spitfire do {value, parser} = parse_expression(parser, @kw_identifier, false, false, false) - {kvs, parser} = - while2 peek_token(parser) == :"," <- parser do - parser = parser |> next_token() |> next_token() - {pair, parser} = parse_kw_identifier(parser) - - {pair, parser} - end + {kvs, parser} = parse_kw_list_continuation(parser) {[{token, value} | kvs], parser} end @@ -652,18 +646,39 @@ defmodule Spitfire do {value, parser} = parse_expression(parser, @kw_identifier, false, false, false) - {kvs, parser} = - while2 peek_token(parser) == :"," <- parser do - parser = parser |> next_token() |> next_token() - {pair, parser} = parse_kw_identifier(parser) - - {pair, parser} - end + {kvs, parser} = parse_kw_list_continuation(parser) {[{atom, value} | kvs], parser} end end + defp parse_kw_list_continuation(parser) do + while2 peek_token(parser) == :"," <- parser do + parser = parser |> next_token() |> next_token() + + case current_token_type(parser) do + type when type in [:kw_identifier, :kw_identifier_unsafe] -> + parse_kw_identifier(parser) + + _ -> + parser = + if match?({:paren_identifier, _, :__cursor__}, parser.current_token) do + parser + else + put_error( + parser, + {current_meta(parser), + "unexpected expression after keyword list. Keyword lists must always come as the last argument. " <> + "Therefore, this is not allowed:\n\n function_call(1, some: :option, 2)\n\n" <> + "Instead, wrap the keyword in brackets:\n\n function_call(1, [some: :option], 2)"} + ) + end + + parse_expression(parser, @kw_identifier, true, false, false) + end + end + end + defp parse_assoc_op(%{current_token: {:assoc_op, _, _token}} = parser, key) do trace "parse_assoc_op", trace_meta(parser) do assoc_meta = current_meta(parser) diff --git a/test/spitfire_test.exs b/test/spitfire_test.exs index a806b26..c757d11 100644 --- a/test/spitfire_test.exs +++ b/test/spitfire_test.exs @@ -2805,6 +2805,31 @@ defmodule SpitfireTest do } end + test "unexpected expression after keyword list" do + assert {:error, _ast, errors} = Spitfire.parse(~S|foo(a: 1, b)|) + + assert Enum.any?(errors, fn {_, msg} -> + String.contains?(msg, "unexpected expression after keyword list") + end) + + assert {:error, _ast, errors} = Spitfire.parse(~S|foo(a: 1, :bar)|) + + assert Enum.any?(errors, fn {_, msg} -> + String.contains?(msg, "unexpected expression after keyword list") + end) + + assert {:error, _ast, errors} = Spitfire.parse(~S|@tag foo: bar, baz|) + + assert Enum.any?(errors, fn {_, msg} -> + String.contains?(msg, "unexpected expression after keyword list") + end) + end + + test "__cursor__ after keyword list does not crash" do + code = ~S|foo(a: 1, __cursor__())| + assert {:ok, {:foo, _, [[{:a, 1}, {:__cursor__, _, []}]]}} = Spitfire.parse(code) + end + test "weird characters" do code = """ [«]