Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- **breaking**: dotenvy CLI exits with code 2 instead of code 1 if the external command is omitted
- Fix doctests on windows not compiling ([PR #79](https://github.com/allan2/dotenvy/pull/79) by [vallentin](https://github.com/vallentin).
- MSRV updated to 1.68.0
- special handling for JSON values ([#84](https://github.com/allan2/dotenvy/issues/84) by [ansel1](https://github.com/ansel1))

## [0.15.7] - 2023-03-22

Expand Down
106 changes: 104 additions & 2 deletions dotenvy/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ enum SubstitutionMode {
EscapedBlock,
}

#[allow(clippy::cognitive_complexity)]
fn parse_value(
input: &str,
substitution_data: &HashMap<String, Option<String>>,
Expand All @@ -123,13 +124,24 @@ fn parse_value(
let mut weak_quote = false; // "
let mut escaped = false;
let mut expecting_end = false;
let mut brace_depth = 0; // track depth of curly braces for JSON objects
let mut bracket_depth = 0; // track depth of square brackets for JSON arrays
let mut json_literal_mode = false; // when true, treat everything as literal until braces/brackets are balanced

//FIXME can this be done without yet another allocation per line?
let mut output = String::new();

let mut substitution_mode = SubstitutionMode::None;
let mut substitution_name = String::new();

// Check if the value starts with a brace or bracket to enable JSON literal mode
let trimmed = input.trim_start();
if (trimmed.starts_with('{') && trimmed.ends_with('}'))
|| (trimmed.starts_with('[') && trimmed.ends_with(']'))
{
json_literal_mode = true;
}

for (index, c) in input.chars().enumerate() {
//the regex _should_ already trim whitespace off the end
//expecting_end is meant to permit: k=v #comment
Expand Down Expand Up @@ -203,6 +215,8 @@ fn parse_value(
}
}
}
} else if c == '\\' {
escaped = true;
} else if c == '$' {
substitution_mode = if !strong_quote && !escaped {
SubstitutionMode::Block
Expand All @@ -217,12 +231,30 @@ fn parse_value(
} else {
output.push(c);
}
} else if json_literal_mode {
// In JSON literal mode, track braces and brackets and almost everything as a literal
// Substitution and escapes are still supported.
if c == '{' {
brace_depth += 1;
} else if c == '}' && brace_depth > 0 {
brace_depth -= 1;
} else if c == '[' {
bracket_depth += 1;
} else if c == ']' && bracket_depth > 0 {
bracket_depth -= 1;
}

output.push(c);

// Exit JSON literal mode when all braces and brackets are balanced
if brace_depth == 0 && bracket_depth == 0 {
json_literal_mode = false;
expecting_end = true; // After JSON literal, expect end of value
}
} else if c == '\'' {
strong_quote = true;
} else if c == '"' {
weak_quote = true;
} else if c == '\\' {
escaped = true;
} else if c == ' ' || c == '\t' {
expecting_end = true;
} else {
Expand Down Expand Up @@ -398,6 +430,62 @@ KEY4=h\8u
assert!(actual.is_err());
}
}

#[test]
fn test_parse_json() -> Result<(), ParseBufError> {
let input = r#"
JSON={ "foo": "bar" }
ACCOUNT={ "r": "1", "a": "2" }
NESTED={ "config": { "nested": true, "value": 42 } }
ARRAY={ "items": [1, 2, 3] }
SIMPLE_ARRAY=[1, 2, 3]
STRING_ARRAY=["foo", "bar", "baz"]
NESTED_ARRAY=[[1, 2], [3, 4]]
OBJECT_ARRAY=[{"name": "Alice"}, {"name": "Bob"}]
MIXED_WITH_SPACES= [ "spaced" , "array" ]
NAME=red
SUBSTITUTIONS={"name":"${NAME}"}
ESCAPES={"name":"\${NAME}"}
NOT_JSON={"name"
NOT_JSON_2=[{"name"
"#;
let actual_iter = Iter::new(input.as_bytes());

let expected = vec![
("JSON", "{ \"foo\": \"bar\" }"),
("ACCOUNT", "{ \"r\": \"1\", \"a\": \"2\" }"),
(
"NESTED",
"{ \"config\": { \"nested\": true, \"value\": 42 } }",
),
("ARRAY", "{ \"items\": [1, 2, 3] }"),
("SIMPLE_ARRAY", "[1, 2, 3]"),
("STRING_ARRAY", "[\"foo\", \"bar\", \"baz\"]"),
("NESTED_ARRAY", "[[1, 2], [3, 4]]"),
(
"OBJECT_ARRAY",
"[{\"name\": \"Alice\"}, {\"name\": \"Bob\"}]",
),
("MIXED_WITH_SPACES", "[ \"spaced\" , \"array\" ]"),
("NAME", "red"),
("SUBSTITUTIONS", "{\"name\":\"red\"}"),
("ESCAPES", "{\"name\":\"${NAME}\"}"),
("NOT_JSON", "{name"),
("NOT_JSON_2", "[{name"),
];
let expected_count = expected.len();
let expected_iter = expected
.into_iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()));

let mut count = 0;
for (expected, actual) in expected_iter.zip(actual_iter) {
assert_eq!(expected, actual?);
count += 1;
}
assert_eq!(count, expected_count);
Ok(())
}
}

#[cfg(test)]
Expand Down Expand Up @@ -620,4 +708,18 @@ mod error_tests {
Err(ParseBufError::LineParse(ref v, idx)) if v == invalid_esc && idx == invalid_esc.find('\\').unwrap() + 1
));
}

#[test]
fn should_not_parse_unquoted_json_with_other_string() {
let invalid_value = r#"{ "foo": "bar" } some other string"#;
let line = format!("KEY={invalid_value}");
let iter = Iter::new(line.as_bytes()).collect::<Vec<_>>();

let err_idx = invalid_value.find("\"").unwrap();

assert!(matches!(
iter[0],
Err(ParseBufError::LineParse(ref v, idx)) if v == invalid_value && idx == err_idx
));
}
}