Skip to content

Commit ac52df6

Browse files
committed
Add shell-style variable interpolation with $VAR and ${VAR} syntax
1 parent e8e9105 commit ac52df6

File tree

4 files changed

+278
-33
lines changed

4 files changed

+278
-33
lines changed

lib/envious.ex

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ defmodule Envious do
4747
- `{:ok, map}` on success, where map contains the parsed key-value pairs
4848
- `{:error, message}` on failure, with a descriptive error message including line/column info
4949
50+
Variable interpolation is supported using `$VAR` or `${VAR}` syntax in values.
51+
Variables are resolved using previously defined variables in the file (top-down).
52+
5053
## Examples
5154
5255
iex> Envious.parse("PORT=3000")
@@ -55,14 +58,19 @@ defmodule Envious do
5558
iex> Envious.parse("export API_KEY=secret\\nDATABASE_URL=postgres://localhost")
5659
{:ok, %{"API_KEY" => "secret", "DATABASE_URL" => "postgres://localhost"}}
5760
61+
iex> Envious.parse("A=foo\\nB=$A-bar")
62+
{:ok, %{"A" => "foo", "B" => "foo-bar"}}
63+
5864
iex> Envious.parse("KEY=\\"unclosed")
5965
{:error, "Parse error at line 1, column 5: could not parse remaining input"}
6066
"""
6167
def parse(str) do
6268
case Parser.parse(str) do
6369
# Success with all input consumed
6470
{:ok, parsed, "", _context, _line, _offset} ->
65-
{:ok, Map.new(parsed)}
71+
# Build the map while resolving variable interpolations
72+
result = build_env_map(parsed)
73+
{:ok, result}
6674

6775
# Success but with remaining unparsed input - this is an error
6876
{:ok, _parsed, remaining, _context, {line, col}, _offset} when remaining != "" ->
@@ -80,6 +88,27 @@ defmodule Envious do
8088
end
8189
end
8290

91+
# Build environment map from parsed tuples, resolving variable interpolations
92+
# Variables are resolved in order, so later variables can reference earlier ones
93+
defp build_env_map(parsed) do
94+
Enum.reduce(parsed, %{}, fn {key, value}, acc ->
95+
resolved_value = resolve_interpolations(value, acc)
96+
Map.put(acc, key, resolved_value)
97+
end)
98+
end
99+
100+
# Resolve variable interpolations in a value using the accumulated environment
101+
# Only resolves specially-marked interpolations from the parser, not literal $VAR in the input
102+
defp resolve_interpolations(value, env) when is_binary(value) do
103+
# Replace our special markers with actual variable values
104+
# Format: __ENVIOUS_VAR__[varname]__
105+
Regex.replace(~r/__ENVIOUS_VAR__\[([^\]]+)\]__/, value, fn _, var_name ->
106+
Map.get(env, var_name, "")
107+
end)
108+
end
109+
110+
defp resolve_interpolations(value, _env), do: value
111+
83112
@doc """
84113
Parse a .env file string into a map, raising on error.
85114

lib/envious/parser.ex

Lines changed: 105 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -137,33 +137,51 @@ defmodule Envious.Parser do
137137
?]..?~
138138
])
139139

140-
# Content inside double quotes: either escape sequences or regular characters
140+
# Variable interpolation: $VAR or ${VAR}
141+
# Returns the variable name tagged for later interpolation
142+
var_interpolation =
143+
choice([
144+
# ${VAR} format - capture just the variable name
145+
ignore(string("${"))
146+
|> concat(var_name)
147+
|> ignore(string("}"))
148+
|> tag(:var_interp),
149+
# $VAR format - capture just the variable name
150+
ignore(string("$"))
151+
|> concat(var_name)
152+
|> tag(:var_interp)
153+
])
154+
155+
# Content inside double quotes: escape sequences, variable interpolation, or regular characters
156+
# Double quotes allow variable interpolation (like shell behavior)
141157
double_quoted_content =
142158
choice([
159+
var_interpolation,
143160
escape_sequence,
144161
double_quoted_regular_char
145162
])
146163

147164
# Content inside single quotes: either escape sequences or regular characters
165+
# Single quotes do NOT allow variable interpolation (like shell behavior)
148166
single_quoted_content =
149167
choice([
150168
escape_sequence,
151169
single_quoted_regular_char
152170
])
153171

154-
# Double-quoted value: "value with spaces"
155-
# - Handles escape sequences and regular characters
156-
# - Post-processes to convert escape sequences to actual characters
172+
# Double-quoted value: "value with spaces and $VAR interpolation"
173+
# - Handles escape sequences, variable interpolation, and regular characters
174+
# - Post-processes to build string with interpolations resolved
157175
double_quoted_value =
158176
ignore(double_quote)
159177
|> times(double_quoted_content, min: 0)
160178
|> ignore(double_quote)
161-
|> reduce({List, :to_string, []})
162-
|> post_traverse(:process_escape_sequences)
179+
|> post_traverse(:build_quoted_value)
163180

