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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@
- All templates now use `finalAttrs` pattern instead of `rec` for better override composition

- Additions:
- Modern `finalAttrs` pattern is now default for all package templates (stdenv, Python, Rust, Go, npm, pnpm)
- Modern `finalAttrs` pattern is now default for all package templates (stdenv, Python, Rust, Go, npm, pnpm, dotnet)
- Self-references now use `finalAttrs.pname` and `finalAttrs.version`
- PyPI fetcher uses `inherit (finalAttrs) pname version;` syntax
- Added `npm` template for Node.js packages using buildNpmPackage
- Added `pnpm` template for pnpm-based packages using fetchPnpmDeps with stdenv.mkDerivation
- Added `dotnet` template for .NET packages using buildDotnetModule
- Added `ruby` template for Ruby applications using bundlerApp
- Dependency hash prefetching now supports npm and pnpm templates (requires package-lock.json/pnpm-lock.yaml in repository)
- Dependency inference for ruby template from Gemfile.lock (maps common gems like nokogiri, pg, mysql2 to their nixpkgs dependencies)
- Auto-detection now recognizes npm and pnpm projects (via pnpm-lock.yaml, package-lock.json, or package.json)
- Auto-detection now recognizes .NET projects (via *.csproj, *.fsproj, or *.sln files)
- Auto-detection now recognizes Ruby projects (via Gemfile.lock or Gemfile)
- Project file inference for dotnet template when using --from-url (automatically detects .csproj, .fsproj, or .sln)

## v0.4.1

Expand Down
23 changes: 21 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ use std::io::IsTerminal;
use crate::file_path::nix_file_paths;
use crate::interactive::InteractiveData;
use crate::go_deps::infer_go_dependencies;
use crate::ruby_deps;
use crate::rust_deps::infer_rust_dependencies;
use crate::types::{ExpressionInfo, Fetcher, Template, UserConfig, FAKE_SRI_HASH};
use crate::url::{prefetch_dependency_hash, read_meta_from_url};
use crate::url::{infer_dotnet_project_file, prefetch_dependency_hash, read_meta_from_url};

// clap will validate inputs, only use on functions with possible_values defined
pub fn arg_to_type<T>(arg: Option<&str>) -> T
Expand Down Expand Up @@ -282,6 +283,7 @@ pub fn validate_and_serialize_matches(
vendor_hash: FAKE_SRI_HASH.to_owned(),
npm_deps_hash: FAKE_SRI_HASH.to_owned(),
pnpm_deps_hash: FAKE_SRI_HASH.to_owned(),
project_file: "CHANGE".to_owned(),
domain: "CHANGE".to_owned(),
build_inputs: Vec::new(),
native_build_inputs: Vec::new(),
Expand Down Expand Up @@ -358,7 +360,7 @@ pub fn validate_and_serialize_matches(
}
}

// Inference is on by default for the rust and go templates whenever we
// Inference is on by default for the rust, go, and ruby templates whenever we
// have a real source to inspect. Users can disable via `--skip-infer-deps`.
let infer_enabled = matches.is_present("from-url")
&& !matches.is_present("skip-infer-deps");
Expand All @@ -376,6 +378,14 @@ pub fn validate_and_serialize_matches(
info.native_build_inputs = native;
}
}
Template::ruby => {
ruby_deps::infer_dependencies(&mut info);
}
Template::dotnet => {
if let Some(project_file) = infer_dotnet_project_file(&info) {
info.project_file = project_file;
}
}
_ => {}
}
}
Expand Down Expand Up @@ -444,6 +454,7 @@ pub fn build_expression_info_from_interactive(
vendor_hash: FAKE_SRI_HASH.to_owned(),
npm_deps_hash: FAKE_SRI_HASH.to_owned(),
pnpm_deps_hash: FAKE_SRI_HASH.to_owned(),
project_file: "CHANGE".to_owned(),
domain: "CHANGE".to_owned(),
build_inputs: Vec::new(),
native_build_inputs: Vec::new(),
Expand Down Expand Up @@ -479,6 +490,14 @@ pub fn build_expression_info_from_interactive(
info.native_build_inputs = native;
}
}
Template::ruby => {
ruby_deps::infer_dependencies(&mut info);
}
Template::dotnet => {
if let Some(project_file) = infer_dotnet_project_file(&info) {
info.project_file = project_file;
}
}
_ => {}
}
}
Expand Down
111 changes: 111 additions & 0 deletions src/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const INDICATORS: &[(&str, Template, &str)] = &[
("pnpm-lock.yaml", Template::pnpm, "pnpm-lock.yaml"),
("package-lock.json", Template::npm, "package-lock.json"),
// Note: package.json is handled separately as a fallback (see below)
("Gemfile.lock", Template::ruby, "Gemfile.lock"),
("Gemfile", Template::ruby, "Gemfile"),
("meson.build", Template::stdenv, "meson.build"),
("CMakeLists.txt", Template::stdenv, "CMakeLists.txt"),
("configure", Template::stdenv, "configure"),
Expand Down Expand Up @@ -88,9 +90,44 @@ pub fn detect_template_candidates_from_path(source_path: &Path) -> Vec<Candidate
});
}

