diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c098b..52f4197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/cli.rs b/src/cli.rs index e414934..cf617d4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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(arg: Option<&str>) -> T @@ -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(), @@ -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"); @@ -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; + } + } _ => {} } } @@ -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(), @@ -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; + } + } _ => {} } } diff --git a/src/detect.rs b/src/detect.rs index 48a5d86..c5e07b5 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -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"), @@ -88,9 +90,44 @@ pub fn detect_template_candidates_from_path(source_path: &Path) -> Vec 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 @@ -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"); + } } diff --git a/src/expression.rs b/src/expression.rs index 0916f81..c19d4f7 100644 --- a/src/expression.rs +++ b/src/expression.rs @@ -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 }; @@ -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 @@ -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); @@ -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(), @@ -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(), diff --git a/src/interactive.rs b/src/interactive.rs index 13c0c42..9a2b938 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -357,6 +357,8 @@ pub fn prompt_template_type(default: Option