Summary
Add flex_fetch/2 and flex_get_in/2 functions to the Runtime module to support flexible key access that correctly handles both atom and string keys. This enables LLM-generated PTC-Lisp code to work seamlessly with runtime data regardless of key type.
Context
Architecture reference: Flex Key Access Spec - Phase 1: Runtime Module Changes
Dependencies: None
Related issues: Epic #186 - Flexible Key Access for PTC-Lisp
Current State
The Runtime module has a private flex_get/2 helper (lines 14-39 in lib/ptc_runner/lisp/runtime.ex) that handles atom↔string key conversion, but:
- It's private (
defp), so Eval module can't use it
- There's no
flex_fetch/2 that returns {:ok, value} | :error (needed to distinguish nil values from missing keys)
- There's no
flex_get_in/2 for nested path access (a duplicate private version exists in eval.ex:478-489)
select_keys (line 210) uses Map.take/2 which requires exact key matching
get_in (lines 195-201) uses Kernel.get_in/2 which requires exact key matching
Acceptance Criteria
Implementation Hints
Files to modify:
lib/ptc_runner/lisp/runtime.ex - Add new functions, make flex_get public, update select_keys and get_in
test/ptc_runner/lisp/runtime_test.exs - Add unit tests for new functions
Patterns to follow:
flex_get at runtime.ex:14-39 - same bidirectional key lookup pattern
get_in_flexible at eval.ex:478-489 - reference for path traversal logic
Code changes from spec:
-
Make flex_get public (change defp to def at lines 14, 16, 23, 38, 39)
-
Add flex_fetch/2 after flex_get definitions:
@doc """
Flexible key fetch: try both atom and string versions of the key.
Returns {:ok, value} if found, :error if missing.
Use this when you need to distinguish between nil values and missing keys.
"""
def flex_fetch(%MapSet{}, _key), do: :error
def flex_fetch(map, key) when is_map(map) and is_atom(key) do
case Map.fetch(map, key) do
{:ok, _} = ok -> ok
:error -> Map.fetch(map, to_string(key))
end
end
def flex_fetch(map, key) when is_map(map) and is_binary(key) do
case Map.fetch(map, key) do
{:ok, _} = ok -> ok
:error ->
try do
Map.fetch(map, String.to_existing_atom(key))
rescue
ArgumentError -> :error
end
end
end
def flex_fetch(map, key) when is_map(map), do: Map.fetch(map, key)
def flex_fetch(nil, _key), do: :error
- Add
flex_get_in/2 after flex_fetch:
@doc """
Flexible nested key access: try both atom and string versions at each level.
"""
def flex_get_in(data, []), do: data
def flex_get_in(nil, _path), do: nil
def flex_get_in(data, [key | rest]) when is_map(data) do
case flex_fetch(data, key) do
{:ok, value} -> flex_get_in(value, rest)
:error -> nil
end
end
def flex_get_in(_data, _path), do: nil
- Update
select_keys (line 210):
def select_keys(m, ks) do
Enum.reduce(ks, %{}, fn k, acc ->
case flex_fetch(m, k) do
{:ok, val} -> Map.put(acc, k, val)
:error -> acc
end
end)
end
- Update
get_in (lines 195-201):
def get_in(m, path) when is_map(m), do: flex_get_in(m, path)
def get_in(m, path, default) when is_map(m) do
case flex_get_in(m, path) do
nil -> default
val -> val
end
end
Edge cases to consider:
- MapSet should be handled specially (is_map returns true for MapSet)
nil map should return :error for fetch, nil for get
- String keys that don't have existing atoms should not create atoms (security)
- Keys that are neither atoms nor strings should fall back to exact matching
Test Plan
Unit tests for new functions:
describe "flex_fetch/2" do
test "finds atom key in atom-keyed map" do
assert {:ok, 1} = Runtime.flex_fetch(%{a: 1}, :a)
end
test "finds atom key in string-keyed map" do
assert {:ok, 1} = Runtime.flex_fetch(%{"a" => 1}, :a)
end
test "finds string key in string-keyed map" do
assert {:ok, 1} = Runtime.flex_fetch(%{"a" => 1}, "a")
end
test "finds string key in atom-keyed map" do
assert {:ok, 1} = Runtime.flex_fetch(%{a: 1}, "a")
end
test "returns :error for missing key" do
assert :error = Runtime.flex_fetch(%{a: 1}, :b)
end
test "preserves nil values" do
assert {:ok, nil} = Runtime.flex_fetch(%{a: nil}, :a)
end
test "returns :error for nil map" do
assert :error = Runtime.flex_fetch(nil, :a)
end
test "returns :error for MapSet" do
assert :error = Runtime.flex_fetch(MapSet.new([:a, :b]), :a)
end
end
describe "flex_get_in/2" do
test "traverses nested maps with mixed keys" do
data = %{"user" => %{"name" => "Alice"}}
assert "Alice" = Runtime.flex_get_in(data, [:user, :name])
end
test "traverses nested maps with atom keys" do
data = %{user: %{name: "Alice"}}
assert "Alice" = Runtime.flex_get_in(data, [:user, :name])
end
test "returns nil for missing path" do
assert nil == Runtime.flex_get_in(%{a: 1}, [:b, :c])
end
test "returns nil for nil data" do
assert nil == Runtime.flex_get_in(nil, [:a])
end
test "returns data for empty path" do
assert %{a: 1} == Runtime.flex_get_in(%{a: 1}, [])
end
end
describe "select_keys with flexible access" do
test "preserves nil values in selected keys" do
assert %{a: nil, b: 2} = Runtime.select_keys(%{"a" => nil, "b" => 2}, [:a, :b])
end
test "omits keys that don't exist" do
assert %{a: 1} = Runtime.select_keys(%{"a" => 1}, [:a, :b])
end
end
describe "get_in with flexible access" do
test "works with string keys using atom path" do
assert "value" = Runtime.get_in(%{"a" => %{"b" => "value"}}, [:a, :b])
end
test "uses default for missing path" do
assert "default" = Runtime.get_in(%{a: 1}, [:b, :c], "default")
end
end
E2E test note:
The existing e2e test at test/ptc_runner/lisp/e2e_test.exs:155 ("top N with sorting") will still fail after this issue is complete because it requires Eval module changes (covered in the next phase of the epic). The Runtime functions will be ready for use by the Eval module.
Out of Scope
Summary
Add
flex_fetch/2andflex_get_in/2functions to the Runtime module to support flexible key access that correctly handles both atom and string keys. This enables LLM-generated PTC-Lisp code to work seamlessly with runtime data regardless of key type.Context
Architecture reference: Flex Key Access Spec - Phase 1: Runtime Module Changes
Dependencies: None
Related issues: Epic #186 - Flexible Key Access for PTC-Lisp
Current State
The Runtime module has a private
flex_get/2helper (lines 14-39 inlib/ptc_runner/lisp/runtime.ex) that handles atom↔string key conversion, but:defp), so Eval module can't use itflex_fetch/2that returns{:ok, value} | :error(needed to distinguish nil values from missing keys)flex_get_in/2for nested path access (a duplicate private version exists ineval.ex:478-489)select_keys(line 210) usesMap.take/2which requires exact key matchingget_in(lines 195-201) usesKernel.get_in/2which requires exact key matchingAcceptance Criteria
flex_get/2is made public (definstead ofdefp)flex_fetch/2added that returns{:ok, value}when key exists (including nil values),:errorwhen missingflex_get_in/2added for nested path access with flexible keys at each levelselect_keys/2updated to useflex_fetch/2(preserves nil values in selected keys)get_in/2andget_in/3updated to useflex_get_in/2Implementation Hints
Files to modify:
lib/ptc_runner/lisp/runtime.ex- Add new functions, makeflex_getpublic, updateselect_keysandget_intest/ptc_runner/lisp/runtime_test.exs- Add unit tests for new functionsPatterns to follow:
flex_getat runtime.ex:14-39 - same bidirectional key lookup patternget_in_flexibleat eval.ex:478-489 - reference for path traversal logicCode changes from spec:
Make
flex_getpublic (changedefptodefat lines 14, 16, 23, 38, 39)Add
flex_fetch/2afterflex_getdefinitions:flex_get_in/2afterflex_fetch:select_keys(line 210):get_in(lines 195-201):Edge cases to consider:
nilmap should return:errorfor fetch,nilfor getTest Plan
Unit tests for new functions:
E2E test note:
The existing e2e test at
test/ptc_runner/lisp/e2e_test.exs:155("top N with sorting") will still fail after this issue is complete because it requires Eval module changes (covered in the next phase of the epic). The Runtime functions will be ready for use by the Eval module.Out of Scope
get_in_flexiblefrom Eval - covered by epic Epic: Flexible Key Access for PTC-Lisp #186