use std::{
collections::{BTreeMap, BTreeSet},
fmt, fs, io,
ops::Range,
path::{Path, PathBuf},
string::String,
vec::Vec,
};
use crate::{
encode_value, parse_strict, parse_strict_with, parse_with, Diagnostic, Dialect, ExpandOptions,
ExpandScope, ResolveError, Severity,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct LoadOrigin {
pub path: PathBuf,
pub span: Range<usize>,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LoadedEntry {
pub key: String,
pub value: String,
pub origin: LoadOrigin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LoadOverride {
pub key: String,
pub previous: LoadOrigin,
pub replacement: LoadOrigin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LoadRepeated {
pub key: String,
pub origins: Vec<LoadOrigin>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SkipReason {
Bare,
NonParticipating,
EmptyKey,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LoadSkipped {
pub key: Option<String>,
pub origin: LoadOrigin,
pub reason: SkipReason,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedEnvironment {
values: BTreeMap<String, String>,
origins: BTreeMap<String, LoadOrigin>,
raw_entries: BTreeMap<String, (usize, String, String)>,
dialect: Dialect,
entries: Vec<LoadedEntry>,
overrides: Vec<LoadOverride>,
repeated: Vec<LoadRepeated>,
skipped: Vec<LoadSkipped>,
}
impl LoadedEnvironment {
#[must_use]
pub fn values(&self) -> &BTreeMap<String, String> {
&self.values
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.values.get(key).map(String::as_str)
}
#[must_use]
pub fn origin(&self, key: &str) -> Option<&LoadOrigin> {
self.origins.get(key)
}
#[must_use]
pub fn entries(&self) -> &[LoadedEntry] {
&self.entries
}
#[must_use]
pub fn overrides(&self) -> &[LoadOverride] {
&self.overrides
}
#[must_use]
pub fn repeated(&self) -> &[LoadRepeated] {
&self.repeated
}
#[must_use]
pub fn skipped(&self) -> &[LoadSkipped] {
&self.skipped
}
#[must_use]
pub fn len(&self) -> usize {
self.values.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedEnvironment {
values: BTreeMap<String, String>,
origins: BTreeMap<String, LoadOrigin>,
}
impl ResolvedEnvironment {
#[must_use]
pub fn values(&self) -> &BTreeMap<String, String> {
&self.values
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.values.get(key).map(String::as_str)
}
#[must_use]
pub fn origin(&self, key: &str) -> Option<&LoadOrigin> {
self.origins.get(key)
}
#[must_use]
pub fn len(&self) -> usize {
self.values.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedResolveError {
key: Option<String>,
origin: Option<LoadOrigin>,
message: String,
}
impl LoadedResolveError {
fn new(key: Option<String>, origin: Option<LoadOrigin>, message: impl Into<String>) -> Self {
Self {
key,
origin,
message: message.into(),
}
}
#[must_use]
pub fn key(&self) -> Option<&str> {
self.key.as_deref()
}
#[must_use]
pub fn origin(&self) -> Option<&LoadOrigin> {
self.origin.as_ref()
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for LoadedResolveError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(origin) = &self.origin {
write!(formatter, "{}:{}: ", origin.path.display(), origin.line)?;
}
formatter.write_str(&self.message)
}
}
impl std::error::Error for LoadedResolveError {}
impl LoadedEnvironment {
pub fn resolve_decoded<F>(
&self,
lookup: F,
options: &ExpandOptions,
) -> Result<ResolvedEnvironment, LoadedResolveError>
where
F: Fn(&str) -> Option<String>,
{
if options.scope != ExpandScope::WholeFile {
return Err(LoadedResolveError::new(
None,
None,
"loaded environments require whole-file resolution scope",
));
}
let mut source = String::new();
for (key, value) in &self.values {
let token = encode_value(value).ok_or_else(|| {
LoadedResolveError::new(
Some(key.clone()),
self.origin(key).cloned(),
"value cannot be represented exactly",
)
})?;
source.push_str(key);
source.push('=');
source.push_str(&token);
source.push('\n');
}
let document = parse_strict(&source).map_err(|error| {
LoadedResolveError::new(None, None, format!("cannot prepare resolution: {error}"))
})?;
let mut options = *options;
options.rules.single_quotes = true;
options.rules.backtick_quotes = true;
options.rules.escaped_dollar = true;
let resolved = document.resolve(lookup, &options).map_err(|error| {
let key = resolve_error_key(&error).to_string();
let origin = self.origin(&key).cloned();
LoadedResolveError::new(Some(key), origin, error.to_string())
})?;
let mut values = BTreeMap::new();
for entry in resolved.entries() {
values.insert(entry.key.clone(), entry.value.clone().unwrap_or_default());
}
Ok(ResolvedEnvironment {
values,
origins: self.origins.clone(),
})
}
pub fn resolve_contextual<F>(
&self,
lookup: F,
options: &ExpandOptions,
) -> Result<ResolvedEnvironment, LoadedResolveError>
where
F: Fn(&str) -> Option<String>,
{
let mut entries: Vec<_> = self.raw_entries.iter().collect();
entries.sort_unstable_by_key(|(_, (order, _, _))| *order);
let mut source = String::new();
for (_, (_, raw_key, raw_value)) in entries {
source.push_str(raw_key);
source.push('=');
source.push_str(raw_value);
source.push('\n');
}
let document = parse_strict_with(&source, self.dialect).map_err(|error| {
LoadedResolveError::new(None, None, format!("cannot prepare resolution: {error}"))
})?;
let resolved = document.resolve(lookup, options).map_err(|error| {
let key = resolve_error_key(&error).to_string();
let origin = self.origin(&key).cloned();
LoadedResolveError::new(Some(key), origin, error.to_string())
})?;
let mut values = BTreeMap::new();
for entry in resolved.entries() {
values.insert(entry.key.clone(), entry.value.clone().unwrap_or_default());
}
Ok(ResolvedEnvironment {
values,
origins: self.origins.clone(),
})
}
}
fn resolve_error_key(error: &ResolveError) -> &str {
match error {
ResolveError::Unresolved { key, .. }
| ResolveError::Required { key, .. }
| ResolveError::Cycle { key, .. }
| ResolveError::DepthExceeded { key, .. }
| ResolveError::ValueTooLarge { key, .. }
| ResolveError::Command { key, .. } => key,
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum LoadError {
Io {
path: PathBuf,
source: io::Error,
},
InvalidUtf8 {
path: PathBuf,
},
Invalid {
path: PathBuf,
diagnostic: Diagnostic,
},
InvalidMode,
}
impl fmt::Display for LoadError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => {
write!(formatter, "failed to read {}: {source}", path.display())
}
Self::InvalidUtf8 { path } => {
write!(formatter, "{} is not valid UTF-8", path.display())
}
Self::Invalid { path, diagnostic } => {
write!(formatter, "{}: {diagnostic}", path.display())
}
Self::InvalidMode => formatter.write_str("mode must not contain path separators"),
}
}
}
impl std::error::Error for LoadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
pub fn load_cascade<P: AsRef<Path>>(
directory: P,
mode: &str,
) -> Result<LoadedEnvironment, LoadError> {
load_cascade_with(directory, mode, Dialect::PORTABLE)
}
pub fn load_cascade_with<P: AsRef<Path>>(
directory: P,
mode: &str,
dialect: Dialect,
) -> Result<LoadedEnvironment, LoadError> {
if !valid_mode(mode) {
return Err(LoadError::InvalidMode);
}
let directory = directory.as_ref();
let mut paths = vec![directory.join(".env"), directory.join(".env.local")];
if !mode.is_empty() {
paths.push(directory.join(format!(".env.{mode}")));
paths.push(directory.join(format!(".env.{mode}.local")));
}
load_paths(paths, dialect, true)
}
pub fn load_files<I, P>(paths: I, dialect: Dialect) -> Result<LoadedEnvironment, LoadError>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
load_paths(paths, dialect, false)
}
fn valid_mode(mode: &str) -> bool {
!mode.contains(['/', '\\', '\0']) && mode != "." && mode != ".."
}
fn load_paths<I, P>(
paths: I,
dialect: Dialect,
skip_missing: bool,
) -> Result<LoadedEnvironment, LoadError>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let mut loaded = LoadedEnvironment {
values: BTreeMap::new(),
origins: BTreeMap::new(),
raw_entries: BTreeMap::new(),
dialect,
entries: Vec::new(),
overrides: Vec::new(),
repeated: Vec::new(),
skipped: Vec::new(),
};
for path in paths {
let path = path.as_ref();
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(error) if skip_missing && error.kind() == io::ErrorKind::NotFound => continue,
Err(source) => {
return Err(LoadError::Io {
path: path.to_path_buf(),
source,
});
}
};
let text = String::from_utf8(bytes).map_err(|_| LoadError::InvalidUtf8 {
path: path.to_path_buf(),
})?;
let (document, diagnostics) = parse_with(&text, dialect);
if let Some(diagnostic) = diagnostics
.into_iter()
.find(|diagnostic| diagnostic.severity() == Severity::Error)
{
return Err(LoadError::Invalid {
path: path.to_path_buf(),
diagnostic,
});
}
merge_document(&mut loaded, path, &document);
}
Ok(loaded)
}
fn merge_document(loaded: &mut LoadedEnvironment, path: &Path, document: &crate::Document<'_>) {
let mut seen = BTreeSet::new();
for entry in document.entries() {
let key = entry.key().to_string();
if key.is_empty() {
loaded.skipped.push(LoadSkipped {
key: None,
origin: origin(path, entry),
reason: SkipReason::EmptyKey,
});
continue;
}
if !seen.insert(key.clone()) {
continue;
}
let occurrences: Vec<_> = document.get_all(&key).collect();
if occurrences.len() > 1 {
loaded.repeated.push(LoadRepeated {
key: key.clone(),
origins: occurrences
.iter()
.map(|entry| origin(path, *entry))
.collect(),
});
}
let Some(effective) = document.get_entry(&key) else {
loaded.skipped.push(LoadSkipped {
key: Some(key),
origin: origin(path, entry),
reason: SkipReason::NonParticipating,
});
continue;
};
if !effective.participates_in_lookup() {
loaded.skipped.push(LoadSkipped {
key: Some(key),
origin: origin(path, effective),
reason: SkipReason::NonParticipating,
});
continue;
}
let value = if let Some(value) = effective.value() {
value.to_string()
} else if document.dialect().bare_keys == crate::BareKeys::Empty {
String::new()
} else {
loaded.skipped.push(LoadSkipped {
key: Some(key),
origin: origin(path, effective),
reason: SkipReason::Bare,
});
continue;
};
let entry_origin = origin(path, effective);
let order = loaded.entries.len();
let raw_key = document.source()[effective.tokens().key.clone()].to_string();
let raw_value = effective.raw_value().unwrap_or_default().to_string();
loaded
.raw_entries
.insert(key.clone(), (order, raw_key, raw_value));
loaded.entries.push(LoadedEntry {
key: key.clone(),
value: value.clone(),
origin: entry_origin.clone(),
});
if let Some(previous) = loaded.origins.insert(key.clone(), entry_origin.clone()) {
loaded.overrides.push(LoadOverride {
key: key.clone(),
previous,
replacement: entry_origin,
});
}
loaded.values.insert(key, value);
}
}
fn origin(path: &Path, entry: crate::Entry<'_>) -> LoadOrigin {
LoadOrigin {
path: path.to_path_buf(),
span: entry.value_span().unwrap_or_else(|| entry.key_span()),
line: entry.line(),
}
}