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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,16 +219,22 @@ disinherit
load
load flake
load flake devShells.default
# a directed flake elsewhere as `path[#output]`. relative paths resolve against
# this .cade's dir; `..`, absolute paths, and a leading `~/` are all allowed
load flake ../svc
load flake ./sub#dev

# load from shell.nix
# load from shell.nix (path resolves against this .cade's dir; ../, abs, ~/ ok)
load shell
load shell custom-shell.nix
load shell ../tools/shell.nix

# load from .env file
# load from .env file (path resolves against this .cade's dir)
load env
load env .env.development
load env ../shared/.env

# load a direnv .envrc (declarative subset only)
# load a direnv .envrc (declarative subset only; path resolves against this dir)
load envrc
load envrc .envrc.local

Expand Down
169 changes: 102 additions & 67 deletions src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1635,6 +1635,91 @@ impl CadeLayer {
}
}

/// what to actually load, with paths already resolved
enum LoadRun {
Flake(crate::nix_dev_env::FlakeTarget),
Shell(PathBuf),
Env(PathBuf),
Envrc(PathBuf),
}

/// single source of truth for a `Load` directive: resolve once, then both the
/// load path and the watch path read the same target and file set
struct ResolvedLoad {
run: LoadRun,
/// profile/gc-root key for this resolved target
spec: String,
/// files whose change reloads this layer
watch: Vec<PathBuf>,
}

impl Loadable {
/// effective single-file arg for a non-flake directive, applying the
/// empty-arg default. the one place these defaults live. flake returns
/// `None` (its target is a dir of two files, not a single path)
fn file_arg(&self) -> Option<&str> {
match self {
Loadable::Shell(f) => Some(if f.is_empty() { "./shell.nix" } else { f }),
Loadable::Env(f) => Some(if f.is_empty() { ".env" } else { f }),
Loadable::Envrc(f) => Some(crate::envrc::envrc_arg(f)),
Loadable::Default | Loadable::Flake(_) => None,
}
}

/// resolve this directive against `layer_dir` into one target that owns both
/// the canonical load path and the watch identity. paths resolve canonically
/// when they exist and lexically when not, so a not-yet-created (possibly
/// directed) target still watches its real files; the loaders surface a
/// genuinely missing target as an error at load time. spec and watch both
/// key off the resolved path, so the same physical file spelled differently
/// (relative, `..`, or a symlink) shares one profile/gc-root identity
fn resolve(&self, layer_dir: &Path) -> ResolvedLoad {
use crate::path_resolve::resolve_for_watch;
match self {
Loadable::Default | Loadable::Flake(_) => {
let arg = match self {
Loadable::Flake(a) => Some(a.as_str()),
_ => None,
};
// resolve_flake_target resolves the (possibly directed) dir for
// watch, deferring a missing dir to `nix develop` at load time
let target = crate::nix_dev_env::resolve_flake_target(layer_dir, arg);
let watch = vec![target.cwd.join("flake.nix"), target.cwd.join("flake.lock")];
ResolvedLoad {
spec: target.spec.clone(),
watch,
run: LoadRun::Flake(target),
}
}
Loadable::Shell(_) => {
let file = resolve_for_watch(layer_dir, self.file_arg().unwrap());
ResolvedLoad {
spec: format!("shell:{}", file.display()),
watch: vec![file.clone()],
run: LoadRun::Shell(file),
}
}
Loadable::Env(_) => {
let file = resolve_for_watch(layer_dir, self.file_arg().unwrap());
ResolvedLoad {
spec: format!("env:{}", file.display()),
watch: vec![file.clone()],
run: LoadRun::Env(file),
}
}
Loadable::Envrc(_) => {
let path = resolve_for_watch(layer_dir, self.file_arg().unwrap());
let watch = crate::envrc::envrc_watch_files(&path);
ResolvedLoad {
spec: format!("envrc:{}", path.display()),
watch,
run: LoadRun::Envrc(path),
}
}
}
}
}