// .NET detection: scan for .csproj, .fsproj, or .sln files
// These files have dynamic names (e.g., MyProject.csproj), so we need to scan the directory.
let has_dotnet = candidates
.iter()
.any(|c| c.template == Template::dotnet);

if !has_dotnet {
if let Some(reason) = find_dotnet_project_file(source_path) {
candidates.push(Candidate {
template: Template::dotnet,
reason,
});
}
}

candidates
}

/// Scan a directory for .NET project files (.csproj, .fsproj, .sln).
/// Returns the reason string (file type found) or None if no project files exist.
fn find_dotnet_project_file(source_path: &Path) -> Option<&'static str> {
use std::fs;

if let Ok(entries) = fs::read_dir(source_path) {
for entry in entries.flatten() {
if let Some(ext) = entry.path().extension().and_then(|s| s.to_str()) {
match ext {
"csproj" => return Some("*.csproj"),
"fsproj" => return Some("*.fsproj"),
"sln" => return Some("*.sln"),
_ => {}
}
}
}
}
None
}

/// Detect template candidates by materialising a remote source tree.
///
/// If the fetcher is PyPI, short-circuits without materialising (we already
Expand Down Expand Up @@ -321,4 +358,78 @@ mod tests {
assert_eq!(candidates[0].template, Template::rust);
assert_eq!(candidates[1].template, Template::npm);
}

#[test]
fn detect_dotnet_from_csproj() {
let dir = make_source_dir(&["MyProject.csproj", "Program.cs"]);
let candidates = detect_template_candidates_from_path(dir.path());
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].template, Template::dotnet);
assert_eq!(candidates[0].reason, "*.csproj");
}

#[test]
fn detect_dotnet_from_fsproj() {
let dir = make_source_dir(&["MyProject.fsproj", "Program.fs"]);
let candidates = detect_template_candidates_from_path(dir.path());
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].template, Template::dotnet);
assert_eq!(candidates[0].reason, "*.fsproj");
}

#[test]
fn detect_dotnet_from_sln() {
let dir = make_source_dir(&["MySolution.sln", "README.md"]);
let candidates = detect_template_candidates_from_path(dir.path());
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].template, Template::dotnet);
assert_eq!(candidates[0].reason, "*.sln");
}

#[test]
fn detect_multiple_with_dotnet() {
let dir = make_source_dir(&["Cargo.toml", "MyProject.csproj"]);
let candidates = detect_template_candidates_from_path(dir.path());
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].template, Template::rust);
assert_eq!(candidates[1].template, Template::dotnet);
}

#[test]
fn detect_ruby_from_gemfile_lock() {
let dir = make_source_dir(&["Gemfile", "Gemfile.lock", "lib/app.rb"]);
let candidates = detect_template_candidates_from_path(dir.path());
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].template, Template::ruby);
assert_eq!(candidates[0].reason, "Gemfile.lock");
}

#[test]
fn detect_ruby_from_gemfile_fallback() {
let dir = make_source_dir(&["Gemfile", "lib/app.rb"]);
let candidates = detect_template_candidates_from_path(dir.path());
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].template, Template::ruby);
assert_eq!(candidates[0].reason, "Gemfile");
}

#[test]
fn detect_multiple_with_ruby() {
let dir = make_source_dir(&["Cargo.toml", "Gemfile"]);
let candidates = detect_template_candidates_from_path(dir.path());
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].template, Template::rust);
assert_eq!(candidates[1].template, Template::ruby);
}

