From ad792c769abb2881f14163ad0da2326ee1deaee7 Mon Sep 17 00:00:00 2001 From: Santiago Date: Tue, 1 Sep 2026 17:54:51 -0500 Subject: [PATCH] fix: parse identifiers with leading underscores Journey expressions could not traverse webhook vendor keys that start with an underscore: @event.message._vnd.v1.chat rendered the inspected message map followed by ._vnd.v1.chat as literal text, because the atom combinator required identifiers to start with a letter or digit. The _vnd namespace is added by the platform's own webhook enrichment, so flow authors cannot rename it, and the only workaround was bracket syntax (event.message["_vnd"]["v1"]["chat"]). An atom still needs at least one letter or digit, so a bare _ keeps failing to parse and @(_) remains literal text. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 ++++++++++ README.md | 2 +- lib/expression/parser.ex | 3 ++- mix.exs | 2 +- test/expression/parser_test.exs | 30 ++++++++++++++++++++++++++++++ test/expression_test.exs | 17 +++++++++++++++++ 6 files changed, 61 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ccbdb9c..5ff367c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v3.0.0-rc.3 + +### Fixed + +- Identifiers may now start with underscores, so attribute access on + WhatsApp webhook vendor keys such as `@event.message._vnd.v1.chat` + parses fully instead of stopping at the underscore. A bare `_` is + still not an identifier: `@(_)` remains literal text, and unresolved + variables like `@_missing` render back as-is. + ## v3.0.0-rc.0 This is the first release candidate for v3.0.0. It contains the breaking diff --git a/README.md b/README.md index e55980f7..4c88ce42 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ by adding `expression` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:expression, "~> 3.0.0-rc.2"} + {:expression, "~> 3.0.0-rc.3"} ] end ``` diff --git a/lib/expression/parser.ex b/lib/expression/parser.ex index 823555f5..b27aa6df 100644 --- a/lib/expression/parser.ex +++ b/lib/expression/parser.ex @@ -37,7 +37,8 @@ defmodule Expression.Parser do # atom = atom atom = - ascii_string([?a..?z, ?A..?Z, ?0..?9], min: 1) + ascii_string([?_], min: 0) + |> ascii_string([?a..?z, ?A..?Z, ?0..?9], min: 1) |> ascii_string([?a..?z, ?A..?Z, ?0..?9, ?_, ?-], min: 0) |> map({String, :downcase, []}) |> reduce({Enum, :join, []}) diff --git a/mix.exs b/mix.exs index 11a20c30..4e9015c5 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Expression.MixProject do use Mix.Project - @version "3.0.0-rc.2" + @version "3.0.0-rc.3" def project do [ diff --git a/test/expression/parser_test.exs b/test/expression/parser_test.exs index e47713d0..17c333ba 100644 --- a/test/expression/parser_test.exs +++ b/test/expression/parser_test.exs @@ -10,6 +10,11 @@ defmodule Expression.ParserTest do assert_ast([expression: [atom: "foo"]], "@foo") end + test "expression with a leading underscore" do + assert_ast([expression: [atom: "_foo"]], "@_foo") + assert_ast([expression: [atom: "__foo"]], "@__foo") + end + test "escaped at" do assert_ast([text: "user", text: "@", text: "example.org"], "user@@example.org") end @@ -425,6 +430,31 @@ defmodule Expression.ParserTest do ) end + test "on keys with leading underscores" do + assert_ast( + [ + expression: [ + attribute: [ + attribute: [ + attribute: [ + attribute: [atom: "event", atom: "message"], + atom: "_vnd" + ], + atom: "v1" + ], + atom: "chat" + ] + ] + ], + "@event.message._vnd.v1.chat" + ) + + assert_ast( + [expression: [attribute: [atom: "foo", atom: "_bar"]]], + "@(foo._bar)" + ) + end + test "on functions" do assert_ast( [ diff --git a/test/expression_test.exs b/test/expression_test.exs index 2d98135b..eb0765ed 100644 --- a/test/expression_test.exs +++ b/test/expression_test.exs @@ -197,6 +197,23 @@ defmodule ExpressionTest do assert "bar" == Expression.evaluate_as_string!("@foo[1]", %{"foo" => ["baz", "bar"]}) end + test "attributes with leading underscores" do + context = %{ + "event" => %{ + "message" => %{"_vnd" => %{"v1" => %{"chat" => %{"state" => "OPEN"}}}} + } + } + + assert Expression.evaluate_as_string!("@event.message._vnd.v1.chat.state", context) == + "OPEN" + + assert Expression.evaluate_block!("event.message._vnd.v1.chat", context) == %{ + "state" => "OPEN" + } + + assert Expression.evaluate_as_string!("@_missing", %{}) == "@_missing" + end + test "list with variable" do assert "bar" = Expression.evaluate_as_string!("@foo[cursor]", %{