fn load_single_layer(
layer_count: usize,
path: &Path,
Expand All @@ -1644,7 +1729,6 @@ fn load_single_layer(
) -> Result<CadeLayer> {
use crate::loaders::*;
use Keyword::*;
use Loadable::*;

let mut layer = CadeLayer::new(layer_count, path);
for (action_index, kw) in keywords.iter().enumerate() {
Expand All @@ -1653,53 +1737,21 @@ fn load_single_layer(
Call(argv) => call(path, argv.clone())
.context("calling process")
.map(CadeAction::Environ),
Load(loadable) => match loadable {
Default => {
let profile = session.and_then(|session| {
cade.nix_profile_path(session, layer_count, action_index, path, "flake")
});
load_flake(path, None, profile).context("loading flake")
}
Flake(output) => {
let profile = session.and_then(|session| {
cade.nix_profile_path(
session,
layer_count,
action_index,
path,
&format!("flake:{output}"),
)
});
load_flake(path, Some(output.clone()), profile).context("loading flake")
}
Shell(filename) => {
let profile = session.and_then(|session| {
cade.nix_profile_path(
session,
layer_count,
action_index,
path,
&format!("shell:{filename}"),
)
});
load_shell(path, filename.clone(), profile).context("loading shell")
}
Env(filename) => load_env(path, filename.clone()).context("loading env file"),
Envrc(filename) => {
let profile_dir = session.and_then(|session| {
cade.nix_profile_path(
session,
layer_count,
action_index,
path,
&format!("envrc:{filename}"),
)
});
crate::envrc::load_envrc(path, filename.clone(), profile_dir)
.context("loading .envrc")
Load(loadable) => {
let resolved = loadable.resolve(path);
let profile = session.and_then(|session| {
cade.nix_profile_path(session, layer_count, action_index, path, &resolved.spec)
});
match resolved.run {
LoadRun::Flake(target) => load_flake(&target, profile).context("loading flake"),
LoadRun::Shell(file) => load_shell(&file, profile).context("loading shell"),
LoadRun::Env(file) => load_env(&file).context("loading env file"),
LoadRun::Envrc(p) => {
crate::envrc::load_envrc(&p, profile).context("loading .envrc")
}
}
.map(CadeAction::Environ)
}
.map(CadeAction::Environ),
Hook(hook) => Ok(CadeAction::Hook(hook.clone())),
Clear(vars) => Ok(CadeAction::Clear(vars.clone())),
Concat(vars) => Ok(CadeAction::Concat(vars.clone())),
Expand All @@ -1717,27 +1769,10 @@ fn watched_files_for_keywords(dir: &Path, keywords: &[Keyword]) -> Vec<PathBuf>
let mut files = vec![dir.join(".cade")];
for kw in keywords {
match kw {
Keyword::Load(loadable) => match loadable {
Loadable::Default | Loadable::Flake(_) => {
files.push(dir.join("flake.nix"));
files.push(dir.join("flake.lock"));
}
Loadable::Shell(f) => {
let name = if f.is_empty() {
"shell.nix"
} else {
f.as_str()
};
files.push(dir.join(name));
}
Loadable::Env(f) => {
let name = if f.is_empty() { ".env" } else { f.as_str() };
files.push(dir.join(name));
}
Loadable::Envrc(f) => {
files.extend(crate::envrc::envrc_watch_files(dir, f.clone()));
}
},
// same resolver the load path uses, so we never watch a different
// file than we load; the resolver tracks a not-yet-created target's
// real files lexically, so creating it trips the watcher
Keyword::Load(loadable) => files.extend(loadable.resolve(dir).watch),
// explicit user-declared dependencies
Keyword::Watch(ws) => files.extend(ws.iter().map(|w| dir.join(w))),
_ => {}
Expand Down
67 changes: 43 additions & 24 deletions src/envrc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
//! `export`/`PATH_add`) and warns about any line it can't faithfully
//! reproduce. This should cover most cases of .envrc.

use crate::loaders::{load_env, load_flake, load_shell};
use crate::loaders::{load_env, load_shell};
use crate::nix_dev_env::{FlakeTarget, load_flake};
use crate::{
types::EnvSet,
verbosity::{self, Verbosity},
Expand Down Expand Up @@ -113,18 +114,20 @@ fn merge(out: &mut EnvSet, other: EnvSet) {
out.nix_store_paths.extend(other.nix_store_paths);
}

fn envrc_path(dir: &Path, filename: &str) -> PathBuf {
dir.join(if filename.is_empty() {
pub(crate) fn envrc_arg(filename: &str) -> &str {
if filename.is_empty() {
".envrc"
} else {
filename
})
}
}

/// Compose an .envrc's recognized directives into a single EnvSet
pub fn load_envrc(dir: &Path, filename: String, profile_dir: Option<PathBuf>) -> Result<EnvSet> {
let path = envrc_path(dir, &filename);
let contents = std::fs::read_to_string(&path)
/// Compose an .envrc's recognized directives into a single EnvSet. `path` is the
/// already-resolved .envrc; its directives resolve against its own dir
pub fn load_envrc(path: &Path, profile_dir: Option<PathBuf>) -> Result<EnvSet> {
// directives inside resolve against the .envrc's own dir (direnv semantics)
let dir = path.parent().unwrap_or(path);
let contents = std::fs::read_to_string(path)
.with_context(|| format!("reading .envrc at {}", path.display()))?;

let mut out = EnvSet::new();
Expand All @@ -136,27 +139,26 @@ pub fn load_envrc(dir: &Path, filename: String, profile_dir: Option<PathBuf>) ->
let profile = profile_dir
.as_ref()
.map(|base| base.join(format!("{idx}-flake")));
merge(
&mut out,
load_flake(dir, output, profile).context("use flake")?,
)
// build the bare-output installable directly: a parsed `.#dev`
// must not be re-encoded into a `.#dev` string and fed back
// through the path classifier, which would misread it as a
// directed path and orphan previously-rooted profiles
let target = FlakeTarget::bare_output(dir, output.as_deref());
merge(&mut out, load_flake(&target, profile).context("use flake")?)
}
Directive::UseNix(file) => {
let profile = profile_dir
.as_ref()
.map(|base| base.join(format!("{idx}-nix")));
merge(&mut out, load_shell(dir, file, profile).context("use nix")?)
let shell = dir.join(if file.is_empty() { "shell.nix" } else { &file });
merge(&mut out, load_shell(&shell, profile).context("use nix")?)
}
Directive::Dotenv { file, if_exists } => {
let p = if file.is_empty() {
dir.join(".env")
} else {
dir.join(&file)
};
let p = dir.join(if file.is_empty() { ".env" } else { &file });
if if_exists && !p.exists() {
continue;
}
merge(&mut out, load_env(dir, file).context("dotenv")?);
merge(&mut out, load_env(&p).context("dotenv")?);
}
Directive::Export(key, value) => {
let parts: Vec<String> = value.split(':').map(str::to_string).collect();
Expand Down Expand Up @@ -197,11 +199,13 @@ pub fn load_envrc(dir: &Path, filename: String, profile_dir: Option<PathBuf>) ->
Ok(out)
}

/// Files an .envrc layer depends on
pub fn envrc_watch_files(dir: &Path, filename: String) -> Vec<PathBuf> {
let path = envrc_path(dir, &filename);
let mut files = vec![path.clone()];
let Ok(contents) = std::fs::read_to_string(&path) else {
/// Files an .envrc layer depends on. `path` is the already-resolved .envrc, the
/// same canonical path the load path keys on, so watch and load never diverge
pub fn envrc_watch_files(path: &Path) -> Vec<PathBuf> {
// directives inside resolve against the .envrc's own dir (direnv semantics)
let dir = path.parent().unwrap_or(path);
let mut files = vec![path.to_path_buf()];
let Ok(contents) = std::fs::read_to_string(path) else {
return files;
};
for directive in parse(&contents) {
Expand Down Expand Up @@ -256,6 +260,21 @@ mod tests {
);
}

#[test]
fn use_flake_named_output_stays_bare_output() {
// regression: a parsed `.#dev` must build a bare-output installable, not
// be re-encoded and reclassified as a directed path (which would change
// the installable to a layer-dir path and orphan rooted profiles)
let Some(Directive::UseFlake(output)) = parse_line("use flake .#dev") else {
panic!("expected UseFlake");
};
let target =
crate::nix_dev_env::FlakeTarget::bare_output(Path::new("/layer"), output.as_deref());
assert_eq!(target.installable, ".#dev");
assert_eq!(target.spec, "flake:dev");
assert_eq!(target.cwd, Path::new("/layer"));
}

#[test]
fn comments_and_blanks_are_ignored() {
assert_eq!(parse_line(""), None);
Expand Down
10 changes: 2 additions & 8 deletions src/loaders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,14 +360,8 @@ pub(crate) fn run_checked(mut cmd: Command, what: &str) -> Result<Vec<u8>> {
Ok(out.stdout)
}

pub fn load_env(path: &Path, filename: String) -> Result<EnvSet> {
let mut p = path.to_path_buf();
if filename.is_empty() {
p.push(".env");
} else {
p.push(filename);
}
let mut file = std::fs::File::open(p)
pub fn load_env(path: &Path) -> Result<EnvSet> {
let mut file = std::fs::File::open(path)
.with_context(|| format!("opening env file at {}", path.display()))?;
let mut buf = String::new();
file.read_to_string(&mut buf).context("reading env file")?;
Expand Down
5 changes: 4 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod envrc;
mod envs;
mod loaders;
mod nix_dev_env;
mod path_resolve;
mod progress;
mod shells;
mod types;
Expand Down Expand Up @@ -130,7 +131,9 @@ fn try_main() -> Result<()> {

fn main() {
if let Err(e) = try_main() {
eprintln!("failed to {e}\n{}", e.root_cause());
// `{e:#}` prints the whole context chain, so which directive/path
// failed to resolve is not lost between top-level action and root cause
eprintln!("failed to {e:#}");
std::process::exit(1);
}
}
Loading
Loading