Skip to content
Merged
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 Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ tasks:
cmds:
- task: test
- bash devops/release.sh {{.VERSION}}
- echo "Don't forget \033[0;32mcargo publish"

publish:npm:
desc: "Manually publish the current version to npmjs (normally handled by GitHub Actions)"
Expand Down
13 changes: 13 additions & 0 deletions devops/render-release-notes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ brew install mikegarde/tap/dotenv-cli
npm install -g @mikegarde/dotenv-cli
\`\`\`

### Docker

\`\`\`bash
ARG TARGETARCH
ARG DOTENV_CLI_VERSION=${VERSION}

RUN ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") \
&& curl -fsSL \
"https://github.com/MikeGarde/dotenv-cli/releases/download/${DOTENV_CLI_VERSION}/dotenv-cli-${DOTENV_CLI_VERSION}-unknown-linux-gnu-${ARCH}.gz" \
| gzip -d > /usr/local/bin/dotenv \
&& chmod +x /usr/local/bin/dotenv
\`\`\`

### Manual

RHEL x86
Expand Down
3 changes: 3 additions & 0 deletions src/env_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub struct EnvValue {
pub line_start: i64,
pub line_end: i64,
pub no_expand: bool,
pub exported: bool,
}

impl EnvValue {
Expand All @@ -19,6 +20,7 @@ impl EnvValue {
line_start: -1,
line_end: -1,
no_expand: false,
exported: false,
}
}

Expand All @@ -28,6 +30,7 @@ impl EnvValue {
line_start,
line_end,
no_expand: false,
exported: false,
}
}
}
Expand Down
167 changes: 130 additions & 37 deletions src/env_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,84 @@ use std::fs;
use crate::env_object::{EnvObject, EnvValue};
use crate::errors::EnvParseError;

/// Walk forward from `start` until a line whose trimmed form ends with `end_str`.
/// Returns the index of that line, capped at the last line index.
fn get_end_line(lines: &[&str], start: usize, end_str: &str) -> usize {
let mut end = start;
while end < lines.len() && !lines[end].trim().ends_with(end_str) {
if end + 1 >= lines.len() {
break;
/// Find the line and byte offset of the `]` that closes a JSON array opened by
/// the `[` at `open_pos` on `lines[start]`.
///
/// Counts bracket depth rather than looking for a `]` by position, so nested
/// arrays close on the right bracket and a `]` inside a JSON string (`["a]b"]`)
/// is skipped. String state carries across lines so a multi-line array scans as
/// one blob. Locating the close structurally is what lets a trailing comment be
/// dropped safely — including one that itself contains a bracket, as in
/// `LIST=["a"] # see note [1]`, which a scan for the *last* `]` on the line got
/// wrong.
///
/// Errors instead of scanning to EOF if the array never closes, so an
/// unterminated list fails loudly rather than absorbing the rest of the file.
fn find_list_close(
lines: &[&str],
start: usize,
open_pos: usize,
) -> Result<(usize, usize), EnvParseError> {
let mut depth: usize = 0;
let mut in_string = false;
let mut escaped = false;

for (line_idx, line) in lines.iter().enumerate().skip(start) {
let from = if line_idx == start { open_pos } else { 0 };
for (rel, c) in line[from..].char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' if in_string => escaped = true,
'"' => in_string = !in_string,
'[' if !in_string => depth += 1,
// `depth <= 1` rather than a decrement-then-test so a stray
// leading `]` cannot underflow the counter.
']' if !in_string && depth <= 1 => return Ok((line_idx, from + rel)),
']' if !in_string => depth -= 1,
_ => {}
}
}
end += 1;
// A line break cannot continue a backslash escape.
escaped = false;
}
end

Err(EnvParseError {
line: start + 1,
message: format!(
"Unterminated list starting on line {}: {}",
start + 1,
lines[start]
),
})
}