#[test]
fn deduplicate_ruby_indicators() {
// When both Gemfile.lock and Gemfile exist, only Gemfile.lock should be kept
let dir = make_source_dir(&["Gemfile", "Gemfile.lock"]);
let candidates = detect_template_candidates_from_path(dir.path());
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].template, Template::ruby);
// Gemfile.lock has higher priority than Gemfile
assert_eq!(candidates[0].reason, "Gemfile.lock");
}
}
53 changes: 53 additions & 0 deletions src/expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ fn derivation_helper(info: &ExpressionInfo) -> (String, String) {
Template::rust => ("rustPlatform", "rustPlatform.buildRustPackage", None),
Template::npm => ("buildNpmPackage", "buildNpmPackage", Some("buildNpmPackage")),
Template::pnpm => ("stdenv", "stdenv.mkDerivation", Some("stdenvMkDerivation")),
Template::dotnet => ("buildDotnetModule", "buildDotnetModule", Some("buildDotnetModule")),
Template::ruby => ("bundlerApp", "bundlerApp", Some("bundlerApp")),
Template::test => ("", "", None), // Tests aren't a normal expression
Template::module => ("", "", None), // Modules aren't a normal expression
};
Expand Down Expand Up @@ -159,6 +161,27 @@ fn build_inputs(info: &ExpressionInfo) -> String {
hash = \"@pnpm_deps_hash@\";
};".to_owned()
}
Template::dotnet => {
" projectFile = \"@project_file@\";\n nugetDeps = ./deps.json; # Run `nix-build -A package-name.passthru.fetch-deps` to generate".to_owned()
}
Template::ruby => {
// Conditionally render build inputs only when inferred
let native = if info.native_build_inputs.is_empty() {
String::new()
} else {
"\n nativeBuildInputs = [@native_build_inputs@ ];".to_owned()
};
let build = if info.build_inputs.is_empty() {
String::new()
} else {
"\n buildInputs = [@build_inputs@ ];".to_owned()
};
format!(
" gemdir = ./.;\n exes = [ \"@pname@\" ]; # To build this package, you need Gemfile, Gemfile.lock, and gemset.nix in this directory{native}{build}\n",
native = native,
build = build,
)
}
// stdenv / stdenvNoCC: render `nativeBuildInputs` only when
// populated (via --native-build-inputs); always render
// `buildInputs` to preserve the existing template's ergonomic
Expand Down Expand Up @@ -262,6 +285,34 @@ mkShell rec {
}
"
.to_string(),
Template::ruby => {
let (_, dh_block) = derivation_helper(info);
let meta_content = if info.include_meta { meta() } else { "" };

let mut inputs = vec![String::from("lib"), String::from("bundlerApp")];

// Add inferred system dependencies to function header
inputs.extend(info.native_build_inputs.iter().map(|s| s.to_owned()));
inputs.extend(info.build_inputs.iter().map(|s| s.to_owned()));

let input_list = inputs.join("\n, ");
let header = format!("{{ {input_list}\n}}:", input_list = input_list);

info.format(&format!(
"{header}

{dh_helper} {{
pname = \"{pname}\";
{build_inputs}{meta}
}}
",
header = header,
dh_helper = dh_block,
pname = &info.pname,
build_inputs = build_inputs(info),
meta = meta_content,
))
}
_ => {
// Generate nix expression
let (dh_input, dh_block) = derivation_helper(info);
Expand Down Expand Up @@ -350,6 +401,7 @@ mod tests {
vendor_hash: "sha256-vendor".to_owned(),
npm_deps_hash: "sha256-npm".to_owned(),
pnpm_deps_hash: "sha256-pnpm".to_owned(),
project_file: "Project.csproj".to_owned(),
domain: "".to_owned(),
build_inputs: Vec::new(),
native_build_inputs: Vec::new(),
Expand Down Expand Up @@ -391,6 +443,7 @@ mod tests {
vendor_hash: "".to_owned(),
npm_deps_hash: "".to_owned(),
pnpm_deps_hash: "".to_owned(),
project_file: "".to_owned(),
domain: "".to_owned(),
build_inputs: Vec::new(),
native_build_inputs: Vec::new(),
Expand Down
2 changes: 2 additions & 0 deletions src/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,8 @@ pub fn prompt_template_type(default: Option<Template>) -> Result<Template> {
("go", "Go package with buildGoModule"),
("npm", "Node.js package with buildNpmPackage"),
("pnpm", "Node.js package with pnpm (stdenv + fetchPnpmDeps)"),
("dotnet", ".NET package with buildDotnetModule"),
("ruby", "Ruby application with bundlerApp"),
("mkshell", "Development shell.nix"),
("module", "NixOS module"),
("test", "NixOS test"),
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod expression;
mod file_path;
mod go_deps;
mod interactive;
mod ruby_deps;
mod rust_deps;
mod source;
mod types;
Expand Down
Loading
Loading