164-
# Single-quoted value: 'value with spaces'
181+
# Single-quoted value: 'value with spaces, no interpolation'
165182
# - Handles escape sequences and regular characters
166183
# - Post-processes to convert escape sequences to actual characters
184+
# - Does NOT support variable interpolation (like shell behavior)
167185
single_quoted_value =
168186
ignore(single_quote)
169187
|> times(single_quoted_content, min: 0)
@@ -175,29 +193,39 @@ defmodule Envious.Parser do
175193
# - Newline (\n) and carriage return (\r) - these end the value
176194
# - Hash (#) - this starts an inline comment
177195
# - Quotes (" and ') - these start quoted values
196+
# - Dollar ($) - this starts variable interpolation
197+
# - Brace ({, }) - reserved for ${VAR} syntax
178198
#
179199
# Character ranges:
180200
# - ?\s..?! is space (0x20) through exclamation (0x21), excluding double-quote (0x22)
181-
# - ?$..?& is dollar through ampersand, excluding hash (0x23) and single-quote (0x27)
182-
# - ?(..?~ is open-paren through tilde - includes alphanumeric and symbols
201+
# - ?%..?& is percent through ampersand, excluding hash (0x23) and single-quote (0x27)
202+
# - ?(..?z is open-paren through lowercase z, excluding braces ({ and })
203+
# - ?|..?~ is pipe through tilde
183204
unquoted_value_char =
184205
utf8_char([
185206
# Space (32) through exclamation (33), which excludes double-quote (34)
186207
?\s..?!,
187-
# Dollar (36) through ampersand (38), which excludes hash (35) and single-quote (39)
188-
?$..?&,
189-
# Open-paren (40) through tilde (126) - includes all alphanumeric and symbols
190-
?(..?~
208+
# Percent (37) through ampersand (38), which excludes hash (35), dollar (36), and single-quote (39)
209+
?%..?&,
210+
# Open-paren (40) through lowercase z (122), which excludes left-brace (123)
211+
?(..?z,
212+
# Pipe (124) through tilde (126), which excludes right-brace (125)
213+
?|..?~
191214
])
192215

193-
# Unquoted value: traditional unquoted values
194-
# - Collect 0 or more value characters (allows empty values like KEY=)
195-
# - Convert the character list to a string
196-
# - Trim whitespace from both ends (handles inline comments: "value # comment")
216+
# Unquoted value content: either variable interpolation or regular characters
217+
unquoted_value_content =
218+
choice([
219+
var_interpolation,
220+
unquoted_value_char
221+
])
222+
223+
# Unquoted value: supports variable interpolation
224+
# - Collect 0 or more value parts (allows empty values like KEY=)
225+
# - Post-process to trim whitespace (handles inline comments: "value # comment")
197226
unquoted_value =
198-
times(unquoted_value_char, min: 0)
199-
|> reduce({List, :to_string, []})
200-
|> post_traverse(:trim_value)
227+
times(unquoted_value_content, min: 0)
228+
|> post_traverse(:build_unquoted_value)
201229

202230
# Parse the value portion after the = sign
203231
# Values can be:
@@ -274,28 +302,73 @@ defmodule Envious.Parser do
274302
defp not_line_terminator(<<?\r, _::binary>>, context, _, _), do: {:halt, context}
275303
defp not_line_terminator(_, context, _, _), do: {:cont, context}
276304

277-
# Post-traversal callback to trim whitespace from parsed values
305+
# Post-traversal callback to build a quoted value with variable interpolation support
278306
#
279-
# This is used to remove trailing whitespace before inline comments.
280-
# Example: "value # comment" becomes "value"
307+
# Takes the accumulated tokens (which may include :var_interp tags and character codes)
308+
# and builds a single string value. Variable interpolations are left as markers
309+
# to be resolved later by the main Envious module.
281310
#
282311
# Parameters:
283312
# - rest: Remaining input after parsing
284-
# - [value]: The parsed value (as a single-element list from the accumulator)
313+
# - acc: Accumulated tokens from the quoted value
285314
# - context: Parser context
286315
# - _line, _offset: Position information (unused)
287316
#
288-
# Returns: {rest, [trimmed_value], context}
317+
# Returns: {rest, [string_value], context}
318+
defp build_quoted_value(rest, acc, context, _line, _offset) do
319+
# Reverse the accumulator and build the string
320+
value = acc |> Enum.reverse() |> build_value_string([])
321+
{rest, [value], context}
322+
end
323+
324+
# Post-traversal callback to build an unquoted value with variable interpolation support
325+
#
326+
# Similar to build_quoted_value but also trims whitespace.
289327
#
290-
# Note: We must return `rest` (not an empty list) to allow the parser to
291-
# continue processing remaining input. Returning [] would consume all input.
292-
defp trim_value(rest, [value], context, _line, _offset) when is_binary(value) do
293-
{rest, [String.trim(value)], context}
328+
# Parameters:
329+
# - rest: Remaining input after parsing
330+
# - acc: Accumulated tokens from the unquoted value
331+
# - context: Parser context
332+
# - _line, _offset: Position information (unused)
333+
#
334+
# Returns: {rest, [string_value], context}
335+
defp build_unquoted_value(rest, acc, context, _line, _offset) do
336+
# Reverse the accumulator and build the string, then trim
337+
value = acc |> Enum.reverse() |> build_value_string([]) |> String.trim()
338+
{rest, [value], context}
294339
end
295340

296-
# Fallback clause for trim_value when accumulator doesn't match expected pattern
297-
defp trim_value(rest, acc, context, _line, _offset) do
298-
{rest, acc, context}
341+
# Build a string from a list of tokens, handling variable interpolations
342+
# Tokens can be:
343+
# - Integers (character codes)
344+
# - {:var_interp, [var_name]} tuples representing variable interpolations
345+
# Returns a string with interpolation markers using a special syntax that won't conflict with user input
346+
defp build_value_string([], acc) do
347+
acc
348+
|> Enum.reverse()
349+
|> IO.iodata_to_binary()
350+
|> process_escapes_in_string([])
351+
|> IO.iodata_to_binary()
352+
end
353+
354+
defp build_value_string([{:var_interp, [var_name]} | rest], acc) when is_binary(var_name) do
355+
# Use a special marker that won't conflict with normal .env content
356+
# Format: __ENVIOUS_VAR__[varname]__
357+
build_value_string(rest, ["__ENVIOUS_VAR__[#{var_name}]__" | acc])
358+
end
359+
360+
defp build_value_string([char | rest], acc) when is_integer(char) do
361+
build_value_string(rest, [<<char::utf8>> | acc])
362+
end
363+
364+
defp build_value_string([other | rest], acc) do
365+
# Handle any other tokens (shouldn't normally happen)
366+
build_value_string(rest, [to_string(other) | acc])
367+
end
368+
369+
# Process escape sequences in a string (for values that support them)
370+
defp process_escapes_in_string(binary, acc) when is_binary(binary) do
371+
process_escapes(binary, acc)
299372
end
300373

301374
# Post-traversal callback to convert [value, key] list to {key, value} tuple

test/envious/parser_test.exs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,89 @@ defmodule Envious.ParserTest do
4242
assert Parser.parse(file) ==
4343
{:ok, [{"FOO", "bar"}, {"BAZ", "qux"}], "", %{}, {4, 61}, 61}
4444
end
45+
46+
test "variable interpolation with ${VAR} in double quotes" do
47+
file = """
48+
export A="foo"
49+
export B="bar and ${A}"
50+
"""
51+
52+
# Parser just parses - interpolation happens in Envious module
53+
result = Envious.parse(file)
54+
assert result == {:ok, %{"A" => "foo", "B" => "bar and foo"}}
55+
end
56+
57+
test "variable interpolation with $VAR in double quotes" do
58+
file = """
59+
A=hello
60+
B="world and $A"
61+
"""
62+
63+
result = Envious.parse(file)
64+
assert result == {:ok, %{"A" => "hello", "B" => "world and hello"}}
65+
end
66+
67+
test "variable interpolation in unquoted values" do
68+
file = """
69+
export A=foo
70+
export B=bar-$A-baz
71+
"""
72+
73+
result = Envious.parse(file)
74+
assert result == {:ok, %{"A" => "foo", "B" => "bar-foo-baz"}}
75+
end
76+
77+
test "mixed variable interpolation formats" do
78+
file = """
79+
export A="A"
80+
export B="B and ${A} or $A"
81+
"""
82+
83+
result = Envious.parse(file)
84+
assert result == {:ok, %{"A" => "A", "B" => "B and A or A"}}
85+
end
86+
87+
test "variable interpolation with undefined variable" do
88+
file = """
89+
B="value is $UNDEFINED"
90+
"""
91+
92+
result = Envious.parse(file)
93+
# Undefined variables resolve to empty string
94+
assert result == {:ok, %{"B" => "value is "}}
95+
end
96+
97+
test "single quotes do not interpolate variables" do
98+
file = """
99+
A=foo
100+
B='$A is not interpolated'
101+
"""
102+
103+
result = Envious.parse(file)
104+
assert result == {:ok, %{"A" => "foo", "B" => "$A is not interpolated"}}
105+
end
106+
107+
test "multiple interpolations in one value" do
108+
file = """
109+
A=hello
110+
B=world
111+
C="$A $B from ${A} and ${B}"
112+
"""
113+
114+
result = Envious.parse(file)
115+
116+
assert result ==
117+
{:ok, %{"A" => "hello", "B" => "world", "C" => "hello world from hello and world"}}
118+
end
119+
120+
test "chained variable interpolation" do
121+
file = """
122+
A=foo
123+
B=$A-bar
124+
C=$B-baz
125+
"""
126+
127+
result = Envious.parse(file)
128+
assert result == {:ok, %{"A" => "foo", "B" => "foo-bar", "C" => "foo-bar-baz"}}
129+
end
45130
end

0 commit comments

Comments
 (0)