/// Extract the value of a list KEY spanning lines[start..=end_line], given the
/// byte offsets of the opening `[` on lines[start] and the closing `]` on
/// lines[end_line] (as returned by `find_list_close`). Both brackets are part
/// of the value; everything after the closing one — including a trailing
/// `# comment` — is discarded.
fn extract_list_value(
lines: &[&str],
start: usize,
end_line: usize,
open_pos: usize,
close_pos: usize,
) -> Result<String, EnvParseError> {
let blob = if start == end_line {
lines[start][open_pos..=close_pos].to_string()
} else {
let mut parts: Vec<&str> = vec![&lines[start][open_pos..]];
for line in lines.iter().take(end_line).skip(start + 1) {
parts.push(line);
}
parts.push(&lines[end_line][..=close_pos]);
parts.join("\n")
};

reformat_json_array_if_present(blob, start)
}

/// If `blob` looks like a JSON array, re-format it as `["a", "b", ...]`.
Expand Down Expand Up @@ -45,15 +112,12 @@ fn reformat_json_array_if_present(blob: String, start: usize) -> Result<String,
///
/// - Takes everything after the first '=' on lines[start] (preserving '=' in values).
/// - Joins continuation lines with '\n'.
/// - If `quoted`, strips one layer of outer quote characters.
/// - Everything after '=' is literal: dotenv-cli has never treated a trailing
/// `#` on an unquoted line as a comment. Quoted and list values, which do
/// support one, are extracted by `extract_quoted_value`/`extract_list_value`.
/// - If the result looks like a JSON array, re-formats it as `["a", "b", ...]`
/// and returns an error if the JSON is invalid.
fn extract_value(
lines: &[&str],
start: usize,
end: usize,
quoted: bool,
) -> Result<String, EnvParseError> {
fn extract_value(lines: &[&str], start: usize, end: usize) -> Result<String, EnvParseError> {
let first_line = lines[start];
let after_eq = first_line
.find('=')
Expand All @@ -65,12 +129,7 @@ fn extract_value(
parts.push(line);
}

let mut blob = parts.join("\n").trim().to_string();

if quoted && blob.len() >= 2 {
// Strip outer quote character (first and last byte – safe for ASCII " and ')
blob = blob[1..blob.len() - 1].to_string();
}
let blob = parts.join("\n").trim().to_string();

reformat_json_array_if_present(blob, start)
}
Expand Down Expand Up @@ -156,7 +215,12 @@ fn extract_quoted_value(
/// - `KEY="double quoted"` (single or multiline)
/// - `KEY='single quoted'` (single or multiline)
/// - `KEY=["json", "array"]` (single or multiline)
/// - Comments (`# …`) and blank lines are ignored.
/// - `export KEY=value` — the prefix is dropped from the key name. It is the
/// only word allowed before a key; any other whitespace inside a key name is
/// reported as a violation.
/// - Comments (`# …`) and blank lines are ignored. A comment may also trail a
/// quoted value or a list's closing `]`; on an unquoted value, everything
/// after `=` is taken literally.
/// - Lines without `=` are ignored (e.g. `// invalid comment`).
pub fn parse_env_file(file_path: &str) -> Result<EnvObject, EnvParseError> {
let content = fs::read_to_string(file_path).map_err(|e| EnvParseError {
Expand Down Expand Up @@ -193,21 +257,45 @@ pub fn parse_env_file(file_path: &str) -> Result<EnvObject, EnvParseError> {

let key_segment = &trimmed[..eq_pos];
let value_segment = &trimmed[eq_pos + 1..];
let key = key_segment.trim().to_string();
let value_part = value_segment.trim();

// `export KEY=value` is the common form for .env files that are also
// meant to be `source`d. The prefix is not part of the key name; only
// strip it when whitespace follows, so a key literally named `export`
// still works.
let key_text = key_segment.trim();
let (key, exported) = match key_text.strip_prefix("export") {
Some(rest) if rest.starts_with(char::is_whitespace) => (rest.trim().to_string(), true),
_ => (key_text.to_string(), false),
};

if key.is_empty() {
i += 1;
continue;
}

// `export` is the only word allowed to precede a key, and it has already
// been stripped above. Anything else separated by whitespace is a typo
// or an unsupported dialect — either way the key is unaddressable, so
// say so rather than inventing a key name that no shell could use.
if key.contains(char::is_whitespace) {
violations.push((
line_start + 1,
format!("invalid key '{}': keys cannot contain whitespace", key),
));
}

// Whitespace hugging the '=' (`KEY = value` or `KEY= value`) is not part
// of the key or value and is almost always a mistake.
if key_segment.ends_with(char::is_whitespace) || value_segment.starts_with(char::is_whitespace)
if key_segment.ends_with(char::is_whitespace)
|| value_segment.starts_with(char::is_whitespace)
{
violations.push((
line_start + 1,
format!("whitespace around '=' is not allowed: {}", raw_lines[line_start]),
format!(
"whitespace around '=' is not allowed: {}",
raw_lines[line_start]
),
));
}

Expand All @@ -230,15 +318,21 @@ pub fn parse_env_file(file_path: &str) -> Result<EnvObject, EnvParseError> {
let mut env_value = EnvValue::with_lines(value, line_start as i64, end as i64);
// Single quotes disable ${VAR} expansion (literal, POSIX-style).
env_value.no_expand = quote == '\'';
env_value.exported = exported;
env_object.set(key, env_value);
i = end + 1;
} else if value_part.starts_with('[') {
let end = get_end_line(&raw_lines, line_start, "]");
let value = extract_value(&raw_lines, line_start, end, false)?;
env_object.set(
key,
EnvValue::with_lines(value, line_start as i64, end as i64),
);
let first_line = raw_lines[line_start];
let raw_eq_pos = first_line.find('=').unwrap_or(0);
let open_pos = first_line[raw_eq_pos..]
.find('[')
.map(|p| raw_eq_pos + p)
.unwrap_or(raw_eq_pos);
let (end, close_pos) = find_list_close(&raw_lines, line_start, open_pos)?;
let value = extract_list_value(&raw_lines, line_start, end, open_pos, close_pos)?;
let mut env_value = EnvValue::with_lines(value, line_start as i64, end as i64);
env_value.exported = exported;
env_object.set(key, env_value);
i = end + 1;
} else {
// Unquoted value — must not contain bare quotes (would indicate a parse issue)
Expand All @@ -248,11 +342,10 @@ pub fn parse_env_file(file_path: &str) -> Result<EnvObject, EnvParseError> {
message: format!("Invalid value: {}", raw_lines[i]),
});
}
let value = extract_value(&raw_lines, line_start, line_start, false)?;
env_object.set(
key,
EnvValue::with_lines(value, line_start as i64, line_start as i64),
);
let value = extract_value(&raw_lines, line_start, line_start)?;
let mut env_value = EnvValue::with_lines(value, line_start as i64, line_start as i64);
env_value.exported = exported;
env_object.set(key, env_value);
i += 1;
}
}
Expand Down
15 changes: 11 additions & 4 deletions src/handlers/set_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@ use crate::qualifying_rules::Options;
pub fn set_value(options: &Options) {
let key = &options.target_keys[0];
let set_val = options.set_value.as_deref().unwrap_or("");

let env_object = options.env_object.as_ref().unwrap();

// A key that was written as `export KEY=value` keeps its prefix on rewrite,
// so a file meant to be `source`d stays sourceable.
let prefix = match env_object.get(key) {
Some(env_val) if env_val.exported => "export ",
_ => "",
};
let new_line = if set_val.contains('\n') {
format!("{}=\"{}\"", key, set_val)
format!("{}{}=\"{}\"", prefix, key, set_val)
} else {
format!("{}={}", key, set_val)
format!("{}{}={}", prefix, key, set_val)
};

let env_object = options.env_object.as_ref().unwrap();

if let Some(env_val) = env_object.get(key) {
// Read file fresh (in case concurrent changes occurred)
let content = fs::read_to_string(&options.full_env_path).expect("Failed to read .env file");
Expand Down
Loading
Loading