use anyhow::{Context, Result, anyhow};
use rnix::ast::{self, HasEntry};
use rowan::ast::AstNode;
use std::{ops::Range, path::Path};
pub(crate) fn replace_input_in_file(
root: &Path,
input_name: &str,
input_existing: &str,
input_new: &str,
) -> Result<()> {
let path = root.join("flake.nix");
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read original flake.nix from {path:?}"))?;
let updated = replace_input(&content, input_name, input_existing, input_new)?;
std::fs::write(&path, updated)
.with_context(|| format!("failed to write new flake.nix at {path:?}"))?;
Ok(())
}
pub(crate) fn replace_input(
flake_nix_content: &str,
input_name: &str,
input_existing: &str,
input_new: &str,
) -> Result<String> {
let tree = rnix::Root::parse(flake_nix_content).ok()?;
let root = tree.expr().context("root")?;
let ast::Expr::AttrSet(ref set) = root else {
return Err(anyhow!("root not attrset"));
};
let url_str = locate_url(set, &["inputs", input_name, "url"])?
.ok_or_else(|| anyhow!("input url not found"))?;
let url_parts = url_str.normalized_parts();
if url_parts.len() != 1 {
return Err(anyhow!("input url wasn't a simple string"));
}
let ast::InterpolPart::Literal(url) = &url_parts[0] else {
return Err(anyhow!("input url wasn't a simple string"));
};
if url != input_existing {
return Err(anyhow!(
"input url value {url} didn't match expected {input_existing}"
));
}
replace_input_value(
flake_nix_content,
url_str.syntax().text_range().into(),
input_new,
)
}
fn locate_url(set: &ast::AttrSet, path: &[&str]) -> Result<Option<ast::Str>> {
for entry in set.entries() {
let ast::Entry::AttrpathValue(attrpath_value) = entry else {
continue;
};
let attrs = attrpath_value
.attrpath()
.context("attrpath")?
.attrs()
.map(|a| a.to_string())
.collect::<Vec<_>>();
if attrs.len() > path.len() || attrs != path[..attrs.len()] {
continue;
}
let value = attrpath_value
.value()
.with_context(|| format!("{} empty?", attrs.join(".")))?;
let rest = &path[attrs.len()..];
if rest.is_empty() {
let ast::Expr::Str(url_str) = value else {
return Err(anyhow!("input url not string"));
};
return Ok(Some(url_str));
}
let ast::Expr::AttrSet(ref inner) = value else {
return Err(anyhow!("{} not attrset", attrs.join(".")));
};
if let Some(url_str) = locate_url(inner, rest)? {
return Ok(Some(url_str));
}
}
Ok(None)
}
fn replace_input_value(subject: &str, tr: Range<usize>, replacement: &str) -> Result<String> {
if subject.as_bytes()[tr.start] != b'"' {
return Err(anyhow!(
"replacement subject didn't start with double quote"
));
}
if subject.as_bytes()[tr.end - 1] != b'"' {
return Err(anyhow!("replacement subject didn't end with double quote"));
}
let mut subject = subject.to_string();
subject.drain((tr.start + 1)..(tr.end - 1));
subject.insert_str(tr.start + 1, replacement);
Ok(subject)
}
#[test]
fn does_the_replacement_ok() {
assert_eq!(
CHOG_FLAKE_NIX.replace(CHOG_NIXPKGS, CHOG_DELIGHT),
replace_input(CHOG_FLAKE_NIX, "nixpkgs", CHOG_NIXPKGS, CHOG_DELIGHT).unwrap()
);
}
#[cfg(test)]
const CHOG_NIXPKGS: &str = "github:NixOS/nixpkgs/cb82756ecc37fa623f8cf3e88854f9bf7f64af93";
#[cfg(test)]
const CHOG_DELIGHT: &str = "delighted:nossa";
#[cfg(test)]
const CHOG_FLAKE_NIX: &str = r#"
{
description = "chog";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/cb82756ecc37fa623f8cf3e88854f9bf7f64af93";
};
outputs =
{
self,
nixpkgs,
}:
let
systems = [
"aarch64-darwin"
"aarch64-linux"
"x86_64-darwin"
"x86_64-linux"
];
eachSystem = nixpkgs.lib.genAttrs systems;
partsForSystem = system: rec {
pkgs = nixpkgs.legacyPackages.${system};
erlang = pkgs.beam_minimal.interpreters.erlang_27;
beamPackages = pkgs.beam_minimal.packagesWith erlang;
elixir = beamPackages.elixir_1_18;
};
in
{
formatter = eachSystem (system: nixpkgs.legacyPackages.${system}.nixfmt-rfc-style);
packages = eachSystem (
system:
let
inherit (partsForSystem system)
pkgs
erlang
beamPackages
elixir
;
package = pkgs.callPackage ./nix/package.nix {
inherit
beamPackages
erlang
elixir
;
};
in
rec {
default = chog;
chog = package;
chog-docker = pkgs.callPackage ./nix/docker.nix {
chog-rev = self.rev or "dev";
inherit chog;
};
}
);
devShell = eachSystem (
system: import ./nix/shell.nix (partsForSystem system // { mixEnv = "dev"; })
);
};
}
"#;
#[cfg(test)]
mod input_forms {
use super::*;
const EXISTING: &str = "github:NixOS/nixpkgs/nixos-26.05";
const NEW: &str = "https://channels.nixos.org/nixos-26.05/nixexprs.tar.zst";
fn replaced(flake_nix: &str) -> String {
replace_input(flake_nix, "nixpkgs", EXISTING, NEW).unwrap()
}
#[test]
fn attrset_form() {
let flake_nix = r#"{
inputs = {
fenix.url = "github:nix-community/fenix";
nixpkgs = {
url = "github:NixOS/nixpkgs/nixos-26.05";
flake = true;
};
};
}
"#;
assert_eq!(flake_nix.replace(EXISTING, NEW), replaced(flake_nix));
}
#[test]
fn top_level_path_form() {
let flake_nix = r#"{
inputs.fenix.url = "github:nix-community/fenix";
inputs.fenix.inputs.nixpkgs.follows = "nixpkgs";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
}
"#;
assert_eq!(flake_nix.replace(EXISTING, NEW), replaced(flake_nix));
}
#[test]
fn top_level_attrset_form() {
let flake_nix = r#"{
inputs.nixpkgs = {
url = "github:NixOS/nixpkgs/nixos-26.05";
};
}
"#;
assert_eq!(flake_nix.replace(EXISTING, NEW), replaced(flake_nix));
}
#[test]
fn follows_only_input_is_not_a_url() {
let flake_nix = r#"{
inputs = {
nixpkgs.follows = "nixpkgs-stable";
};
}
"#;
assert_eq!(
"input url not found",
replace_input(flake_nix, "nixpkgs", EXISTING, NEW)
.unwrap_err()
.to_string()
);
}
#[test]
fn absent_input_is_reported() {
let flake_nix = r#"{
inputs.fenix.url = "github:nix-community/fenix";
}
"#;
assert_eq!(
"input url not found",
replace_input(flake_nix, "nixpkgs", EXISTING, NEW)
.unwrap_err()
.to_string()
);
}
}