diff --git a/.gitignore b/.gitignore index 1a4d232..7f63848 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ -.bundle/ -*.gem -tmp -result +/*.gem +/.bundle/ +/.yardoc/ +/coverage/ +/doc/ +/result +/tmp/ /vendor/ diff --git a/Gemfile b/Gemfile index d1a9777..c8228cb 100644 --- a/Gemfile +++ b/Gemfile @@ -3,12 +3,16 @@ source 'https://rubygems.org' gemspec -# Development dependencies -gem 'guard-rspec', '~> 4.7' -gem 'guard-rubocop', '~> 1.5' -gem 'pry-byebug', '~> 3.10' -gem 'rake', '~> 13.0' -gem 'rspec', '~> 3.12' -gem 'rubocop', '~> 1.45' -gem 'rubocop-rake', '~> 0.6' -gem 'rubocop-rspec', '~> 2.18' +group :development do + gem 'guard-rspec', '~> 4.7' + gem 'guard-rubocop', '~> 1.5' + gem 'pry-byebug', '~> 3.10' + gem 'rake', '~> 13.0' + gem 'rspec', '~> 3.12' + gem 'rubocop', '~> 1.45' + gem 'rubocop-rake', '~> 0.6' + gem 'rubocop-rspec', '~> 2.18' + gem 'simplecov', '~> 0.22' + gem 'webmock', '~> 3.18' + gem 'yard', '~> 0.9' +end diff --git a/Gemfile.lock b/Gemfile.lock index e69dda7..e6a3254 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,10 +8,15 @@ PATH GEM remote: https://rubygems.org/ specs: + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) byebug (11.1.3) coderay (1.1.3) + crack (0.4.5) + rexml diff-lcs (1.5.0) + docile (1.4.0) ffi (1.15.5) formatador (1.1.0) guard (2.18.0) @@ -31,6 +36,7 @@ GEM guard-rubocop (1.5.0) guard (~> 2.0) rubocop (< 2.0) + hashdiff (1.0.1) json (2.6.3) listen (3.8.0) rb-fsevent (~> 0.10, >= 0.10.3) @@ -50,6 +56,7 @@ GEM pry-byebug (3.10.1) byebug (~> 11.0) pry (>= 0.13, < 0.15) + public_suffix (5.0.1) rainbow (3.1.1) rake (13.0.6) rb-fsevent (0.11.2) @@ -91,8 +98,21 @@ GEM rubocop-capybara (~> 2.17) ruby-progressbar (1.12.0) shellany (0.0.1) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.12.3) + simplecov_json_formatter (0.1.4) thor (1.2.1) unicode-display_width (2.4.2) + webmock (3.18.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + webrick (1.7.0) + yard (0.9.28) + webrick (~> 1.7.0) zeitwerk (2.6.7) PLATFORMS @@ -108,6 +128,9 @@ DEPENDENCIES rubocop (~> 1.45) rubocop-rake (~> 0.6) rubocop-rspec (~> 2.18) + simplecov (~> 0.22) + webmock (~> 3.18) + yard (~> 0.9) BUNDLED WITH 2.4.6 diff --git a/README.md b/README.md index a8ffbad..278fd7c 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,140 @@ # About - Bundix makes it easy to package your [Bundler](http://bundler.io/)-enabled Ruby -applications with the [Nix](http://nixos.org/nix/) package manager. - -## Installation +applications with the [Nix](https://nixos.org/download.html) package manager. -Installing from this repo: +# Basic Usage - nix-env -iA bundix +**Note**: See [Getting Started](./guides/getting-started.md) for a more detailed +description of setting up a new ruby project. -Please note that in order to actually use this gem you must have Nix installed. +> Please note that in order to actually use this gem you must have Nix installed. +> +> I recommend first reading the [nixpkgs manual entry for +> Ruby](http://nixos.org/nixpkgs/manual/#sec-language-ruby) as this README might +> become outdated, it's a short read right now, so you won't regret it. -## Basic Usage +To use Bundix, all your project needs is a `Gemfile` describing your project's +ruby dependencies. If you already have a `Gemfile.lock`, Bundix will use it, but +it will generate one if you don't. -I recommend first reading the -[nixpkgs manual entry for Ruby](http://nixos.org/nixpkgs/manual/#sec-language-ruby) -as this README might become outdated, it's a short read right now, so you won't -regret it. +```sh +$ nix run github:sangster/bundix +$ nix run nixpkgs#git -- add gemset.nix +``` -1. Making a gemset.nix +## Adding Bundix to your `flake.nix` - Change to your project's directory and run this: +To integrate Bundix into your Nix package, you'll need to make 3 changes: - bundix -l +### 1. Import Bundix's overlay - This will generate a `gemset.nix` file that you then can use in your - `bundlerEnv` expression like this: +For example, if you have a `import nixpkgs` line in your flake, add a `overlays += [ ...];` attribute to it. For example: -2. Using `nix-shell` +```nix +{ + inputs.bundix.url = github:sangster/bundix; - To try your package in `nix-shell`, create a `default.nix` like this: + outputs = { bundix, ... }: + let + pkgs = import nixpkgs { + system = "x86_64-linux"; + overlays = [bundix.overlays.default]; + }; + in { ... }; +} +``` - ```nix - with (import {}); - let - gems = bundlerEnv { - name = "your-package"; - inherit ruby; - gemdir = ./.; - }; - in stdenv.mkDerivation { - name = "your-package"; - buildInputs = [gems ruby]; - } - ``` +### 2. Create the gem bundle with Bundix + +Now we use the `pkgs.bundixEnv` nix function to convert your project's +`gemset.nix` into a nix derivation that will be used as a runtime dependency for +your own ruby package. Here is an example usage: + +```nix +{ + gems = pkgs.bundixEnv { + name = "bundix-project-gems"; + ruby = pkgs.ruby; + gemdir = ./.; + platform = "x86_64-linux"; + }; +} +``` - and then simply run `nix-shell`. +`bundixEnv` accepts the same attribute arguments as +[bundlerEnv](https://github.com/NixOS/nixpkgs/blob/48e4e2a1/pkgs/development/ruby-modules/bundler-env/default.nix), +with the addition of two: + + - `platform`: Specifies the gem platform we want to build this package for. + - `system`: As an alternative to `platform`, you can provide your nix `system` + and `bundixEnv` will attempt to figure out the correct `platform` from that. + +### 3. Use the gem bundle in your app + +Finally, you need to integrate your new gem bundle into your package. An easy +method is to use its `wrappedRuby` package as the `ruby` used to execute your +ruby code. + +```nix +pkgs.stdenv.mkDerivation { + phases = "installPhase"; + installPhase = '' + mkdir -p $out/bin + cat << EOF > "$out/bin/my-app" + #!/bin/sh + exec ${gems.wrappedRuby}/bin/ruby ${./my-ruby-script.rb} + EOF + chmod +x "$out/bin/my-app" + ''; +} +``` -3. Proper packages +### Generate an example `flake.nix` - To make a package for nixpkgs, you can try something like this: +If your project doesn't have a +flake.nix+ yet, Bundix can make an example one +for you: - ```nix - { stdenv, bundlerEnv, ruby }: - let - gems = bundlerEnv { - name = "your-package"; - inherit ruby; - gemdir = ./.; - }; - in stdenv.mkDerivation { - name = "your-package"; - src = ./.; - buildInputs = [gems ruby]; - installPhase = '' - mkdir -p $out - cp -r $src $out - ''; - } - ``` +```sh +$ nix run github:sangster/bundix -- --init +``` -### Command-line Flags +## Command-line Flags ``` $ nix run github:sangster/bundix -- --help Usage: bundix [options] - -i, --init[=RUBY_DERIVATION] initialize a new flake.nix for 'nix develop' (won't overwrite old ones) - -t, --init-template=TEMPLATE the flake.nix template to use. may be 'default', 'flake-utils', or a filename (default: default) - -p, --init-project=NAME project name to use with --init (default: moo) - --gemset=PATH path to the gemset.nix (default: ./gemset.nix) - --lockfile=PATH path to the Gemfile.lock (default: ./Gemfile.lock) - --gemfile=PATH path to the Gemfile (default: ./Gemfile) - --skip-gemset do not generate gemset -q, --quiet only output errors - -l, --bundle-lock generate Gemfile.lock first - -u, --bundle-update[=GEMS] ignores the existing lockfile. Resolve then updates lockfile. Taking a list of gems or updating all gems if no list is given (implies --bundle-lock) - -c, --bundle-cache[=DIRECTORY] package .gem files into directory (default: ./vendor/bundle) - -v, --version show the version of bundix - --env show the environment in bundix -``` - -## How & Why - -I'd usually just tell you to read the code yourself, but the big picture is -that bundix tries to fetch a hash for each of your bundle dependencies and -store them all together in a format that Nix can understand and is then used by -`bundlerEnv`. -I wrote this new version of bundix because I became frustrated with the poor -performance of the old bundix, and wanted to save both time and bandwidth, as -well as learn more about Nix. +File options: + --gemfile=PATH path to the existing Gemfile (default: ./Gemfile) + --lockfile=PATH path to the Gemfile.lock (default: ./Gemfile.lock) -For each gem, it first tries to look for an existing gem in the bundler cache -(usually generated via `bundle package`), and if that fails it goes through -each remote and tries to fetch the gem from there. If the remote happens to be -[rubygems.org](http://rubygems.org/) we ask the API first for a hash of the -gem, and then ask the Nix store whether we have this version already. Only if -that also fails do we download the gem. +Output options: + --gemset=PATH destination path of the gemset.nix (default: ./gemset.nix) + -g, --groups=GROUPS bundler groups to include in the gemset.nix (default: all groups) + --bundler-env[=PLATFORM] export a nixpkgs#bundlerEnv compatiblegemset (default: ruby) + --skip-gemset do not generate gemset -As an added bonus I also implemented parsing the `gemset.nix` if it already -exists, and get hashes from there directly, that way updating an existing -`gemset.nix` only takes a few seconds. +Bundler options: + -l, --lock lock the gemfile gems into the lockfile + -u, --update[=GEMS] update the lockfile with new versions of the specified gems, or each one, if none given (implies --lock) + -a, --add-platforms=PLATFORMS add platforms to the lockfile (implies --lock) + -r, --remove-platforms=PLATFORMS remove platforms from the lockfile (implies --lock) + -p, --platforms=PLATFORMS replace all platforms in the lockfile (implies --lock) + -c, --bundle-cache[=DIR] package .gem files into directory (default: ./vendor/bundle) + --ignore-bundler-configs ignores Bundler config files -The output from bundix should be as stable as possible, to make auditing diffs -easier, that's why I also implemented a pretty printer for the `gemset.nix`. +flake.nix options: + -i, --init[=RUBY_DERIVATION] initialize a new flake.nix for 'nix develop' (won't overwrite old ones) + -t, --init-template=TEMPLATE the flake.nix template to use. may be 'default', 'flake-utils', or a filename (default: default) + -n, --project-name=NAME project name to use with --init (default: bundix) -I hope you enjoy using bundix as much as I do, and if you don't, let me know. +Environment options: + -v, --version show the version of bundix + --env show the environment in Bundix + --platform show the gem platform of this host +``` ## Development @@ -124,11 +143,12 @@ some utilities which may help you. Furthermore, running `nix develop` will start a new shell where `rake`, and other development dependencies are available. Some example `rake` commands (via `nix develop` in these examples): -``` sh -$ nix develop -c rake -T # List available rake commands -$ nix develop -c rake # Default rake command: all tests and linters -$ nix develop -c rake dev:console # Open a ruby REPL shell -$ nix develop -c rake dev:guard # Begin automated test-runner +```sh +$ nix develop -c rake -T # List available rake commands +$ nix develop -c rake # Default rake command: all tests and linters +$ nix develop -c rake dev:console # Open a ruby REPL shell +$ nix develop -c rake dev:guard # Begin automated test-runner +$ nix develop -c rake docs:generate # Generate ruby documentation ``` ### Building @@ -143,11 +163,19 @@ command-line arguments must be preceeded with `--`; for example: ## Closing words -For any questions or suggestions, please file an issue on Github or ask in -`#nixos` on [Freenode](http://freenode.net/). +For any questions or suggestions, please file an issue on Github. + +If you're curious about the rationale behind the different versions of Bundix, +see the [Motivations Guide](./guides/motivations.md). + +A huge shoutout to Michael 'manveru' Fellinger! As someone who writes ruby +professionally, without his work on Bundix 2, I never would have had the +opportunity to discover how great nix is. + +From Bundix 2: -Big thanks go out to -[Charles Strahan](http://www.cstrahan.com/) for his awesome work bringing Ruby to Nix, -[zimbatm](https://zimbatm.com/) for being a good rubber duck and tester, and -[Alexander Flatter](https://github.com/aflatter) for the original bundix. I -couldn't have done this without you guys. +> Big thanks go out to [Charles Strahan](http://www.cstrahan.com/) for his +> awesome work bringing Ruby to Nix, [zimbatm](https://zimbatm.com/) for being a +> good rubber duck and tester, and [Alexander +> Flatter](https://github.com/aflatter) for the original bundix. I couldn't have +> done this without you guys. diff --git a/Rakefile b/Rakefile index ddcb1a4..26cbed7 100644 --- a/Rakefile +++ b/Rakefile @@ -3,6 +3,7 @@ require 'rake/testtask' require 'rspec/core/rake_task' require 'rubocop/rake_task' +require 'yard' namespace :dev do desc 'Start a ruby REPL' @@ -18,6 +19,13 @@ namespace :dev do end end +namespace :docs do + YARD::Rake::YardocTask.new(:generate) + + desc 'Serve YARD Documentation with web server' + task(:serve) { YARD::CLI::Server.new.run } +end + namespace :test do RSpec::Core::RakeTask.new(:specs) end diff --git a/bin/bundix b/bin/bundix index 0be8400..af8c30c 100755 --- a/bin/bundix +++ b/bin/bundix @@ -1,6 +1,8 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require_relative '../lib/bundix' +require 'bundler' +Bundler.require(:default) +Bundler.require(:development) if ENV.key?('BUNDIX_DEVELOPMENT') Bundix::CommandLine.call diff --git a/bundix.gemspec b/bundix.gemspec index 6ed5033..31755b2 100644 --- a/bundix.gemspec +++ b/bundix.gemspec @@ -1,22 +1,19 @@ # frozen_string_literal: true -version = /VERSION\s*=\s*'([^']+)'/.match(File.read('lib/bundix/version.rb'))[1] +require_relative 'lib/bundix/version' Gem::Specification.new do |s| s.name = 'bundix' - s.version = version + s.version = Bundix::VERSION s.licenses = ['MIT'] - s.homepage = 'https://github.com/manveru/bundix' + s.homepage = 'https://github.com/sangster/bundix' s.summary = 'Creates Nix packages from Gemfiles.' s.description = 'Creates Nix packages from Gemfiles.' - s.authors = ["Michael 'manveru' Fellinger"] - s.files = Dir['bin/*'] + - Dir['lib/**/*.{rb,nix,erb}'] + - Dir['template/**/*.{rb,nix,erb}'] + s.authors = ["Michael 'manveru' Fellinger", 'Jon Sangster'] + s.files = Dir['bin/*'] + Dir['lib/**/*.rb'] + Dir['template/**/*.erb'] s.bindir = 'bin' s.executables = ['bundix'] s.required_ruby_version = '>= 2.7.0' - s.metadata['rubygems_mfa_required'] = 'true' s.add_dependency 'bundler', '~> 2.4' diff --git a/default.nix b/default.nix deleted file mode 100644 index 0272492..0000000 --- a/default.nix +++ /dev/null @@ -1,39 +0,0 @@ -{ - pkgs ? (import {}), - ruby ? pkgs.ruby_2_7, - bundler ? (pkgs.bundler.override { inherit ruby; }), - nix ? pkgs.nix, - nix-prefetch-git ? pkgs.nix-prefetch-git, -}: -pkgs.stdenv.mkDerivation rec { - version = "3.0.0-alpha"; - name = "bundix"; - src = ./.; - phases = "installPhase"; - installPhase = '' - mkdir -p $out - makeWrapper $src/bin/bundix $out/bin/bundix \ - --prefix PATH : "${nix.out}/bin" \ - --prefix PATH : "${nix-prefetch-git.out}/bin" \ - --prefix PATH : "${bundler.out}/bin" \ - --set GEM_PATH "${bundler}/${bundler.ruby.gemPath}" - ''; - - nativeBuildInputs = [ pkgs.makeWrapper ]; - buildInputs = [ ruby bundler ]; - - meta = { - inherit version; - description = "Creates Nix packages from Gemfiles"; - longDescription = '' - This is a tool that converts Gemfile.lock files to nix expressions. - - The output is then usable by the bundlerEnv derivation to list all the - dependencies of a ruby package. - ''; - homepage = "https://github.com/nix-community/bundix"; - license = "MIT"; - maintainers = with pkgs.lib.maintainers; [ manveru zimbatm ]; - platforms = pkgs.lib.platforms.all; - }; -} diff --git a/flake.nix b/flake.nix index 6babbb8..3231c1b 100644 --- a/flake.nix +++ b/flake.nix @@ -7,64 +7,53 @@ }; outputs = { self, nixpkgs, flake-utils }: + { + overlays.default = final: prev: + let + pname = "bundix"; + src = ./.; + lib = final.callPackage ./nix {}; + version = lib.extractBundixVersion ./lib/bundix/version.rb; + in { + bundix = final.callPackage ./nix/derivation.nix { + inherit pname src version; + runtimeInputs = with final; [ + git + nix + nix-prefetch-git + ]; + gems = with final; bundixEnv { + inherit pname ruby system; + name = "${pname}-${version}-bundler-env"; + groups = ["default"]; + gemdir = ./.; + }; + }; + bundixEnv = args: final.callPackage ./nix/bundixEnv.nix args; + }; + } // flake-utils.lib.eachDefaultSystem (system: let - name = "bundix"; - version = extract-bundix-version ./lib/bundix/version.rb; - pkgs = import nixpkgs { inherit system; }; - - gems = with pkgs; bundlerEnv { - inherit name ruby; - gemdir = ./.; - extraConfigPaths = [ - "${./.}/lib" # .gemspec file references `Bundix::Version` - "${./.}/${name}.gemspec" - ]; - }; - - extract-bundix-version = path: with builtins; - let - pattern = ".*VERSION[[:space:]]*=[[:space:]]['\"]([^'\"]+)['\"].*"; - captures = match pattern (readFile path); - version-list = if isNull captures || length captures == 0 - then [upstream-package.version] - else captures; - in elemAt version-list 0; - - upstream-package = import ./default.nix { - inherit pkgs; - inherit (gems) ruby; + pkgs = import nixpkgs { + inherit system; + overlays = [self.overlays.default]; }; - bundled-package = upstream-package.overrideAttrs (_old: { - inherit name version; - - # See https://nixos.wiki/wiki/Packaging/Ruby#Build_default.nix - installPhase = '' - mkdir -p $out/{bin,share/${name}} - cp -r $src/{bin,lib,template} $out/share/${name} - - bin=$out/bin/${name} - cat > $bin < Please note that in order to actually use this gem you must have Nix installed. +> +> Consider first reading the [nixpkgs manual entry for +> Ruby](http://nixos.org/nixpkgs/manual/#sec-language-ruby). Bundix makes much +> of this easier, but it's still handy to know what Nix is doing under the hood. + +## Creating a Ruby Project (optional) + +If you're writing a nix package for an existing ruby project, you can skip this +step. But if you just want to try out Bundix with an example project, all you +need to start is a `git` repo[^git] with a `Gemfile` that includes all your +[favourite gems](https://rubygems.org/): + +[^git]: If you prefer not to use `git`, Nix supports other version control + systems. + +```sh +$ nix run nixpkgs#git -- init bundix-project +Initialized empty Git repository in ~/bundix-project/.git/ +$ cd ./bundix-project/ +$ cat < ./Gemfile +source 'https://rubygems.org' +gem 'nokogiri', '1.14.2' +gem 'test-unit, '3.5.7, group: :development +RUBY +``` + + - [Nokogiri](https://nokogiri.org/) will allow your hypothetical project to + easily work with XML and HTML. + - [test-unit](https://test-unit.github.io/) provides a framework for writing + unit tests. Unlike Nokogiri, test-unit is a "development dependency." You + need it to help you write your application, but it won't necessarily be + needed by your app at runtime. For this reason, we've added it to your + Gemfile's `development` group. + +### Generating your project's nix files + +While typical ruby projects include many source files, Bundix only needs your +`Gemfile` to be present. + +```sh +$ nix run github:sangster/bundix -- --init +Fetching gem metadata from https://rubygems.org/....... +Resolving dependencies... +Writing lockfile to ~/bundix-project/Gemfile.lock +``` + +Bundix will create 3 files: + + - `Gemfile.lock` + - `gemset.nix` + - `flake.nix` + +### Gemfile.lock + +Since the example project didn't have a `Gemfile.lock`, Bundix will have created +one similar to this: + +``` +GEM + remote: https://rubygems.org/ + specs: + nokogiri (1.14.2-x86_64-linux) + racc (~> 1.4) + power_assert (2.0.3) + racc (1.6.2) + test-unit (3.5.7) + power_assert + +PLATFORMS + x86_64-linux + +DEPENDENCIES + nokogiri (= 1.14.2) + test-unit (= 3.5.7) + +BUNDLED WITH + 2.4.6 +``` + +Typically you won't ever edit, or even look at, this file, but here's a quick +summary of its major sections: + +#### GEM + + The `GEM` section summarises all the gems that need to be downloaded from +`rubygems.org` to build this project. Even though our `Gemfile`only listed 2 +gems, this section includes 4. `racc` was pulled in as a transitive dependency +of `nokogiri` and `power_assert` from `test-unit`. + +#### PLATFORMS + +The `PLATFORMS` section lists the [gem +platforms](https://guides.rubygems.org/what-is-a-gem/) your project intends to +support. By default, when creating a new `Gemfile.lock`, the platform of your +local machine is used.[^platform] + +[^platform]: I used a 64-bit linux machine in this example. Your `Gemfile.lock` + may have something different here. + +You probably noticed that the `nokogiri` entry under `GEMS` lists the version as +`1.14.2-x86_64-linux`, even though the `Gemfile` specified that version `1.14.2` +is needed. This is because `nokogiri` provides a special version of this gem +specifically for this `Gemfile.lock`'s platform[^nokogiri]. `Gemfile.lock` +*only* lists `x86_64-linux` as a supported platform, so it's acceptable to use a +`x86_64-linux`-only version of `nokogiri`. + +[^nokogiri]: Nokogiri provides versions for other platforms too. You can see a + list on their [RubyGems page](https://rubygems.org/gems/nokogiri/versions). + +If you're building a nix derivation that you want to run on all ruby platforms, +you can use the special, `ruby` platform. This platform indicates that your code +doesn't contain any platform-specific code and can hypothetically run on any +platform. + +You can add the `ruby` platform to your `Gemfile.lock` with the +`--add-platforms=` flag: + +```sh +$ nix run github:sangster/bundix -- --add-platforms=ruby +Fetching gem metadata from https://rubygems.org/....... +Resolving dependencies... +Writing lockfile to /tmp/bundix-project/Gemfile.lock +``` + +By examining the updated `Gemfile.lock`, you'll see that the `PLATFORMS` section +has a second entry, and a few more gems added to the `GEMS` section: + +``` +GEM + remote: https://rubygems.org/ + specs: + mini_portile2 (2.8.1) + nokogiri (1.14.2) + mini_portile2 (~> 2.8.0) + racc (~> 1.4) + nokogiri (1.14.2-x86_64-linux) + racc (~> 1.4) + power_assert (2.0.3) + racc (1.6.2) + test-unit (3.5.7) + power_assert + +PLATFORMS + ruby + x86_64-linux +``` + +There are now two `nokogiri` entries: one for the `x86_64-linux` platform and a +generic ruby one (with no platform suffix) for every other platform. Take note +that the pure-ruby implementation of `nokogiri` has an extra transitive +dependency: `mini_portile2`. + +#### DEPENDENCIES + +The `DEPENDENCIES` section reiterates the gems listed in your `Gemfile`. The gem +entries in the `GEM` section are meant to fulfill the requirements listed here. + +### gemset.nix + +Your project's `gemset.nix` is essentially the nix-version of `Gemfile.lock`. +Its purpose is to catalogue all your needed gems, along with the groups and +platforms they support. Unlike the `Gemfile.lock`, this file also includes the +[SHA-256 hash](https://en.wikipedia.org/wiki/SHA-2#Applications) of each gem. + +Recall that the purpose of the nix package manager is to provide reproducable +builds. To fulfill that guarantee, we need to record these hashes in advance, so +the built-gems can be verified at build time. Bundler itself isn't concerned +with reproducable builds and the `Gemfile`/`Gemfile.lock` aren't enough on their +own, so the `gemset.nix` file is necessary. + +The `gemset.nix` generated in your project should look like this[^platform2]: + +[^platform2]: Again, your file will certainly look different if your local + platform is something other than `x86_64-linux`. + +```nix +{ + dependencies = ["nokogiri" "test-unit"]; + platforms = { + ruby = { + mini_portile2 = { + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1af4yarhbbx62f7qsmgg5fynrik0s36wjy3difkawy536xg343mp"; + type = "gem"; + }; + version = "2.8.1"; + }; + nokogiri = { + dependencies = ["mini_portile2" "racc"]; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1djq4rp4m967mn6sxmiw75vz24gfp0w602xv22kk1x3cmi5afrf7"; + type = "gem"; + }; + version = "1.14.2"; + }; + power_assert = { + groups = ["development"]; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1y2c5mvkq7zc5vh4ijs1wc9hc0yn4mwsbrjch34jf11pcz116pnd"; + type = "gem"; + }; + version = "2.0.3"; + }; + racc = { + source = { + remotes = ["https://rubygems.org"]; + sha256 = "09jgz6r0f7v84a7jz9an85q8vvmp743dqcsdm3z9c8rqcqv6pljq"; + type = "gem"; + }; + version = "1.6.2"; + }; + test-unit = { + dependencies = ["power_assert"]; + groups = ["development"]; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1rdhpdi8mlk7jwv9pxz3mhchpd5q93jxzijqhw334w5yv1ajl5hf"; + type = "gem"; + }; + version = "3.5.7"; + }; + }; + x86_64-linux = { + nokogiri = { + dependencies = ["racc"]; + platforms = [{ engine = "ruby"; }]; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0xp427axb5h5rbdgmcviqdc6wk62q3qpbmw23x06xb6xyghhar5w"; + type = "gem"; + }; + version = "1.14.2-x86_64-linux"; + }; + }; + }; +} +``` + +Here's an overview of the sections in this `gemset.nix`: + +``` +dependencies +platforms + ruby + mini_portile2 + nokogiri + power_assert + racc + test-unit + x86_64-linux + nokogiri +``` + + - `dependencies` is the same as your `Gemset.lock` file's `DEPENDENCIES` + section. It lists all the top-level requirements you listed in your + `Gemfile`. + - `platforms` lists each of your gem dependencies, divided up by platform. At + build time, Bundix will choose the apropriate ones for the target system. + +The section for each of the listed gems have mostly the same attributes: + + - `dependencies` (optional): Transitive dependencies of that gem. if any + - `groups` (optional): What group, if any, the gem belongs to in the `Gemfile`. + - `platforms` (optional): Despite its name, this attribute describes the [ruby + engine](https://bundler.io/v1.12/man/gemfile.5.html#ENGINE-engine-) this gem + requires. This attribute will be absent if the gem is a generic `ruby` gem + that can run on any engine. + - `source`: Describes where nix can download the gem. See below for more + details. + - `version`: The exact version number of the gem. + +#### Gem sources + +Bundler allows you to add gems to your `Gemfile` from 3 kinds of sources, and +Bundix supports them all: + + - [RubyGems server](https://guides.rubygems.org/publishing). This will often be + `rubygems.org`, but it doesn't have to be. You can use GitHub's RubyGems + server or any other that is compliant with the + [RubyGems API](https://guides.rubygems.org/rubygems-org-api/). + - [git repository](https://bundler.io/guides/git.html) + - A directory on the local filesystem. Please note that if you reference a path + outside of your project's git repo, Nix may require that you use the + `--impure` flag[^impure]. + +[^impure]: For example, `nix build --impure` or `nix run --impure`. + +### flake.nix + +Because we ran Bundix with the `--init` flag, it created an example `flake.nix` +file for your project. Here's a truncated version that highlights the important +parts: + +```nix +{ + inputs.bundix.url = github:sangster/bundix; + + outputs = { self, nixpkgs, bundix }: + let + pname = "bundix-project"; + system = "x86_64-linux"; + version = "0.0.1"; + pkgs = import nixpkgs { + inherit system; + overlays = [bundix.overlays.default]; + }; + + gems = pkgs.bundixEnv { + inherit system; + name = "${pname}-${version}-gems"; + groups = ["default"]; + ruby = pkgs.ruby; + gemdir = ./.; + }; + in { + packages.${system}.default = pkgs.stdenv.mkDerivation { + inherit gems pname version; + ruby = gems.wrappedRuby; + phases = "installPhase"; + installPhase = '' + mkdir -p $out/bin + cat << EOF > "$out/bin/${pname}" + #!/bin/sh + exec $ruby/bin/ruby << RUBY + require 'bundler' + Bundler.setup(:default) + puts "Loaded gems:" + Gem.loaded_specs.each_key { |gem| puts " - #{gem}" } + RUBY + EOF + chmod +x "$out/bin/${pname}" + ''; + }; +} +``` + +#### Importing the Bundix overlay + +```nix +{ + pkgs = import nixpkgs { + inherit system; + overlays = [bundix.overlays.default]; + }; +} +``` + +The Bundix overlay gives your flake access to the `bundixEnv` function that will +build your project's rubygems. Alternatively, if you don't want want to use an +overlay, you can reference this function with +`bundix.packages.${system}.bundixEnv`. + +#### Building the gems + +Now we use the `pkgs.bundixEnv` nix function to convert your project's +`gemset.nix` into a nix derivation that provides all the gems to your own ruby +package. + +```nix +{ + gems = pkgs.bundixEnv { + inherit system; + name = "${pname}-${version}-gems"; + groups = ["default"]; + ruby = pkgs.ruby; + gemdir = ./.; + }; +} +``` + +`bundixEnv` accepts the same attribute arguments as +[bundlerEnv](https://github.com/NixOS/nixpkgs/blob/48e4e2a1/pkgs/development/ruby-modules/bundler-env/default.nix), +with the addition of two: + + - `platform`: Specifies the gem platform we want to build this package for. + - `system`: As an alternative to `platform`, you can provide your nix `system` + and `bundixEnv` will attempt to figure out the correct `platform` from that. + +In this example, we've also set `groups = ["default"]`. Our `Gemfile` included +`test-unit`[^transitive] in its `development` group. We don't need to include +development dependencies in our package, so this instructs Bundix to only build +gems from the default group (`nokogiri`). If `groups` is unspecified, every gem +will be included. + +[^transitive]: And its transitive dependency, `power_assert`. + +#### An example program + +For demonstration purposes, the example `flake.nix` includes a simple sample +application. Here's the nix portion: + +```nix +{ + packages.${system}.default = pkgs.stdenv.mkDerivation { + inherit gems pname version; + ruby = gems.wrappedRuby; + phases = "installPhase"; + installPhase = '' + mkdir -p $out/bin + cat << EOF > "$out/bin/${pname}" + #!/bin/sh + exec $ruby/bin/ruby <<< "# ... ruby code here ..." + chmod +x "$out/bin/${pname}" + ''; + }; +} +``` + +This package builds a single shell script (`$out/bin/bundix-project` in our +example) that runs `$ruby/bin/ruby` with a simple ruby script (shown below). +This simple shell script has access to our gem dependencies because the `$ruby` +being used by this script comes from `gems.wrappedRuby`. It's preconfigured to +point Bundler to the right nix paths. + +Alternatively, if you prefer to use [bundle +exec](https://bundler.io/v2.4/man/bundle-exec.1.html), you can do so with +`$gems/bin/bundle exec COMMAND`.[^alternatively] + +[^alternatively]: Or you can add them to your app's `$PATH`. Nix is provides a + lot of options for writing your package. + +##### An example ruby script + +The generated example script just proves that the bundle works by loading your +gems and printing their names: + +```ruby +require 'bundler' +Bundler.setup(:default) + +puts "Loaded gems:" +Gem.loaded_specs.each_key { |gem| puts " - #{gem}" } +``` + +It's important to note that, in this example, we are loading the gems with +`Bundler.setup(:default)`. Recall that we explicitly built our package to only +include runtime dependencies, and not development dependencies. If you try load +the dev-dependencies (with `Bundler.setup(:development)`) or *all* the +dependencies (with `Bundler.setup`), you'll get an error like: + +``` +Could not find test-unit-3.5.7, power_assert-2.0.3 in locally installed gems (Bundler::GemNotFound) +``` + +#### Development with nix develop + +You can skip this step if you're planning to package a project that's complete, +or packaging someone else's ruby project. But, if you're planning continue +development on your project, [`nix +develop`](https://nixos.org/manual/nix/stable/command-ref/new-cli/nix3-develop.html) +can be a useful tool. It's purpose is to provide a shell with all your projects +development dependencies available. You can ensure this includes your gems (and +preconfigured `ruby` and `bunder` commands) by including them as `buildInputs`: + +```nix +{ + devShell.${system} = pkgs.mkShell { + buildInputs = with gems; [basicEnv wrappedRuby]; + }; +} +``` + +However, we setup our example project so our `gems` package includes runtime +dependencies only. During development, you'll want to run your unit tests, and +you'll need the `test-unit` gem for that. Bundix can let you create a version of +your gems, that includes everything, with nix's `override` feature: + +```nix +{ + devShell.${system} = pkgs.mkShell { + buildInputs = with gems.override { groups = null; }; [basicEnv wrappedRuby]; + }; +} +``` + +Specify `groups = null;` will create a bundle with gems from all your groups, +but if you explicitly want to include gems from only the `default` and +`development` groups, you can set `groups = ["development"];`. The default group +is always built. diff --git a/guides/motivations.md b/guides/motivations.md new file mode 100644 index 0000000..39e4f08 --- /dev/null +++ b/guides/motivations.md @@ -0,0 +1,180 @@ +# Motivations + +This guide describes the reasoning behind Bundix and why it works how it does. + +## Bundix 2 Motivations + +See [Bundix v2.5.1](https://github.com/nix-community/bundix/tree/2.5.1). + +> I'd usually just tell you to read the code yourself, but the big picture is +> that bundix tries to fetch a hash for each of your bundle dependencies and +> store them all together in a format that Nix can understand and is then used +> by `bundlerEnv`. +> +> I wrote this new version of bundix because I became frustrated with the poor +> performance of the old bundix, and wanted to save both time and bandwidth, as +> well as learn more about Nix. +> +> For each gem, it first tries to look for an existing gem in the bundler cache +> (usually generated via `bundle package`), and if that fails it goes through +> each remote and tries to fetch the gem from there. If the remote happens to be +> [rubygems.org](http://rubygems.org/) we ask the API first for a hash of the +> gem, and then ask the Nix store whether we have this version already. Only if +> that also fails do we download the gem. +> +> As an added bonus I also implemented parsing the `gemset.nix` if it already +> exists, and get hashes from there directly, that way updating an existing +> `gemset.nix` only takes a few seconds. +> +> The output from bundix should be as stable as possible, to make auditing diffs +> easier, that's why I also implemented a pretty printer for the `gemset.nix`. +> +> I hope you enjoy using bundix as much as I do, and if you don't, let me know. + +## Bundix 3 Motivations + +Bundix 2 is an incredible tool, but after two years of daily use, I've stumbled +across a few areas where it needs improvement. Notably: + + - When a `Gemfile.lock` includes multiple platforms. + - When a `Gemfile` specifies gem groups. + - Bundler integration + + Consider how Bundix 2 handles the example `Gemfile` from the [Getting + Stated](./getting-started.md) guide: + +```ruby +source 'https://rubygems.org' +gem 'nokogiri', '1.14.2' +gem 'test-unit', '3.5.7', group: :development +``` + +In that guide, we also configured our bundle to include both the `ruby` and +`x86_64-linux` platforms, so its `Gemfile.lock` includes these `GEM` and +`PLATFORM` sections: + +``` +GEM + remote: https://rubygems.org/ + specs: + mini_portile2 (2.8.1) + nokogiri (1.14.2) + mini_portile2 (~> 2.8.0) + racc (~> 1.4) + nokogiri (1.14.2-x86_64-linux) + racc (~> 1.4) + power_assert (2.0.3) + racc (1.6.2) + test-unit (3.5.7) + power_assert + +PLATFORMS + ruby + x86_64-linux +``` + +If we run these files through Bundix 2, it generates this `gemset.nix`: + +```nix +{ + mini_portile2 = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1af4yarhbbx62f7qsmgg5fynrik0s36wjy3difkawy536xg343mp"; + type = "gem"; + }; + version = "2.8.1"; + }; + nokogiri = { + dependencies = ["mini_portile2" "racc"]; + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1djq4rp4m967mn6sxmiw75vz24gfp0w602xv22kk1x3cmi5afrf7"; + type = "gem"; + }; + version = "1.14.2"; + }; + power_assert = { + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1y2c5mvkq7zc5vh4ijs1wc9hc0yn4mwsbrjch34jf11pcz116pnd"; + type = "gem"; + }; + version = "2.0.3"; + }; + racc = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "09jgz6r0f7v84a7jz9an85q8vvmp743dqcsdm3z9c8rqcqv6pljq"; + type = "gem"; + }; + version = "1.6.2"; + }; + test-unit = { + dependencies = ["power_assert"]; + groups = ["development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1rdhpdi8mlk7jwv9pxz3mhchpd5q93jxzijqhw334w5yv1ajl5hf"; + type = "gem"; + }; + version = "3.5.7"; + }; +} +``` + +### Platform issues + +The most pressing issue with this generated `gemset.nix` is that only one +version of `nokogiri` made it in. In this example it was the `ruby` version, +with the `x86_64-linux` nowhere to be found. If you attempt to build this nix +package on an `x86_64-linux` machine, Bundler will raise an error like: + +``` +Could not find nokogiri-1.14.2-x86_64-linux in locally installed gems (Bundler::GemNotFound) +``` + +The `nokogiri-1.14.2` pure-ruby gem *is* available, but Bundler sees that +`nokogiri (1.14.2-x86_64-linux)` is in the `Gemfile.lock` and always wants to use +the most-specific version for the current platform. + +### Grouping issues + +The second problem with this `gemset.nix` is how transitive dependencies are +grouped. In the above `Gemset` we added `test-unit` to the `development` group. +In the generated `gemset.nix` it is correctly in that group; however, its +transitive dependency, `power_assert`, found its way into the `default` group. +This means that `power_assert` (but not `test-unit`, the gem that uses it) will +always be erroneously included in nix package. + +The reason for this error is because `power_assert` is in the `default` group... +of the `test-unit` gem. Bundix 2 doesn't realise that a `default` dependency of +one of our project's `development` dependencies should also be rendered as a +`development` dependency. + +### Bundler integration + +This issue is a bit more vague, but there is a certain chicken-and-egg problem I +run into occasionally, when a colleague has updated our `Gemfile`, but didn't +(or incorrectly) update the `Gemfile.lock`. Especially for projects that use +git-sources. + +Bundix 2 uses the `Gemfile.lock` to generate the `gemset.nix` file. If it sees +that the `Gemfile.lock` needs to be updated, it will ask `Bundler` to update it. +However, Bundler may complain that you need to run `bundle install` to update +the `Gemfile.lock` (as some remote depenendcies are unmet). However, `bundle +install` will want to build all the gem dependencies. This will probably fail, +since we're on a nix system, and use Bundix to build them. But... Bundix needs +an updated `gemset.nix` to build the correct ones. And so on. + +Bundix 3 avoids this issue by removing Bundler from the equation and updating +the `Gemfile.lock` itself. diff --git a/lib/bundix.rb b/lib/bundix.rb index 5e6243e..c063002 100644 --- a/lib/bundix.rb +++ b/lib/bundix.rb @@ -9,4 +9,32 @@ module Bundix CACHE_DIR = Pathname(ENV['XDG_CACHE_HOME'] || "#{Dir.home}/.cache").join('bundix').freeze SHA256_32 = /^[a-z0-9]{52}$/.freeze + TEMPLATES = Pathname(__dir__).join('../templates').freeze + + FLAKE_NIX_TEMPLATES = { + 'default' => TEMPLATES.join('flake-nix/default.nix.erb'), + 'flake-utils' => TEMPLATES.join('flake-nix/flake-utils.nix.erb') + }.freeze + + # @see {CommandLine::Options} + DEFAULT_OPTIONS = { + bundle_cache_path: './vendor/bundle', + gemfile: './Gemfile', + gemset: './gemset.nix', + ignore_config: false, + init_template: FLAKE_NIX_TEMPLATES['default'], + lockfile: './Gemfile.lock', + project: File.basename(Dir.pwd).freeze, + ruby_derivation: 'ruby', + ruby_platform: 'ruby', + skip_gemset: false + }.freeze + + LOCAL_PLATFORM = Gem::Platform.local.to_s.freeze + + Error = Class.new(RuntimeError) + BundlerError = Class.new(Error) + ProcessError = Class.new(Error) end + +loader.eager_load diff --git a/lib/bundix/bundler_proxy.rb b/lib/bundix/bundler_proxy.rb deleted file mode 100644 index 332ac0e..0000000 --- a/lib/bundix/bundler_proxy.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -require 'bundler/cli' -require 'bundler/cli/cache' - -module Bundix - module BundlerProxy - # A base class for services which execute Bundler CLI classes. - # - # The reason for executing bundle CLI aps via its {Bundler} ruby library, - # instead of the +bundle+ executable, is that +bundle+ will often quit early - # if sources haven't yet been fetched. +bundle+ will recommend that you - # execute +bundle install+ to fetch those sources; however +bundle install+ - # may fail if it needs to build native gems (which is +bundlerEnv+'s job). - class Base - attr_reader :cache_dir - - # @param cache_dir [#to_s] The local directory where Bundix caches gems - # and other fetched sources. - def initialize(cache_dir: CACHE_DIR) - @cache_dir = cache_dir - end - - def call - # We fork here because Bundler may instance_exec some .gemspec files, - # and those file may require other ruby files. We don't want them affect - # the ruby process running this app. - Process.wait(fork { with_bundler_env { cli.run } }) - end - - protected - - # @return [#run] The {Bundler::CLI} service to execute. - def cli - raise NotImplementedError - end - - # @return [Hash] The modified {ENV} to run the Bundler CLI application in. - def env - {} - end - - private - - def with_bundler_env - System.temp_env(**env) do - Bundler.reset! - monkey_patch_definition(Bundler.definition) - yield - end - end - - def monkey_patch_definition(definition) - # Stop Bundler from complaining about the host system's ruby version or - # platform being different than the Gemfile specifies. We're not using - # Bundler to load rubygems, so we don't need these to match. - definition.define_singleton_method(:validate_runtime!) { true } - end - end - end -end diff --git a/lib/bundix/bundler_proxy/base.rb b/lib/bundix/bundler_proxy/base.rb new file mode 100644 index 0000000..8f47a45 --- /dev/null +++ b/lib/bundix/bundler_proxy/base.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'bundler/cli' +require 'bundler/cli/cache' + +module Bundix + module BundlerProxy + # A base class for services which execute inside a modified Bundler + # environment. + # + # The {Bundler} library does complicated things with {ENV} and frequently + # modifies singleton data. Bundix uses Bundler for two purposes: + # + # 1. To handle the user's +Gemfile+ and +Gemfile.lock+. + # 2. To load its own rubygem dependencies, like a typical ruby project. + # + # When handling user files, it's possible that they may break the instance + # of {Bundler} which Bundix is using for its own purposes. To avoid this, we + # spawn a child process to act as a sandbox. Inside the child process, + # {Bundler} can modify what it likes without affecting the parent process. + class Base + attr_reader :cache_dir, :pipe_result + + # @param cache_dir [#to_s] The local directory where Bundix caches gems + # and other fetched sources. + def initialize(cache_dir: CACHE_DIR, pipe_result: false) + @cache_dir = cache_dir + @pipe_result = pipe_result + end + + def call + # We fork here because Bundler may instance_exec some .gemspec files, + # and those file may require other ruby files. We don't want them affect + # the ruby process running this app. + pipe_result ? do_fork_with_result : do_fork + end + + protected + + def bundler_process + raise NotImplementedError + end + + # @return [Hash] The modified {ENV} to run the Bundler CLI application in. + def env + {} + end + + private + + def do_fork_with_result + read, write = IO.pipe + + pid = fork do + read.close + Marshal.dump(with_bundler_env { bundler_process }, write) + end + + write.close + result = read.read + Process.wait(pid) + raise ProcessError, 'child failed' if result.empty? + + Marshal.load(result) # rubocop:disable Security/MarshalLoad + end + + def do_fork + Process.wait(fork { with_bundler_env { bundler_process } }) + nil + end + + def with_bundler_env + Bundler.with_unbundled_env do + System.temp_env(env) do + Bundler.reset! + monkey_patch_definition(Bundler.definition) + yield + end + end + end + + def monkey_patch_definition(definition) + # Stop Bundler from complaining about the host system's ruby version or + # platform being different than the Gemfile specifies. We're not using + # Bundler to load rubygems, so we don't need these to match. + definition.define_singleton_method(:validate_runtime!) { true } + end + end + end +end diff --git a/lib/bundix/bundler_proxy/cache.rb b/lib/bundix/bundler_proxy/cache.rb index 6909e75..bc0fb51 100644 --- a/lib/bundix/bundler_proxy/cache.rb +++ b/lib/bundix/bundler_proxy/cache.rb @@ -6,26 +6,29 @@ module Bundix module BundlerProxy # Executes +bundle cache --all+. + # + # The reason for executing this via the {Bundler} ruby library, instead of + # the +bundle+ executable, is that +bundle+ may quit early if any git + # sources haven't yet been cloned. +bundle+ will then recommend that you + # execute +bundle install+ to fetch them; however +bundle install+ may fail + # if it needs to build native gems (which is +bundlerEnv+'s job). class Cache < Base attr_reader :all_sources, :gemfile, :path - # @param [#to_s] The directory to store cached gems into. + # @param path [#to_s] The directory to store cached gems into. + # @param gemfile [#to_s] Path to the +Gemfile+. def initialize(path, gemfile, all_sources: true, **kwargs) super(**kwargs) - @path = path + @path = Pathname(path) @gemfile = gemfile @all_sources = all_sources end protected - def cli - Bundler::CLI::Cache - .new('all-platforms' => false, # TODO: get from CLI args - 'cache-path' => cache_dir, - all: all_sources, - path: path, - gemfile: gemfile) + def bundler_process + path.mkpath + cli.run end def env @@ -34,6 +37,17 @@ def env 'BUNDLE_GEMFILE' => gemfile } end + + private + + def cli + Bundler::CLI::Cache + .new('all-platforms' => false, # TODO: get from CLI args + 'cache-path' => cache_dir, + all: all_sources, + path: path.to_s, + gemfile: gemfile) + end end end end diff --git a/lib/bundix/bundler_proxy/clone_git.rb b/lib/bundix/bundler_proxy/clone_git.rb new file mode 100644 index 0000000..f54dca8 --- /dev/null +++ b/lib/bundix/bundler_proxy/clone_git.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'bundler/cli' +require 'bundler/cli/lock' + +module Bundix + module BundlerProxy + # This service ensures a git source is cloned within {#cache_dir}. + class CloneGit < Base + attr_reader :ignore_config, :original_source + + # @param original_source [Bundler::Source::Git] + def initialize(original_source, ignore_config: false, **kwargs) + super(pipe_result: true, **kwargs) + + @original_source = original_source + @ignore_config = ignore_config + end + + protected + + def bundler_process + dup_source(original_source).tap do |source| + git_proxy(source).checkout unless source.cache_path.exist? + end + end + + def env + { + # TODO: 'BUNDLE_FROZEN' => nil, + 'BUNDLE_PATH' => cache_dir, + 'BUNDLE_IGNORE_CONFIG' => (ignore_config ? 'true' : nil) + } + end + + private + + # We must dup the source inside #{with_bundler_env}, so its paths are + # inside {#cache_dir}. + def dup_source(source) + Bundler::Source::Git.new(source.options).tap do |new_source| + new_source.cache_path # memoize path inside {#cache_dir} + new_source.install_path # memoize path inside {#cache_dir} + new_source.remote! # allow repo to be cloned + end + end + + def git_proxy(source) + @git_proxy ||= Bundler::Source::Git::GitProxy.new( + source.cache_path, + source.uri, + source.options, + source.options['revision'], + source + ) + end + end + end +end diff --git a/lib/bundix/bundler_proxy/lock.rb b/lib/bundix/bundler_proxy/lock.rb index d53480a..6cf4ea1 100644 --- a/lib/bundix/bundler_proxy/lock.rb +++ b/lib/bundix/bundler_proxy/lock.rb @@ -7,31 +7,32 @@ module Bundix module BundlerProxy # Executes +bundle lock+ or +bundle lock --update+. class Lock < Base - attr_reader :gemfile, :ignore_config, :lockfile, :update - attr_accessor :add_platforms, :remove_platforms + attr_reader :definition, :ignore_config, :set_platforms, :update - def initialize(gemfile, lockfile, update: false, ignore_config: false, + def initialize(definition, update: false, ignore_config: false, # rubocop:disable Metrics/ParameterLists + add_platforms: [], remove_platforms: [], set_platforms: nil, **kwargs) super(**kwargs) - @gemfile = gemfile - @lockfile = lockfile + @definition = definition @update = update @ignore_config = ignore_config + @add_platforms = add_platforms + @remove_platforms = remove_platforms + @set_platforms = set_platforms || current_platforms + end + + def add_platforms + @add_platforms + (set_platforms - current_platforms) + end - # TODO: these should be specified via CLI args - @add_platforms = [] - @remove_platforms = [] + def remove_platforms + @remove_platforms + (current_platforms - set_platforms) end protected - def cli - Bundler::CLI::Lock - .new('remove-platform' => remove_platforms, - 'add-platform' => add_platforms, - lockfile: lockfile, - gemfile: gemfile, - update: update) + def bundler_process + cli.run end def env @@ -42,6 +43,25 @@ def env 'BUNDLE_IGNORE_CONFIG' => (ignore_config ? 'true' : nil) } end + + private + + def cli + Bundler::CLI::Lock + .new('remove-platform' => remove_platforms, + 'add-platform' => add_platforms, + lockfile: definition.lockfile, + gemfile: gemfile, + update: update) + end + + def current_platforms + @current_platforms ||= definition.platforms.map(&:to_s) + end + + def gemfile + definition.gemfiles.first + end end end end diff --git a/lib/bundix/bundler_proxy/settings.rb b/lib/bundix/bundler_proxy/settings.rb deleted file mode 100644 index e115dd5..0000000 --- a/lib/bundix/bundler_proxy/settings.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Bundix - module BundlerProxy - # A version of {Bundler::Settings} that allows greaters control over - # settings. - class Settings < Bundler::Settings - # @param ignore_config [Bool,nil] Whether to ignore settings from Bundler - # config files. +nil+ to use the +BUNDLE_IGNORE_CONFIG+ environment - # variable. - def initialize(*args, ignore_config: nil) - @ignore_config = ignore_config - - super(*args) - end - - def ignore_config? - @ignore_config.nil? ? super : @ignore_config - end - end - end -end diff --git a/lib/bundix/bundler_settings.rb b/lib/bundix/bundler_settings.rb new file mode 100644 index 0000000..a114541 --- /dev/null +++ b/lib/bundix/bundler_settings.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Bundix + # A version of {Bundler::Settings} that allows greaters control over settings. + class BundlerSettings < Bundler::Settings + # @param ignore_config [Bool,nil] Whether to ignore settings from Bundler + # config files. +nil+ to use the +BUNDLE_IGNORE_CONFIG+ environment + # variable. + def initialize(*args, ignore_config: nil) + @ignore_config = ignore_config + + super(*args) + end + + def ignore_config? + @ignore_config.nil? ? super : @ignore_config + end + end +end diff --git a/lib/bundix/command_line.rb b/lib/bundix/command_line.rb index 515edf2..d948dd6 100644 --- a/lib/bundix/command_line.rb +++ b/lib/bundix/command_line.rb @@ -5,7 +5,7 @@ require 'tmpdir' module Bundix - # Provides a command-line interface to {Converter}. + # Executes Bundix with the options given by the user via the command-line. class CommandLine attr_reader :options @@ -16,7 +16,7 @@ def self.call(...) # @param options [Hash] Options which affect the operation of the # application. If none are given, {ARGV} will be parsed for command-line # flags. - # @see CommandLineOptions for available option keys. + # @see CommandLine::Options for available option keys. def initialize(**options) @options = options.empty? ? parse_options : options end @@ -31,7 +31,7 @@ def call private def parse_options - op = CommandLineOptions.new + op = CommandLine::Options.new op.parse! $VERBOSE = !op.options[:quiet] @@ -39,14 +39,23 @@ def parse_options end def handle_bundle_lock - bundle_lock if (options[:lock] && lockfile_stale?) || options[:update_lock] + bundle_lock if lock_option? || lockfile_stale? end def bundle_lock BundlerProxy::Lock - .new(options[:gemfile], options[:lockfile], update: options[:update_lock]) + .new(definition, **options.slice(*%i[add_platforms remove_platforms + set_platforms update])) .call - .tap { |result| raise unless result } + end + + def definition + Bundler::Definition.build(options[:gemfile], options[:lockfile], false) + end + + def lock_option? + %i[add_platforms lock remove_platforms set_platforms update] + .any? { options[_1] } end def lockfile_stale?(lockfile: options[:lockfile], gemfile: options[:gemfile]) @@ -56,9 +65,7 @@ def lockfile_stale?(lockfile: options[:lockfile], gemfile: options[:gemfile]) def handle_bundle_cache return unless options[:cache] - BundlerProxy::Cache.new(options[:cache], options[:gemfile]) - .call - .tap { |result| raise unless result } + BundlerProxy::Cache.new(**options.slice(:cache, :gemfile)).call end def handle_init @@ -74,21 +81,30 @@ def handle_init def flake_nix_string Nix::Template.new(options[:init_template]) - .call(ruby: options[:init], **options) + .call(ruby: options[:init], gemdir: gemdir, **options) end - def build_gemset - Converter.call(fetcher: fetcher, **options) + def gemdir + return @gemdir if defined? @gemdir + + @gemdir = begin + paths = options.slice(:gemfile, :lockfile, :gemset).values.map { Pathname(_1) } + return nil unless paths.map { _1.basename.to_s } == %w[Gemfile Gemfile.lock gemset.nix] + + dirs = paths.map(&:dirname).uniq + dirs.size == 1 ? dirs.first : nil + end end - def fetcher - Fetcher.new(bundler_settings: bundler_settings) + def build_gemset + Gemset::Builder + .call(definition, **options.slice(*%i[groups bundler_env_format])) end def bundler_settings @bundler_settings ||= - BundlerProxy::Settings.new(bundler_root.join('.bundle'), - ignore_config: options[:ignore_config]) + BundlerSettings.new(bundler_root.join('.bundle'), + ignore_config: options[:ignore_config]) end def bundler_root diff --git a/lib/bundix/command_line/help.rb b/lib/bundix/command_line/help.rb new file mode 100644 index 0000000..1cb0cdf --- /dev/null +++ b/lib/bundix/command_line/help.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'optparse' + +module Bundix + class CommandLine + # Provides documentation for the +--help+ command-line argument. + module Help + class << self + def default(key) + "(default: #{DEFAULT_OPTIONS[key]})" + end + + def template_list + FLAKE_NIX_TEMPLATES.keys.map { |str| "'#{str}'" }.join(', ') + end + end + + FLAGS = { + '--quiet' => 'only output errors', + + # Input options + '--gemfile=PATH' => "path to the existing Gemfile #{default :gemfile}", + '--lockfile=PATH' => "path to the Gemfile.lock #{default :lockfile}", + + # Output options + '--gemset=PATH' => + "destination path of the gemset.nix #{default :gemset}", + '--groups=GROUPS' => + 'bundler groups to include in the gemset.nix (default: all groups)', + '--bundler-env[=PLATFORM]' => 'export a nixpkgs#bundlerEnv compatible' \ + "gemset #{default :ruby_platform}", + '--skip-gemset' => 'do not generate gemset', + + # Bundler options + '--lock' => 'lock the gemfile gems into the lockfile', + '--update[=GEMS]' => + 'update the lockfile with new versions of the specified gems, or ' \ + 'each one, if none given (implies --lock)', + '--bundle-cache[=DIR]' => + "package .gem files into directory #{default :bundle_cache_path}", + '--ignore-bundler-configs' => 'ignores Bundler config files', + + '--add-platforms=PLATFORMS' => 'add platforms to the lockfile (implies --lock)', + '--remove-platforms=PLATFORMS' => 'remove platforms from the lockfile (implies --lock)', + '--platforms=PLATFORMS' => 'replace all platforms in the lockfile (implies --lock)', + + # flake.nix options + '--init[=RUBY_DERIVATION]' => "initialize a new flake.nix for 'nix " \ + "develop' (won't overwrite old ones)", + '--init-template=TEMPLATE' => + "the flake.nix template to use. may be #{template_list}, or a " \ + 'filename (default: default)', + '--project-name=NAME' => + "project name to use with --init #{default :project}", + + # Environment options + '--version' => 'show the version of bundix', + '--env' => 'show the environment in Bundix', + '--platform' => 'show the gem platform of this host' + }.freeze + + def on(*args, &blk) + super(*args.push(flag_help(args)), &blk) + end + + private + + def flag_help(args) + FLAGS.fetch(args.find { _1.start_with?('--') }) + end + end + end +end diff --git a/lib/bundix/command_line/options.rb b/lib/bundix/command_line/options.rb new file mode 100644 index 0000000..df4849d --- /dev/null +++ b/lib/bundix/command_line/options.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'optparse' + +module Bundix + class CommandLine + # Parses command-line options. + class Options < OptionParser + include Help + + attr_accessor :options + + def initialize + @options = DEFAULT_OPTIONS.dup + super + make_options + end + + protected + + def make_options + logging_options + file_options + bundler_options + init_options + environment_options + end + + def logging_options + on('-q', '--quiet') { options[:quiet] = true } + end + + def file_options # rubocop:disable Metrics/AbcSize + separator("\nFile options:") + + on('--gemfile=PATH') { options[:gemfile] = path(_1) } + on('--lockfile=PATH') { options[:lockfile] = path(_1) } + on('--gemset=PATH') { options[:gemset] = path(_1) } + on('-g', '--groups=GROUPS', Array) { options[:groups] = _1 } + on '--bundler-env[=PLATFORM]' do |platform| + options[:bundler_env_format] = platform || options[:ruby_platform] + end + on('--skip-gemset') { options[:skip_gemset] = true } + end + + def bundler_options # rubocop:disable Metrics/AbcSize + separator("\nBundler options:") + + on('-l', '--lock') { options[:lock] = true } + on('-u', '--update[=GEMS]', Array) { options[:update] = _1 || true } + on('-a', '--add-platforms=PLATFORMS', Array) { options[:add_platforms] = _1 } + on('-r', '--remove-platforms=PLATFORMS', Array) { options[:remove_platforms] = _1 } + on('-p', '--platforms=PLATFORMS', Array) { options[:set_platforms] = _1 } + on '-c', '--bundle-cache[=DIR]' do |dir| + options[:cache] = path(dir, default: :bundle_cache_path) + end + on('--ignore-bundler-configs') { options[:ignore_config] = true } + end + + def init_options + separator("\nflake.nix options:") + + on '-i', '--init[=RUBY_DERIVATION]' do |ruby| + options[:init] = ruby || options[:ruby_derivation] + end + on '-t', '--init-template=TEMPLATE' do |template| + options[:init_template] = parse_template(template) + end + on('-n', '--project-name=NAME') { options[:project] = _1 } + end + + def environment_options + separator("\nEnvironment options:") + + on('-v', '--version') { puts VERSION && exit } + on('--env') { system('env') && exit } + on('--platform') { puts LOCAL_PLATFORM && exit } + end + + private + + def path(str, default: nil) + Pathname(str || options[default]).expand_path + end + + def parse_template(template) + if FLAKE_NIX_TEMPLATES.key?(template) + FLAKE_NIX_TEMPLATES[template] + elsif (user_template = path(template)).readable? + user_template + else + raise OptionParser::InvalidArgument, "--init-template=#{template}" + end + end + end + end +end diff --git a/lib/bundix/command_line_options.rb b/lib/bundix/command_line_options.rb deleted file mode 100644 index 9a61cef..0000000 --- a/lib/bundix/command_line_options.rb +++ /dev/null @@ -1,116 +0,0 @@ -# frozen_string_literal: true - -require 'optparse' - -module Bundix - # Parses commandline options. - class CommandLineOptions < OptionParser - FLAKE_NIX_TEMPLATES = { - 'default' => '../../template/flake.nix.erb', - 'flake-utils' => '../../template/flake-with-utils.nix.erb' - }.transform_values { |path| Pathname(__dir__).join(path).freeze }.freeze - - DEFAULTS = { - bundle_cache_path: './vendor/bundle', - gemfile: './Gemfile', - gemset: './gemset.nix', - ignore_config: false, - init_template: FLAKE_NIX_TEMPLATES['default'], - lockfile: './Gemfile.lock', - project: File.basename(Dir.pwd), - ruby_derivation: 'ruby', - skip_gemset: false - }.freeze - - attr_accessor :options - - def initialize - @options = DEFAULTS.dup - super { |opts| make_options(opts) } - end - - private - - def make_options(opts) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength - opts.on '-i', '--init[=RUBY_DERIVATION]', - "initialize a new flake.nix for 'nix develop' (won't overwrite old ones)" do |ruby| - options[:init] = ruby || DEFAULTS[:ruby_derivation] - end - - opts.on '-t', '--init-template=TEMPLATE', - "the flake.nix template to use. may be #{template_list}, " \ - 'or a filename (default: default)' do |template| - options[:init_template] = - if FLAKE_NIX_TEMPLATES.key?(template) - FLAKE_NIX_TEMPLATES[template] - elsif File.readable?(template) - template - else - raise OptionParser::InvalidArgument, "--init-template=#{template}" - end - end - - opts.on '-p', '--init-project=NAME', - "project name to use with --init #{default :project}" do |name| - options[:project] = name - end - - opts.on '--gemset=PATH', "path to the gemset.nix #{default :gemset}" do |value| - options[:gemset] = File.expand_path(value) - end - - opts.on '--lockfile=PATH', "path to the Gemfile.lock #{default :lockfile}" do |value| - options[:lockfile] = File.expand_path(value) - end - - opts.on '--gemfile=PATH', "path to the Gemfile #{default :gemfile}" do |value| - options[:gemfile] = File.expand_path(value) - end - - opts.on '--skip-gemset', 'do not generate gemset' do - options[:skip_gemset] = true - end - - opts.on '-q', '--quiet', 'only output errors' do - options[:quiet] = true - end - - opts.on '-l', '--bundle-lock', 'generate Gemfile.lock first' do - options[:lock] = true - end - - opts.on '-u', '--bundle-update[=GEMS]', - 'ignores the existing lockfile. Resolve then updates lockfile. Taking a list of gems or updating ' \ - 'all gems if no list is given (implies --bundle-lock)' do |gems| - options[:update_lock] = gems || true - end - - opts.on '-c', '--bundle-cache[=DIRECTORY]', - "package .gem files into directory #{default :bundle_cache_path}" do |dir| - options[:cache] = dir || DEFAULTS[:bundle_cache_path] - end - - opts.on '--bundle-ignore-config', 'ignores Bundler config files' do - options[:ignore_config] = true - end - - opts.on '-v', '--version', 'show the version of bundix' do - puts VERSION - exit - end - - opts.on '--env', 'show the environment in bundix' do - system('env') - exit - end - end - - def default(key) - "(default: #{DEFAULTS[key]})" - end - - def template_list - FLAKE_NIX_TEMPLATES.keys.map { |str| "'#{str}'" }.join(', ') - end - end -end diff --git a/lib/bundix/converter.rb b/lib/bundix/converter.rb deleted file mode 100644 index bb0173f..0000000 --- a/lib/bundix/converter.rb +++ /dev/null @@ -1,120 +0,0 @@ -# frozen_string_literal: true - -require 'json' - -module Bundix - # A service to parse a Gemfile/Gemfile.lock pair, download any gem - # dependencies, and calculate their hashes. - class Converter - DEFAULT_OPTIONS = { quiet: false, tempfile: nil }.freeze - - attr_reader :fetcher, :options, :platforms - - def self.call(...) - new(...).call - end - - # @params fetcher [Fetcher] - # @params platforms [Platforms] - # @params options [Hash] - def initialize(fetcher: Fetcher.new, platforms: Platforms.defaults, - **options) - @fetcher = fetcher - @platforms = platforms - @options = DEFAULT_OPTIONS.merge(**options) - end - - def call - # reverse so git comes last - lockfile.specs.reverse_each.with_object({}) do |spec, gems| - convert_spec!(spec, gems) - end - end - - def parse_gemset - path = File.expand_path(options[:gemset].to_s) - File.file?(path) ? JSON.parse(System.nix_to_json(path)) : {} - end - - private - - def convert_spec!(spec, gems) - gem = find_cached_spec(spec, cache) || convert_single_spec(spec) - gems.merge!(gem) - - gems[spec.name]['dependencies'] = spec.dependencies.map(&:name) - ['bundler'] if spec.dependencies.any? - end - - def dependency_cache - @dependency_cache ||= DependencyCache.new(options[:lockfile], options[:gemfile]) - end - - def lockfile - dependency_cache.lockfile_parser - end - - def cache - @cache ||= parse_gemset - end - - def groups(spec) - { groups: dependency_cache.fetch(spec.name).groups } - end - - def convert_single_spec(spec) - value = { version: spec.version.to_s, source: spec_source(spec) } - { spec.name => value.merge(platforms: spec_platforms(spec)).merge(groups(spec)) } - rescue Bundler::Dsl::DSLError - raise - rescue StandardError => e - warn "Skipping #{spec.name}: #{e}" - puts e.backtrace - { spec.name => {} } - end - - def spec_source(spec) - Source.new(spec, fetcher).convert - end - - def spec_platforms(spec) - # c.f. Bundler::CurrentRuby - dependency_cache - .fetch(spec.name) - .platforms - .map { |platform_name| platforms[platform_name] } - .flatten - end - - def find_cached_spec(spec, cache) - name, cached = cache.find { |k, v| spec_matches?(spec, k, v) } - - { name => cached } if cached - end - - def spec_matches?(spec, key, value) - cached_source = value['source'] - return false unless key == spec.name && cached_source - - case spec.source - when Bundler::Source::Git - cached_git_spec?(cached_source, spec.source) - when Bundler::Source::Rubygems - cached_rubygems_spec?(cached_source, value['version'], spec.version.to_s) - end - end - - def cached_git_spec?(cached_source, spec_source) - cached_rev = cached_source['rev'] - spec_rev = spec_source.options['revision'] - - cached_source['type'] == 'git' && - cached_rev && - spec_rev && - spec_rev == cached_rev - end - - def cached_rubygems_spec?(cached_source, version, spec_version) - cached_source['type'] == 'gem' && version == spec_version - end - end -end diff --git a/lib/bundix/dependency.rb b/lib/bundix/dependency.rb deleted file mode 100644 index 0bc087d..0000000 --- a/lib/bundix/dependency.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Bundix - # A version of {Bundler::Dependency} that exposes its {#version}. - class Dependency < Bundler::Dependency - attr_reader :version - end -end diff --git a/lib/bundix/dependency_cache.rb b/lib/bundix/dependency_cache.rb deleted file mode 100644 index 11a37bf..0000000 --- a/lib/bundix/dependency_cache.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -module Bundix - # Parses a Gemfile/Gemfile.lock pair and creates a map of dependency-names to - # their {Bundler::Dependency}. - class DependencyCache - attr_reader :gemfile, :lockfile - - def initialize(lockfile, gemfile) - @lockfile = lockfile - @gemfile = gemfile - end - - def fetch(key) - dep_cache.fetch(key) - end - - def lockfile_parser - @lockfile_parser ||= Bundler::LockfileParser.new(File.read(lockfile)) - end - - private - - def dep_cache - @dep_cache ||= build_depcache - end - - def build_depcache - new_cache = {} - add_top_level_gems_and_specs(new_cache) - - loop do - changed = false - lockfile_parser.specs.each do |spec| - changed = add_spec_set(new_cache, spec) || changed - end - break unless changed - end - - new_cache - end - - def bundler_definition - @bundler_definition ||= Bundler::Definition.build(gemfile, lockfile, false) - end - - def add_top_level_gems_and_specs(new_cache) - bundler_definition.dependencies.each { |dep| new_cache[dep.name] = dep } - - lockfile_parser.specs.each do |spec| - new_cache[spec.name] ||= Dependency.new(spec.name, nil, {}) - end - end - - # @param new_cache [Hash] - # @param spec_set [Bundler::SpecSet] - # @return [Boolean] TODO If something has changed? - def add_spec_set(new_cache, spec_set) - changed = false - spec_dep = new_cache.fetch(spec_set.name) - - spec_set.dependencies.each do |dep| - changed = add_spec_set_dependency(new_cache, spec_dep, dep.name) || changed - end - - changed - end - - # @param new_cache [Hash] - # @param spec_dep [Bundler::Dependency] - # @param dep_name [String] - # @return [Boolean] TODO If something has changed? - def add_spec_set_dependency(new_cache, spec_dep, dep_name) - dep = new_cache.fetch(dep_name) do |name| - assert_bundler_dep!(name) - bundler_dependency - end - return false unless groups_or_platforms_diff?(spec_dep, dep) - - new_cache[dep.name] = build_dependency(spec_dep, dep) - true - end - - def assert_bundler_dep!(name) - return if name == 'bundler' - - raise KeyError, "Gem dependency '#{name}' not specified in #{lockfile}" - end - - def bundler_dependency - @bundler_dependency ||= Dependency.new('bundler', lockfile_parser.bundler_version, {}) - end - - def groups_or_platforms_diff?(as_dep, cached) - !((as_dep.groups - cached.groups) - [:default]).empty? || - !(as_dep.platforms - cached.platforms).empty? - end - - def build_dependency(spec_dep, dep) - Dependency.new( - dep.name, - nil, - { - 'group' => spec_dep.groups | dep.groups, - 'platforms' => spec_dep.platforms | dep.platforms - } - ) - end - end -end diff --git a/lib/bundix/fetcher.rb b/lib/bundix/fetcher.rb deleted file mode 100644 index 47aa792..0000000 --- a/lib/bundix/fetcher.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -require 'fileutils' - -module Bundix - # Fetches gems from local and remote sources. - class Fetcher - attr_reader :bundler_settings - - # @param bundler_settings [Bundler::Settings,nil] Optional bundler settings - # to provide credentials for remote fetchers. - def initialize(bundler_settings: nil) - @bundler_settings = bundler_settings - end - - # @param dest [#to_s] The filename to save the downloaded gem to. - # @param url [URI,String] The HTTP, HTTPS, or local file to download. - def download(dest, url) - warn "Downloading #{dest} from #{url}" - uri = URI(url) - - case uri.scheme - when nil # local file path - FileUtils.cp(url, dest.to_s) - when 'http', 'https' - HttpFetcher.new(uri, bundler_settings: bundler_settings).download(dest) - else - raise 'Unsupported URL scheme' - end - end - - def fetch_remotes_hash(spec, remotes) - remotes.each do |remote| - hash = fetch_remote_hash(spec, remote) - return remote, Nix::Hash32.call(hash) if hash - end - - nil - end - - def nix_prefetch_git(...) - System.nix_prefetch_git(...) - end - - def fetch_local_hash(spec) - spec.source.caches.each do |cache| - path = File.join(cache, "#{spec.full_name}.gem") - next unless File.file?(path) - - hash = nix_prefetch_url(path)&.[](SHA256_32) - return Nix::Hash32.call(hash) if hash - end - - nil - end - - def nix_prefetch_url(url, dir: CACHE_DIR) - FileUtils.mkdir_p(dir.to_s) - file = File.join(dir.to_s, url.gsub(/[^\w-]+/, '_')) - - download(file, url) unless File.size?(file) - return unless File.size?(file) - - System.nix_prefetch_url(url, file) - rescue StandardError => e - warn(e.full_message) - nil - end - - def fetch_remote_hash(spec, remote) - uri = "#{remote}/gems/#{spec.full_name}.gem" - result = nix_prefetch_url(uri) - return unless result - - result[SHA256_32] - rescue StandardError => e - puts "ignoring error during fetching: #{e}" - puts e.backtrace - nil - end - end -end diff --git a/lib/bundix/gemset/builder.rb b/lib/bundix/gemset/builder.rb new file mode 100644 index 0000000..4a0506f --- /dev/null +++ b/lib/bundix/gemset/builder.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module Bundix + module Gemset + # A service class to generate the contents of +gemfile.nix+, describing the + # rubygem derivations required by the +Gemfile+ on different platforms. + # + # Some gems, especially those with native extensions, may provide different + # versions for different platforms, and those versions may vary in their own + # transitive dependencies. + class Builder + # Download RubyGems index, with SHA-256 hashes + class RubyGemsApi + def initialize(source) + @source = source + end + + def call(names) + @source.fetchers + .first # TODO: use all fetchers? or "api fetcher?" + .specs_with_retry(names, @source) + end + end + + attr_reader :definition, :engines, :groups + + class << self + def call(...) + build(...).call + end + + def build(*args, bundler_env_format: nil, **kwargs) + if bundler_env_format + EnvFormatBuilder.new(bundler_env_format, *args, **kwargs) + else + new(*args, **kwargs) + end + end + end + + # @param groups [nil, Array<#to_sym>] The Bundler groups to include, or + # +nil+ for all. + # @param engines [RubyEngines] + # @see https://bundler.io/guides/groups.html + def initialize(definition, groups: nil, engines: RubyEngines.defaults) + @definition = definition + @groups = compact_groups(groups) + @engines = engines + end + + # @return [Hash] A Hash describing gems available to + # different platforms. The +:dependencies+ entry is an array of gem + # names defined as dependencies of the lockfile. The +:platforms+ entry + # contains a map of gem-platforms to the gems available to that + # platform. + def call + { dependencies: dependencies, platforms: platforms } + end + + def dependencies + @dependencies ||= definition.dependencies_for(groups).map(&:name) + end + + def platforms + @platforms ||= + PlatformGems.new(definition, all_lockfile_specs, groups: groups).call + end + + def lockfile_parser + definition.locked_gems + end + + private + + def compact_groups(groups) + groups ||= definition.groups + (groups.map(&:to_sym) + [:default]).uniq + end + + def all_lockfile_specs + specs = lockfile_sources.flat_map { |source| source_specs(source).to_a } + deps = sources_map.values.flatten + specs.select do |spec| + deps.find do |dep| + %i[name version platform].all? do |attr| + spec.send(attr) == dep.send(attr) + end + end + end + end + + def source_specs(source) + case source + when Bundler::Source::Git then git_specs(source) + when Bundler::Source::Path then source.specs + when Bundler::Source::Rubygems then cached_rubygem_specs[source] + else + raise "unexpected source: #{source}" + end + end + + def git_specs(source) + BundlerProxy::CloneGit.new(source).call.specs + end + + def cached_rubygem_specs + @cached_rubygem_specs ||= Hash.new do |hash, source| + hash[source] = + RubyGemsApi.new(source) + .call(sources_map.fetch(source, []).map(&:name)) + end + end + + def sources_map + @sources_map ||= lockfile_sources.to_h { |s| [s, lockfile_specs(s)] } + end + + def lockfile_sources + lockfile_parser.specs.map(&:source).uniq + end + + def lockfile_specs(source) + lockfile_parser.specs.select { |spec| spec.source == source } + end + end + end +end diff --git a/lib/bundix/gemset/env_format_builder.rb b/lib/bundix/gemset/env_format_builder.rb new file mode 100644 index 0000000..4bcec98 --- /dev/null +++ b/lib/bundix/gemset/env_format_builder.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Bundix + module Gemset + # This {Builder} renders the contents of a +gemset.nix+ that is suiltable + # for the +builderEnv+ nix function. + class EnvFormatBuilder < Builder + attr_reader :platform + + # @param platform [String] The platform of the gems to include. + # @param args [Array] See {Builder#new}. + # @param kwargs [Hash] See {Builder#new}. + def initialize(platform, *args, **kwargs) + super(*args, **kwargs) + @platform = platform + end + + def call + @gemset = super + compile({}, @gemset[:dependencies]) + end + + private + + def compile(newset, deps) + return newset if deps.empty? + + name = deps.first + spec = platform_specs[name] || ruby_specs[name] + raise "no suitable '#{name}' gem for '#{platform}' platform" unless spec + + newset[name] = spec + compile(newset, deps[1..] + Array(spec[:dependencies])) + end + + def platform_specs + @platform_specs ||= @gemset[:platforms].fetch(platform, {}) + end + + def ruby_specs + @ruby_specs ||= @gemset[:platforms].fetch('ruby', {}) + end + end + end +end diff --git a/lib/bundix/gemset/platform_gems.rb b/lib/bundix/gemset/platform_gems.rb new file mode 100644 index 0000000..631d666 --- /dev/null +++ b/lib/bundix/gemset/platform_gems.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Bundix + module Gemset + # Convert a {Bundler::SpecSet} to a {Hash} of gem-platform/gemset pairs. + class PlatformGems + attr_reader :definition, :groups, :specs + + # @param definition [Bundler::Definition] + # @param specs [#to_a] A list of Bundler specs. + # @param groups [nil,Array<#to_sym>] The list of Bundler groups to + # include, or +nil+ for all. + def initialize(definition, specs, groups: nil) + @definition = definition + @specs = Bundler::SpecSet.new(specs.to_a) + @groups = groups || definition.groups + end + + def call + Nix::Serializer.new(platform_gemsets).to_nix + end + + private + + def platform_gemsets + new_gemset_hash.tap do |gemsets| + all_spec_dependencies.each do |spec| + gemset_add(gemsets, spec, group_specs[spec_id(spec)]) + end + end + end + + # Nested hash: platform -> spec name -> spec details + def new_gemset_hash + Hash.new do |pmap, platform| + pmap[platform] = Hash.new { |smap, name| smap[name] = {} } + end + end + + def all_spec_dependencies + Set.new.tap do |set| + gem_platforms.each { |platform| set.merge(platform_gemset(platform)) } + end + end + + def platform_gemset(platform) + specs.for(dependencies, false, [platform]) + end + + def dependencies + groups.flat_map { group_dependencies[_1] } + end + + def all_dependencies + @all_dependencies ||= + [ + dependencies, + definition.resolve.flat_map(&:dependencies) + ].flatten.sort.uniq + end + + def group_dependencies + @group_dependencies ||= groups.to_h do |group| + [group.to_sym, definition.dependencies_for([group.to_sym])] + end + end + + def gem_platforms + @gem_platforms ||= + definition.platforms.map { |plat| Gem::Platform.new(plat) } + end + + def gemset_add(gemsets, spec, groups) + prev = gemsets.dig(spec.platform.to_s, spec.name, spec.version) + raise "version mismatch: #{prev}, #{spec}" if prev && prev.version != spec.version + + gemsets[spec.platform.to_s][spec.name] = + Nix::BundlerSpecification.new(spec, groups: groups) + end + + def group_specs + @group_specs ||= + Hash.new { |h1, spec| h1[spec] = [] } + .tap { |hash| add_dependencies_to_groups(hash, dependencies) } + end + + def add_spec_to_groups(hash, spec, groups) + hash[spec_id(spec)] = groups + add_dependencies_to_groups(hash, spec.dependencies, groups: groups) + end + + def add_dependencies_to_groups(hash, deps, groups: nil) + deps.each do |dep| + next if dep.name == 'bundler' + + spec = definition.resolve.find { dep.matches_spec?(_1) } + raise "No spec for #{dep}" unless spec + + add_spec_to_groups(hash, spec, groups || dep.groups) + end + end + + def spec_id(spec) + [spec.name, spec.version, spec.platform] + end + end + end +end diff --git a/lib/bundix/hash_with_nix_order.rb b/lib/bundix/hash_with_nix_order.rb deleted file mode 100644 index 320d365..0000000 --- a/lib/bundix/hash_with_nix_order.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Bundix - # A renfinement that changes {Hash} comparison to first sort entries via - # {Budix::Nixes.order}. - module HashWithNixOrder - refine Hash do - def <=>(other) - return unless other.is_a?(Hash) - - larray = to_a.sort { |l, r| Bundix::Nix::Serializer.order(l, r) } - rarray = other.to_a.sort { |l, r| Bundix::Nix::Serializer.order(l, r) } - larray <=> rarray - end - end - end -end diff --git a/lib/bundix/http_fetcher.rb b/lib/bundix/http_fetcher.rb deleted file mode 100644 index 2140e37..0000000 --- a/lib/bundix/http_fetcher.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -require 'net/http' - -module Bundix - # This fetcher fetches gems from HTTP and HTTPS sources. - class HttpFetcher - attr_reader :bundler_settings, :uri - - # @param uri [URI] The URI of the gem to fetch. - # @param bundler_settings [Bundler::Settings,nil] Optional bundler settings - # to provide HTTP credentials. - def initialize(uri, bundler_settings: nil) - raise ArgumentError, "unexpected scheme: #{uri.scheme}" unless %w[http https].include?(uri.scheme) - - @uri = uri - @bundler_settings = bundler_settings - end - - # @param dest [#to_s] The filename to save the downloaded gem to. - def download(dest) - inject_credentials_from_bundler_settings unless uri.user - make_request do |resp| - File.open(dest.to_s, 'wb+') do |local| - resp.read_body { |chunk| local.write(chunk) } - end - end - end - - private - - def inject_credentials_from_bundler_settings - creds = bundler_settings&.credentials_for(uri) - return unless creds - - uri.user, uri.password = creds.split(':', 2) - end - - def make_request(&blk) - http_start do |http| - request = Net::HTTP::Get.new(uri) - request.basic_auth(uri.user, uri.password) if uri.user - http.request(request) { |resp| handle_response(resp, &blk) } - end - end - - def http_start(&blk) - Net::HTTP.start(uri.host, uri.port, use_ssl: (uri.scheme == 'https'), - &blk) - end - - def handle_response(resp) - case resp - when Net::HTTPOK - yield resp - when Net::HTTPUnauthorized, Net::HTTPForbidden - debrief_access_denied - raise "http error #{resp.code}: #{uri.host}" - else - raise "http error #{resp.code}: #{uri.host}" - end - end - - def debrief_access_denied - print_error( - "Authentication is required for #{uri.host}.\n" \ - "Please supply credentials for this source. You can do this by running:\n " \ - 'bundle config packages.shopify.io username:password' - ) - end - - def print_error(msg) - msg = "\x1b[31m#{msg}\x1b[0m" if $stdout.tty? - warn(msg) - end - end -end diff --git a/lib/bundix/nix/bundler_source.rb b/lib/bundix/nix/bundler_source.rb new file mode 100644 index 0000000..3d951dc --- /dev/null +++ b/lib/bundix/nix/bundler_source.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Bundix + module Nix + # {Serializer Serializes} a Bundler spec's source into nix format. + class BundlerSource + attr_reader :spec + + def self.build(spec) + case spec.source + when Bundler::Source::Git then Git.new(spec) + when Bundler::Source::Path then Path.new(spec) + when Bundler::Source::Rubygems then Rubygems.new(spec) + else + raise ArgumentError, "unexpected source: #{spec.source}" + end + end + + def initialize(spec) + @spec = spec + end + + def source + spec.source + end + + # {Serializer Serializes} a {Bundler::Source::Git} into nix format. + class Git < BundlerSource + def self.sha256(spec) + rev = spec.source.options.fetch('revision') + System.nix_prefetch_git(spec.source.cache_path, rev)['sha256'] + end + + def to_nix + { + type: 'git', + url: source.options.fetch('uri').to_s, + rev: source.options.fetch('revision'), + sha256: self.class.sha256(spec), + fetchSubmodules: !source.submodules.nil? + } + end + end + + # {Serializer Serializes} a {Bundler::Source::Path} into nix format. + class Path < BundlerSource + def to_nix + { + type: 'path', + path: source.path + } + end + end + + # {Serializer Serializes} a {Bundler::Source::Rubygems} into nix format. + class Rubygems < BundlerSource + def self.sha256(spec) + Nix::Hash32.call(spec.checksum) + end + + def to_nix + { + remotes: remotes, + type: 'gem', + sha256: self.class.sha256(spec) + } + end + + def remotes + source.remotes.map { |remote| remote.to_s.sub(%r{/+$}, '') } + end + end + end + end +end diff --git a/lib/bundix/nix/bundler_specification.rb b/lib/bundix/nix/bundler_specification.rb new file mode 100644 index 0000000..f91109c --- /dev/null +++ b/lib/bundix/nix/bundler_specification.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Bundix + module Nix + # {Serializer Serializes} a Bundler spec into nix format. + class BundlerSpecification + attr_reader :engines, :groups, :spec + + def initialize(spec, groups: nil, engines: RubyEngines.defaults) + @spec = spec + @groups = compact_groups(groups) + @engines = engines + end + + def to_nix + { + dependencies: dependencies, + groups: groups, + source: Nix::BundlerSource.build(spec), + version: version, + platforms: engine + }.compact + end + + def dependencies + spec.dependencies + .select(&:runtime?) + .map(&:name) + .tap { _1.delete('bundler') } + .then { _1.empty? ? nil : _1 } + end + + private + + def compact_groups(groups) + groups = Array(groups).map(&:to_sym) + return nil if (groups - [:default]).empty? + + groups + end + + def version + [spec.version, (platform unless ruby_platform?)].compact.join('-') + end + + def platform + @platform ||= spec.platform.to_s + end + + def ruby_platform? + platform == Gem::Platform::RUBY + end + + def engine + engines[spec.platform] unless spec.platform == Gem::Platform::RUBY + end + end + end +end diff --git a/lib/bundix/nix/context.rb b/lib/bundix/nix/context.rb index 531b082..286dc2d 100644 --- a/lib/bundix/nix/context.rb +++ b/lib/bundix/nix/context.rb @@ -14,11 +14,15 @@ def initialize(**template_vars) end def method_missing(name, *args, &block) - template_vars.key?(name) ? template_vars[name] : super + if args.empty? && (attr = path_attr(name)) + path_for(send(attr)) + else + template_vars.key?(name) ? template_vars[name] : super + end end def respond_to_missing?(name, include_private = false) - template_vars.key?(name) || super + template_vars.key?(name) || path_attr(name) || super end # @return [Binding] @@ -30,16 +34,14 @@ def path_for(file) Serializer.call(Pathname(file).relative_path_from(Pathname('./'))) end - def gemfile_path - path_for(gemfile) - end + private - def lockfile_path - path_for(lockfile) - end + def path_attr(name) + str = name.to_s + return nil unless str.end_with?('_path') - def gemset_path - path_for(gemset) + attr = str[...-5].to_sym + attr if respond_to?(attr) end end end diff --git a/lib/bundix/nix/serializer.rb b/lib/bundix/nix/serializer.rb index 3da1f6f..8d910f6 100644 --- a/lib/bundix/nix/serializer.rb +++ b/lib/bundix/nix/serializer.rb @@ -14,55 +14,44 @@ module Nix # - {Symbol} # - {Pathname} # - {TrueClass true} and {FalseClass false} + # - Any object that responds to +to_nix+ class Serializer - using Bundix::HashWithNixOrder + include ClassMethods + using HashOrder - SET_TEMPLATE = '../../../template/nixer/set.erb' - LIST_TEMPLATE = '../../../template/nixer/list.erb' + DEFAULT_WIDTH = 80 + LIST_TEMPLATE = erb_template(TEMPLATES.join('serializer/list.erb')) + SET_TEMPLATE = erb_template(TEMPLATES.join('serializer/set.erb')) - attr_reader :level, :obj + attr_reader :compact_width, :level, :obj - class << self - def call(...) - new(...).call - end - - def order(left, right) - if right.is_a?(left.class) && right.respond_to?(:<=>) - cmp = right <=> left - return -1 * cmp unless cmp.nil? - end - - if left.is_a?(right.class) && left.respond_to?(:<=>) - cmp = right <=> left - return class_order(left, right) if cmp.nil? - - return cmp - end - - class_order(left, right) - end - - def class_order(left, right) - left.class.name <=> right.class.name # like Erlang - end - end - - def initialize(obj, level = 0) + def initialize(obj, level = 0, compact_width: DEFAULT_WIDTH) @obj = obj @level = level + @compact_width = compact_width - level if compact_width end - def call # rubocop:disable Metrics/AbcSize + def call case obj - when Hash then set_template.result(binding) - when Array then list_template.result(binding) - when String then obj.dump - when Symbol then obj.to_s.dump + when Hash then compact_string(SET_TEMPLATE.result(binding)) + when Array then compact_string(LIST_TEMPLATE.result(binding)) + when String, Symbol, Gem::Version then nix_string when Pathname then serialize_pathname(obj) when true, false then obj.to_s else - raise "Cannot convert to nix: #{obj.inspect}" + serialize_by_method(obj) + end + end + + def to_nix(obj = self.obj) + if obj.respond_to?(:to_nix) + to_nix(obj.to_nix) + elsif obj.is_a?(Hash) + obj.entries.to_h { |k, v| [to_nix(k), to_nix(v)] } + elsif obj.respond_to?(:map) + obj.map { |elem| to_nix(elem) } + else + obj end end @@ -72,16 +61,26 @@ def order(...) private - def set_template - @set_template ||= erb_template(SET_TEMPLATE) + def compact_string(str) + return str unless compact_width + + oneliner = compact_braces(str.gsub(/\s*\n\s*/, ' '), ['[]']) + oneliner.size < compact_width ? oneliner : str + end + + def compact_braces(str, pairs) + pairs.map(&:chars).each do |left, right| + return [left, str[2..-3], right].join if wrapped?(str, left, right) + end + str end - def list_template - @list_template ||= erb_template(LIST_TEMPLATE) + def wrapped?(str, left, right) + str.start_with?("#{left} ") && str.end_with?(" #{right}") end - def erb_template(path) - ERB.new(Pathname(__dir__).join(path).read.chomp) + def nix_string + obj.is_a?(String) ? obj.dump : obj.to_s.dump end def indent @@ -93,7 +92,7 @@ def outdent end def sub(obj, indent = 0) - self.class.call(obj, level + indent) + self.class.call(obj, level + indent, compact_width: compact_width) end def serialize_key(key) @@ -108,6 +107,13 @@ def serialize_pathname(path) str = path.to_s %r{/} =~ str ? str : "./#{str}" end + + def serialize_by_method(obj) + raise "Cannot serialize: #{obj.inspect}" unless obj.respond_to?(:to_nix) + + nix = obj.to_nix + nix.is_a?(String) ? nix : self.class.call(nix, level, compact_width: compact_width) + end end end end diff --git a/lib/bundix/nix/serializer/class_methods.rb b/lib/bundix/nix/serializer/class_methods.rb new file mode 100644 index 0000000..5cff2a3 --- /dev/null +++ b/lib/bundix/nix/serializer/class_methods.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'erb' + +module Bundix + module Nix + class Serializer + # A mixin of class methods for {Serializer}. + module ClassMethods + def self.included(base) + base.extend(Mixin) + end + + # Defines the class methods. + module Mixin + def call(...) + new(...).call + end + + def erb_template(path) + ERB.new(path.read.chomp).freeze + end + + def order(left, right) + if right.is_a?(left.class) && right.respond_to?(:<=>) + cmp = right <=> left + return -1 * cmp unless cmp.nil? + end + + if left.is_a?(right.class) && left.respond_to?(:<=>) + cmp = right <=> left + return class_order(left, right) if cmp.nil? + + return cmp + end + + class_order(left, right) + end + + def class_order(left, right) + left.class.name <=> right.class.name # like Erlang + end + end + end + end + end +end diff --git a/lib/bundix/nix/serializer/hash_order.rb b/lib/bundix/nix/serializer/hash_order.rb new file mode 100644 index 0000000..4f50057 --- /dev/null +++ b/lib/bundix/nix/serializer/hash_order.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Bundix + module Nix + class Serializer + # A renfinement that changes {Hash} comparison to first sort entries via + # {Serializer.order}. + module HashOrder + refine Hash do + def <=>(other) + return unless other.is_a?(Hash) + + larray = to_a.sort { |l, r| Serializer.order(l, r) } + rarray = other.to_a.sort { |l, r| Serializer.order(l, r) } + larray <=> rarray + end + end + end + end + end +end diff --git a/lib/bundix/nix/template.rb b/lib/bundix/nix/template.rb index 696a461..fbc6fd3 100644 --- a/lib/bundix/nix/template.rb +++ b/lib/bundix/nix/template.rb @@ -24,7 +24,7 @@ def call(**template_vars) private def erb_template - ERB.new(path.read) + ERB.new(path.read, trim_mode: '<>') end def erb_context(**options) diff --git a/lib/bundix/platforms.rb b/lib/bundix/platforms.rb deleted file mode 100644 index 4f549c3..0000000 --- a/lib/bundix/platforms.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module Bundix - # Maps ruby versions to the engines they support. - class Platforms - PLATFORM_ENGINES = { - 'ruby' => [{ engine: 'ruby' }, { engine: 'rbx' }, { engine: 'maglev' }], - 'mri' => [{ engine: 'ruby' }, { engine: 'maglev' }], - 'rbx' => [{ engine: 'rbx' }], - 'jruby' => [{ engine: 'jruby' }], - 'mswin' => [{ engine: 'mswin' }], - 'mswin64' => [{ engine: 'mswin64' }], - 'mingw' => [{ engine: 'mingw' }], - 'truffleruby' => [{ engine: 'ruby' }], - 'x64_mingw' => [{ engine: 'mingw' }] - }.freeze - SUPPORTED_RUBY_VERSIONS = %w[1.8 1.9 2.0 2.1 2.2 2.3 2.4 2.5 2.6 2.7].freeze - - PLATFORM_VERSION_ENGINES = PLATFORM_ENGINES.flat_map do |name, list| - SUPPORTED_RUBY_VERSIONS.map do |version| - [ - "#{name}_#{version.sub(/[.]/, '')}", - list.map { |platform| platform.merge(version: version) } - ] - end - end.to_h.freeze - - attr_reader :supported - - def self.defaults - new(PLATFORM_ENGINES.merge(PLATFORM_VERSION_ENGINES)) - end - - def initialize(supported) - @supported = supported - end - - def [](key) - supported.fetch(key.to_s) - end - end -end diff --git a/lib/bundix/ruby_engines.rb b/lib/bundix/ruby_engines.rb new file mode 100644 index 0000000..cb7a4a8 --- /dev/null +++ b/lib/bundix/ruby_engines.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Bundix + # The nix function +bundlerEnv+ checks that each gem can be executed by the + # ruby interpreter being used. This is significant for gems that provide + # native extensions. + # + # +bundlerEnv+ expects the ruby interpreter's nix deriviation to export two + # attrs: +rubyEngine+ and +version+ to compare against. + # + # @see https://github.com/NixOS/nixpkgs/blob/48e4e2a1/pkgs/development/ruby-modules/bundled-common/functions.nix#L44-L51 + # @see https://guides.rubygems.org/gems-with-extensions/ + class RubyEngines + DEFAULT = [{ engine: 'ruby' }].freeze + + # TODO: Aside from jruby, how many of these are still in Euse? + # TODO: Are these the correct strings used gem platforms? + # @see +gem help platforms+ + PLATFORM_ENGINES = { + Gem::Platform::RUBY => DEFAULT + [{ engine: 'rbx' }, { engine: 'maglev' }], + 'mri' => DEFAULT + [{ engine: 'maglev' }], + 'rbx' => [{ engine: 'rbx' }], + 'java' => [{ engine: 'jruby' }], + 'jruby' => [{ engine: 'jruby' }], + 'mswin' => [{ engine: 'mswin' }], + 'mswin64' => [{ engine: 'mswin64' }], + 'mingw' => [{ engine: 'mingw' }], + 'x64_mingw' => [{ engine: 'mingw' }] + }.freeze + + SUPPORTED_RUBY_VERSIONS = %w[1.8 1.9 2.0 2.1 2.2 2.3 2.4 2.5 2.6 2.7].freeze + + PLATFORM_VERSION_ENGINES = PLATFORM_ENGINES.flat_map do |name, list| + SUPPORTED_RUBY_VERSIONS.map do |version| + [ + "#{name}_#{version.sub(/[.]/, '')}", + list.map { |platform| platform.merge(version: version) } + ] + end + end.to_h.freeze + + DEFAULT_SUPPORTED = PLATFORM_ENGINES.merge(PLATFORM_VERSION_ENGINES) + + attr_reader :supported + + def self.defaults + @defaults ||= new(DEFAULT_SUPPORTED) + end + + # @param supported [Hash>>] + def initialize(supported) + @supported = supported + end + + # @param key [String] + # @return [Array>] + def [](key) + supported.fetch(key.to_s, default) + end + + # @return [Array>] The default platform for gems that + # don't specify one. + def default + DEFAULT + end + + def inspect + return super unless supported.equal?(DEFAULT_SUPPORTED) + + "#<#{self.class.name}:#{object_id} defaults>" + end + end +end diff --git a/lib/bundix/source.rb b/lib/bundix/source.rb deleted file mode 100644 index 540fe99..0000000 --- a/lib/bundix/source.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -require 'json' - -module Bundix - # Represents a {Bundler::Source} and a means to calculate its SHA-256 hash - # (where applicable) and other build parameters. - # - # The nix function, +bundlerEnv+, uses +buildRubyGem+ to fetch and build the - # derivation representing a single gem dependency. This service generates the - # function arguments necessary for +buildRubyGem+ to build the given source. - # - # = Supported source types - # - # - {Bundler::Source::Git} - # - {Bundler::Source::Path} - # - {Bundler::Source::Rubygems} - # - # @see https://github.com/NixOS/nixpkgs/blob/6ff8c02/pkgs/development/ruby-modules/gem/default.nix - class Source - attr_reader :fetcher, :spec - - # @param spec [Bundler::Source] - # @param fetcher [Fetcher] - def initialize(spec, fetcher) - @spec = spec - @fetcher = fetcher - end - - # @return [Hash] The attributes neccessary for the nix function, - # +buildRubyGem+, to fetch and build {#spec}. - def convert - case spec.source - when Bundler::Source::Git then convert_git - when Bundler::Source::Path then convert_path - when Bundler::Source::Rubygems then convert_rubygems - else - raise "unknown bundler source: #{spec.inspect}" - end - end - - private - - def convert_git - hash = fetch_git_hash - puts "#{hash} => #{source_uri}" if $VERBOSE - - { type: 'git', - url: source_uri.to_s, - rev: source_revision, - sha256: hash, - fetchSubmodules: submodules? } - end - - def fetch_git_hash - output = fetcher.nix_prefetch_git(source_uri, source_revision, submodules: submodules?) - - # FIXME: this is a hack, we should separate $stdout/$stderr in the sh call - JSON.parse(output[/({[^}]+})\s*\z/m])['sha256'].tap do |hash| - raise "couldn't fetch hash for #{spec.full_name}" unless hash - end - end - - def source_revision - @source_revision ||= spec.source.options.fetch('revision') - end - - def source_uri - @source_uri ||= spec.source.options.fetch('uri') - end - - def submodules? - !spec.source.submodules.nil? - end - - def convert_path - { - type: 'path', - path: spec.source.path - } - end - - def convert_rubygems - remote, hash = fetch_remote_hashes - puts "#{hash} => #{spec.full_name}.gem" if $VERBOSE - - { type: 'gem', - remotes: (remote ? [remote] : remotes), - sha256: hash } - end - - def fetch_remote_hashes - hash = fetcher.fetch_local_hash(spec) - remote, hash = fetcher.fetch_remotes_hash(spec, remotes) unless hash - raise "couldn't fetch hash for #{spec.full_name}" unless hash - - [remote, hash] - end - - def remotes - @remotes ||= spec.source.remotes.map { |remote| remote.to_s.sub(%r{/+$}, '') } - end - end -end diff --git a/lib/bundix/system.rb b/lib/bundix/system.rb index 9e5f5c6..8f2abe2 100644 --- a/lib/bundix/system.rb +++ b/lib/bundix/system.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'json' require 'open3' module Bundix @@ -27,7 +28,7 @@ def sh(*args, env: ENV, &block) unless block_given? ? block.call(status, stdout) : status.success? puts "$ #{args.join(' ')}" if $VERBOSE puts stdout if $VERBOSE - raise "command execution failed: #{status}" + raise ProcessError, "command execution failed: #{status}" end stdout end @@ -36,18 +37,16 @@ def sh(*args, env: ENV, &block) # arguments, used by the nix function +buildRubyGem+ to build a gem found # at the given git +uri+. def nix_prefetch_git(uri, revision, submodules: false) - old_home = Dir.home - ENV['HOME'] = '/homeless-shelter' - - args = [] - args << '--url' << uri - args << '--rev' << revision - args << '--hash' << 'sha256' - args << '--fetch-submodules' if submodules - - sh(NIX_PREFETCH_GIT, *args) - ensure - ENV['HOME'] = old_home + temp_env('HOME' => '/homeless-shelter') do + json = + sh(NIX_PREFETCH_GIT, + '--quiet', + '--url', uri.to_s, + '--rev', revision, + '--hash', 'sha256', + *[('--fetch-submodules' if submodules)].compact) + JSON.parse(json) + end end # Executes {NIX_PREFETCH_URL} to calculate the SHA-256 hash of the local @@ -61,6 +60,10 @@ def nix_prefetch_url(url, file) ).force_encoding('UTF-8').strip end + def nix_hash_path(path) + sh(NIX, 'hash', 'path', '--type', 'sha256', '--base32', path.to_s).strip + end + # Executes {NIX} to convert the given nix file to JSON. # @param nix_file [#to_s] Path to a nix file. # @return [String] @@ -69,7 +72,7 @@ def nix_to_json(nix_file) end # Temporarily modify {ENV} for the duration of the given block. - def temp_env(**env) + def temp_env(env) prev_env = env.to_h { |k, _| [k, ENV.fetch(k, nil)] } env.each { |k, v| ENV[k] = (v.to_s if v) } yield diff --git a/nix/assumePlatform.nix b/nix/assumePlatform.nix new file mode 100644 index 0000000..d9e0f76 --- /dev/null +++ b/nix/assumePlatform.nix @@ -0,0 +1,15 @@ +{ defaultPlatform ? "ruby" +, ... +}: + +let + sysPlatforms = { + aarch64-darwin = "arm64-darwin"; + aarch64-linux = "arm64-linux"; + x86_64-darwin = "x86_64-darwin"; + x86_64-linux = "x86_64-linux"; + }; +in system: + if sysPlatforms ? ${system} + then sysPlatforms.${system} + else defaultPlatform diff --git a/nix/bundixEnv.nix b/nix/bundixEnv.nix new file mode 100644 index 0000000..d4acd16 --- /dev/null +++ b/nix/bundixEnv.nix @@ -0,0 +1,6 @@ +{ bundlerEnv +, callPackage +, ... +}@bundixEnvArgs: + +bundlerEnv ((callPackage ./. {}).toBundlerEnvArgs bundixEnvArgs) diff --git a/nix/bundlerFiles.nix b/nix/bundlerFiles.nix new file mode 100644 index 0000000..c8407cb --- /dev/null +++ b/nix/bundlerFiles.nix @@ -0,0 +1,22 @@ +{ +}: + + # TODO: Can this be imported? + # https://github.com/NixOS/nixpkgs/blob/48e4e2a1/pkgs/development/ruby-modules/bundled-common/functions.nix +{ gemfile ? null +, lockfile ? null +, gemset ? null +, gemdir ? null +, ... +}: { + inherit gemdir; + gemfile = + if gemfile == null then assert gemdir != null; gemdir + "/Gemfile" + else gemfile; + lockfile = + if lockfile == null then assert gemdir != null; gemdir + "/Gemfile.lock" + else lockfile; + gemset = + if gemset == null then assert gemdir != null; gemdir + "/gemset.nix" + else gemset; +} diff --git a/nix/default.nix b/nix/default.nix new file mode 100644 index 0000000..daa243b --- /dev/null +++ b/nix/default.nix @@ -0,0 +1,7 @@ +{ callPackage }: + +{ + bundlerFiles = callPackage ./bundlerFiles.nix {}; + extractBundixVersion = callPackage ./extractBundixVersion.nix {}; + toBundlerEnvArgs = callPackage ./toBundlerEnvArgs.nix {}; +} diff --git a/nix/derivation.nix b/nix/derivation.nix new file mode 100644 index 0000000..4cf293f --- /dev/null +++ b/nix/derivation.nix @@ -0,0 +1,40 @@ +{ stdenv +, lib +, src +, pname +, version +, gems +, runtimeInputs ? [] +, ... +}: +stdenv.mkDerivation { + inherit gems pname src version; + ruby = gems.wrappedRuby; + phases = "installPhase"; + installPhase = '' + mkdir -p $out/{bin,share/${pname}/bin} + cp -r $src/bin/* $out/share/${pname}/bin/ + + cat << EOF > "$out/bin/${pname}" + #!/bin/sh -e + export PATH="${lib.makeBinPath runtimeInputs}:$PATH" + exec $ruby/bin/ruby $out/share/${pname}/bin/${pname} "\$@" + EOF + chmod +x "$out/bin/${pname}" + ''; + meta = { + inherit version; + description = "Creates Nix packages from Gemfiles"; + longDescription = '' + This is a tool that converts Gemfile.lock files to nix expressions. + + The output is then usable by the bundixEnv derivation to list all the + dependencies of a ruby package. + ''; + homepage = "https://github.com/sangster/bundix"; + license = "MIT"; + maintainers = [ { name = "Jon Sangster"; email = "jon@ertt.ca"; + github = "sangster"; githubId = 996850; } ]; + platforms = lib.platforms.all; + }; +} diff --git a/nix/extractBundixVersion.nix b/nix/extractBundixVersion.nix new file mode 100644 index 0000000..9ea02ef --- /dev/null +++ b/nix/extractBundixVersion.nix @@ -0,0 +1,11 @@ +{ ... }: + +with builtins; +path: +let + pattern = ".*VERSION[[:space:]]*=[[:space:]]['\"]([^'\"]+)['\"].*"; + captures = match pattern (readFile path); + version-list = if isNull captures || length captures == 0 + then [upstream-package.version] + else captures; +in elemAt version-list 0 diff --git a/nix/toBundlerEnvArgs.nix b/nix/toBundlerEnvArgs.nix new file mode 100644 index 0000000..0437601 --- /dev/null +++ b/nix/toBundlerEnvArgs.nix @@ -0,0 +1,65 @@ +{ lib +, callPackage +, ... +}: + +# This function translates 'bundixEnv' arguments into those acceptable for +# 'nixpkgs#bundlerEnv'. +{ gemfile ? null +, lockfile ? null +, gemset ? null +, gemdir ? null +, groups ? null +, platform ? null +, system ? null # TODO +, ... +}@args: +let + inherit (lib) assertMsg; + + # Either the provided 'platform' or the one assumed from `system`. + envPlatform = + assert assertMsg (platform == null -> system != null) + "bundixEnv: either platform or system must be specified"; + if platform != null + then platform + else callPackage ./assumePlatform.nix {} system; + + gemFiles = (callPackage ./. {}).bundlerFiles args; + importedGemset = if builtins.typeOf gemFiles.gemset != "set" + then import gemFiles.gemset + else gemFiles.gemset; + + platformGemset = platform: + assert platform != null; + buildPlatformGemset {} platform importedGemset.dependencies; + + # Populate 'gemset' with 'deps' and all their transitive-dependencies, + # selecting from those gems that belong to the named `platform`. Will fallback + # to the ruby platform gem if there are not available for the named platform. + buildPlatformGemset = gemset: platform: deps: + if deps == [] + then gemset + else let + dep = builtins.head deps; + rest = builtins.tail deps; + gem = getGem platform dep; + newGemset = gemset // { ${dep} = gem; }; + newDeps = rest ++ (builtins.filter (d: !(newGemset ? ${d})) (gem.dependencies or [])); + in buildPlatformGemset newGemset platform newDeps; + + getGem = platform: name: + let + platformGem = plat: lib.attrByPath ["platforms" plat name] null importedGemset; + gem = platformGem platform; + in + if gem != null then gem else (platformGem "ruby"); + + selectedGroups = if groups != null && !(builtins.elem "default" groups) + then groups ++ ["default"] + else groups; +in args // { + inherit (gemFiles) gemfile lockfile; + gemset = platformGemset envPlatform; + groups = selectedGroups; +} diff --git a/shell.nix b/shell.nix deleted file mode 100644 index c4272fd..0000000 --- a/shell.nix +++ /dev/null @@ -1,44 +0,0 @@ -with (import {}); -with builtins; - -let - minitest = buildRubyGem { - inherit ruby; - gemName = "minitest"; - type = "gem"; - version = "5.10.1"; - source.sha256 = "1yk2m8sp0p5m1niawa3ncg157a4i0594cg7z91rzjxv963rzrwab"; - gemPath = []; - }; - - rake = buildRubyGem { - inherit ruby; - gemName = "rake"; - type = "gem"; - version = "12.0.0"; - source.sha256 = "01j8fc9bqjnrsxbppncai05h43315vmz9fwg28qdsgcjw9ck1d7n"; - gemPath = []; - }; - - srcWithout = rootPath: ignoredPaths: - let - ignoreStrings = map (path: toString path ) ignoredPaths; - in - filterSource (path: type: (all (i: i != path) ignoreStrings)) rootPath; -in - stdenv.mkDerivation { - name = "bundix"; - src = srcWithout ./. [ ./.git ./tmp ./result ]; - phases = "installPhase"; - installPhase = '' - mkdir -p $out - makeWrapper $src/bin/bundix $out/bin/bundix \ - --prefix PATH : "${nix.out}/bin" \ - --prefix PATH : "${nix-prefetch-git.out}/bin" \ - --set GEM_PATH "${bundler}/${bundler.ruby.gemPath}" - ''; - - nativeBuildInputs = [makeWrapper]; - - buildInputs = [bundler ruby minitest rake nix-prefetch-scripts]; -} diff --git a/spec/integration/gemfile_conversion_spec.rb b/spec/integration/gemfile_conversion_spec.rb index 5118d60..79b8e9e 100644 --- a/spec/integration/gemfile_conversion_spec.rb +++ b/spec/integration/gemfile_conversion_spec.rb @@ -1,30 +1,35 @@ # frozen_string_literal: true RSpec.describe 'Converting Gemfiles to gemset.nix' do - shared_examples 'a gemset with' do |gems| - gems.each do |gem, expected_version| - it "gem '#{gem}', '#{expected_version}'" do - expect(gemset.dig(gem, :version)).to eq expected_version + shared_examples 'a gemset with' do |gem_platforms| + gem_platforms.each do |platform, gems| + gems.each do |gem, expected_version| + it "gem '#{gem}', '#{expected_version}', platform: '#{platform}'" do + expect(gemset.dig(:platforms, platform, gem, :version)).to eq expected_version + end end end end describe 'extracting dependencies from Gemfile/Gemfile.lock' do - include_context 'with gemdir', spec_data_dir.join('bundler-audit') + include_context 'with gemset', spec_data_dir.join('bundler-audit') - it_behaves_like 'a gemset with', 'bundler-audit' => '0.5.0', - 'thor' => '0.19.4' + it_behaves_like 'a gemset with', 'ruby' => { 'bundler-audit' => '0.5.0', + 'thor' => '0.19.4' } end describe 'extracting dependencies from .gemspec' do - include_context 'with gemdir', spec_data_dir.join('gemspec') + include_context 'with gemset', spec_data_dir.join('gemspec') - it_behaves_like 'a gemset with', 'example' => '0.1.0', - 'rubocop' => '1.45.1' + it_behaves_like 'a gemset with', 'ruby' => { 'example' => '0.1.0', + 'rubocop' => '1.45.1' } end describe 'trying to extract dependencies when the .gemspec is missing' do - include_context 'with gemdir', spec_data_dir.join('gemspec-missing') + subject(:gemset) { gemset_builder.call } + + include_context 'with bundle', spec_data_dir.join('gemspec-missing') + let(:gemset_builder) { Bundix::Gemset::Builder.new(definition) } it { expect { gemset }.to raise_error Bundler::Dsl::DSLError } end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e299ac7..15ad459 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,8 +1,23 @@ # frozen_string_literal: true +unless ENV.key?('SKIP_COVERAGE') + require 'simplecov' + + # SimpleCov generates files with odd permissions + Pathname(__dir__).join('../coverage/assets').glob('**/*') + .map { _1.directory? ? _1.chmod(0o755) : _1.chmod(0o644) } + + SimpleCov.start do + enable_coverage :branch + primary_coverage :branch + add_filter %r{^/spec/} + end +end + require 'bundix' require 'pry-byebug' -Dir[Pathname.new(__dir__).join('support', '**', '*.rb')].sort.each { |f| require f } + +Pathname.new(__dir__).glob('support/**/*.rb').map { require _1.realpath } RSpec.configure do |config| config.disable_monkey_patching! diff --git a/spec/support/data.rb b/spec/support/data.rb index 4902a29..fd4dc55 100644 --- a/spec/support/data.rb +++ b/spec/support/data.rb @@ -2,7 +2,7 @@ module SpecDataDirHelpers def spec_data_dir - @spec_data_dir ||= Pathname.new(File.expand_path('./data', __dir__)) + @spec_data_dir ||= Pathname(__dir__).join('./data').expand_path.freeze end end diff --git a/spec/support/disable_network.rb b/spec/support/disable_network.rb new file mode 100644 index 0000000..6698890 --- /dev/null +++ b/spec/support/disable_network.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Disable network access so tests don't accidentally clone remote git repos or +# hammer a RubyGems API. +require 'webmock/rspec' +WebMock.disable_net_connect!(allow_localhost: true) diff --git a/spec/support/shared_contexts/with_bundle.rb b/spec/support/shared_contexts/with_bundle.rb new file mode 100644 index 0000000..4cf009c --- /dev/null +++ b/spec/support/shared_contexts/with_bundle.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.shared_context 'with bundle' do |gemdir| + let(:gemfile) { Pathname(gemdir).join('Gemfile') } + let(:lockfile) { Pathname(gemdir).join('Gemfile.lock') } + let(:definition) do + Bundler::Definition.build(gemfile.to_s, lockfile.to_s, false) + end +end diff --git a/spec/support/shared_contexts/with_dir.rb b/spec/support/shared_contexts/with_dir.rb index a2c915b..48f9529 100644 --- a/spec/support/shared_contexts/with_dir.rb +++ b/spec/support/shared_contexts/with_dir.rb @@ -9,7 +9,7 @@ let(:gemfile_path) { File.join(tmpdir, 'Gemfile') } let(:bundler_credential) { bundler_credential } let(:bundler_dir) { "#{tmpdir}/.bundle" } - let(:bundler_settings) { Bundix::BundlerProxy::Settings.new(bundler_dir) } + let(:bundler_settings) { Bundix::BundlerSettings.new(bundler_dir) } def write_credential FileUtils.mkdir(bundler_dir) diff --git a/spec/support/shared_contexts/with_dir_and_server.rb b/spec/support/shared_contexts/with_dir_and_server.rb deleted file mode 100644 index eab5f11..0000000 --- a/spec/support/shared_contexts/with_dir_and_server.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_context 'with dir and server' do |bundler_credential: nil, - returning_content: 'ok'| - include_context 'with dir', bundler_credential: bundler_credential - include_context 'with server', returning_content: returning_content - - let(:uri) { "http://127.0.0.1:#{port_num}/test" } -end diff --git a/spec/support/shared_contexts/with_gemdir.rb b/spec/support/shared_contexts/with_gemdir.rb deleted file mode 100644 index 785716d..0000000 --- a/spec/support/shared_contexts/with_gemdir.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_context 'with gemdir' do |dir| - include_context 'with gemset', - gemfile: Pathname(dir).join('Gemfile'), - lockfile: Pathname(dir).join('Gemfile.lock') -end diff --git a/spec/support/shared_contexts/with_gemset.rb b/spec/support/shared_contexts/with_gemset.rb index a117743..9f1dec1 100644 --- a/spec/support/shared_contexts/with_gemset.rb +++ b/spec/support/shared_contexts/with_gemset.rb @@ -1,42 +1,38 @@ # frozen_string_literal: true -RSpec.shared_context 'with gemset' do |options| - let :gemset_options do - { deps: false, lockfile: '', gemset: '' }.merge(options) +RSpec.shared_context 'with gemset' do |gemdir| + include_context 'with bundle', gemdir + + let(:gemset_builder) { Bundix::Gemset::Builder.new(definition) } + let(:gemset) { gemset_builder.call } + let :gemset_sources do + gemset_builder.definition + .locked_gems + .specs + .map { Bundix::Nix::BundlerSource.build(_1) } end - let(:converter) do - Bundix::Converter.new(fetcher: PrefetchStub.new, **gemset_options) + let :gemset_platforms do + Hash + .new { |hash, key| hash[key] = {} } + .tap do |platforms| + gemset_builder.definition.locked_gems.specs.each do |spec| + platforms[spec.platform.to_s][spec.name] = { + dependencies: spec.dependencies.map(&:name), + groups: ['default'], + source: Bundix::Nix::BundlerSource.build(spec).to_nix, + version: spec.version.to_s + } + end + end end - let(:gemset) { converter.call } - around do |test| - Bundler.instance_variable_set(:@root, spec_data_dir) - - old_gemfile = ENV.fetch('BUNDLE_GEMFILE', nil) - ENV['BUNDLE_GEMFILE'] = gemset_options[:gemfile].to_s - - test.call - ensure - ENV['BUNDLE_GEMFILE'] = old_gemfile if old_gemfile - Bundler.reset! - end -end - -class PrefetchStub - def nix_prefetch_url(*_args) - 'nix_prefetch_url_hash' - end - - def nix_prefetch_git(_uri, _revision) - '{"sha256": "nix_prefetch_git_hash"}' - end - - def fetch_local_hash(_spec) - # Example hash taken from `man nix-hash`. + let :sha256 do '5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03' end - def fetch_remotes_hash(_spec, _remotes) - 'fetch_remotes_hash_hash' + before do + allow(Bundix::Nix::BundlerSource::Git).to receive(:sha256).and_return(sha256) + allow(Bundix::Nix::BundlerSource::Rubygems).to receive(:sha256).and_return(sha256) + allow(gemset_builder).to receive(:platforms) { gemset_platforms } end end diff --git a/spec/support/shared_contexts/with_server.rb b/spec/support/shared_contexts/with_server.rb deleted file mode 100644 index ba604bf..0000000 --- a/spec/support/shared_contexts/with_server.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -# Runs a temporary HTTP server which responds to a single request. -RSpec.shared_context 'with server' do |returning_content:| - let(:request) { String.new } - let(:server) { TCPServer.new('127.0.0.1', 0) } - let(:port_num) { server.addr[1] } - - def single_request(server, returning_content) - conn = server.accept - until (line = conn.readline) == "\r\n" - request << line - end - - conn.write(plain_response(returning_content)) - conn.close - end - - def plain_response(body) - [ - 'HTTP/1.1 200 OK', - "Content-Length: #{body.length}", - 'Content-Type: text/plain', - '', - body - ].join("\r\n") - end - - around do |test| - Thread.abort_on_exception = true - thr = Thread.new { single_request(server, returning_content) } - - test.call - ensure - server.close - thr.join - end -end diff --git a/spec/unit/bundix/bundler_proxy/settings_spec.rb b/spec/unit/bundix/bundler_settings_spec.rb similarity index 96% rename from spec/unit/bundix/bundler_proxy/settings_spec.rb rename to spec/unit/bundix/bundler_settings_spec.rb index b728f38..d616f32 100644 --- a/spec/unit/bundix/bundler_proxy/settings_spec.rb +++ b/spec/unit/bundix/bundler_settings_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Bundix::BundlerProxy::Settings do +RSpec.describe Bundix::BundlerSettings do subject(:settings) { described_class.new(*args, **kwargs) } let(:args) { [] } diff --git a/spec/unit/bundix/command_line/options_spec.rb b/spec/unit/bundix/command_line/options_spec.rb new file mode 100644 index 0000000..0b1d6ab --- /dev/null +++ b/spec/unit/bundix/command_line/options_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'tempfile' + +RSpec.describe Bundix::CommandLine::Options do + subject(:opt_parser) { described_class.new } + + let(:defaults) { Bundix::DEFAULT_OPTIONS } + + describe '#parse' do + subject(:options) { opt_parser.parse(argv) && opt_parser.options } + + context 'with no CLI flags' do + let(:argv) { [] } + + it 'returns the default options' do + expect(options).to eq defaults + end + end + + describe 'Input/output options' do + context 'with --gemfile' do + let(:argv) { %w[--gemfile=../some/relative/Gemfile] } + + it { expect(options[:gemfile]).to be_a(Pathname) } + it { expect(options[:gemfile]).to be_absolute } + end + + context 'with --lockfile' do + let(:argv) { %w[--lockfile=../some/relative/Gemfile.lock] } + + it { expect(options[:lockfile]).to be_a(Pathname) } + it { expect(options[:lockfile]).to be_absolute } + end + + context 'with --gemset' do + let(:argv) { %w[--gemset=../some/relative/gemset.nix] } + + it { expect(options[:gemset]).to be_a(Pathname) } + it { expect(options[:gemset]).to be_absolute } + end + + context 'with --bundler-env' do + context 'with no value' do + let(:argv) { %w[--bundler-env] } + + it { expect(options[:bundler_env_format]).to eq defaults[:ruby_platform] } + end + + context 'with some value' do + let(:argv) { %w[--bundler-env=some-value] } + + it { expect(options[:bundler_env_format]).to eq 'some-value' } + end + end + + context 'with --update' do + context 'with no value' do + let(:argv) { %w[--update] } + + it { expect(options[:update]).to be true } + end + + context 'with a list of gems' do + let(:argv) { %w[--update=1,2,3] } + + it do + expect(options[:update]).to contain_exactly '1', '2', '3' + end + end + end + + context 'with --bundle-cache' do + context 'with no value' do + let(:argv) { %w[--bundle-cache] } + + it { expect(options[:cache]).to be_a(Pathname) } + it { expect(options[:cache]).to be_absolute } + it { expect(options[:cache].to_s).to end_with defaults[:bundle_cache_path][2..] } + end + + context 'with some value' do + let(:argv) { %w[--bundle-cache=path] } + + it { expect(options[:cache]).to be_a(Pathname) } + it { expect(options[:cache]).to be_absolute } + it { expect(options[:cache].to_s).to end_with '/path' } + end + end + + context 'with --init' do + context 'with no value' do + let(:argv) { %w[--init] } + + it { expect(options[:init]).to eq defaults[:ruby_derivation] } + end + + context 'with some value' do + let(:argv) { %w[--init=jruby] } + + it { expect(options[:init]).to eq 'jruby' } + end + end + + context 'with --init-template' do + context 'with an unknown value' do + let(:argv) { %w[--init-template=unknown_value] } + + it { expect { options }.to raise_error OptionParser::InvalidArgument } + end + + context 'with a named template' do + let(:argv) { ["--init-template=#{template.first}"] } + let(:template) { Bundix::FLAKE_NIX_TEMPLATES.entries.last } + + it { expect(options[:init_template]).to eq template.last } + end + + context 'with a filename' do + let(:argv) { ["--init-template=#{template.path}"] } + let(:template) { Tempfile.new(%w[template- .nix.erb]) } + + around do |test| + test.call + ensure + template.unlink + end + + it { expect(options[:init_template]).to eq Pathname(template.path) } + end + end + end + end +end diff --git a/spec/unit/bundix/converter_spec.rb b/spec/unit/bundix/converter_spec.rb deleted file mode 100644 index 78a5f56..0000000 --- a/spec/unit/bundix/converter_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Bundix::Converter do - subject(:converter) { described_class.new(**options) } - - describe '#call' do - subject(:gemset) { converter.call } - - context 'when using the "./bundler-audit/" test data' do - let(:options) { { gemfile: gemfile, lockfile: lockfile } } - let(:gemfile) { 'spec/support/data/bundler-audit/Gemfile' } - let(:lockfile) { 'spec/support/data/bundler-audit/Gemfile.lock' } - - let :expected_bundler_audit do - { - version: '0.5.0', - source: { - type: 'gem', - remotes: ['https://rubygems.org'], - sha256: '1gr7k6m9fda7m66irxzydm8v9xbmlryjj65cagwm1zyi5f317srb' - }, - platforms: [], - groups: [:default], - 'dependencies' => ['thor'] - } - end - - let :expected_thor do - { - version: '0.19.4', - source: { - type: 'gem', - remotes: ['https://rubygems.org'], - sha256: '01n5dv9kql60m6a00zc0r66jvaxx98qhdny3klyj0p3w34pad2ns' - }, - platforms: [], - groups: [:default] - } - end - - it 'generates the expected gemset.nix contents' do - expect(gemset).to eq 'bundler-audit' => expected_bundler_audit, - 'thor' => expected_thor - end - end - end - - describe '#parse_gemset' do - subject(:gemset) { converter.parse_gemset } - - context 'when using the "./path with space/" test data' do - let(:options) { { gemset: gemset_file } } - let(:gemset_file) { 'spec/support/data/path with space/gemset.nix' } - - it { expect(gemset).to eq 'a' => 1 } - end - end -end diff --git a/spec/unit/bundix/fetcher_spec.rb b/spec/unit/bundix/fetcher_spec.rb deleted file mode 100644 index 25bc53c..0000000 --- a/spec/unit/bundix/fetcher_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require 'base64' - -RSpec.describe Bundix::Fetcher do - subject(:cmd) { described_class.new(bundler_settings: bundler_settings) } - - let(:bundler_settings) { nil } - - describe '#download' do - let(:file) { 'some-file' } - - around do |test| - old_verbose = $VERBOSE - $VERBOSE = true - test.call - $VERBOSE = old_verbose - end - - context 'without bundler credentials' do - include_context 'with dir and server' - - it 'outputting logging only to STDERR' do - expect { cmd.download(file, uri) }.to output('').to_stdout.and( - output(/Downloading #{file} from http:.*/).to_stderr - ) - end - - it 'does not send an Authorization HTTP header' do - cmd.download(file, uri) - expect(request).not_to include 'Authorization:' - end - end - - context 'with bundler credentials' do - include_context 'with dir and server', bundler_credential: 'secret' - - let(:encoded_secret) { Base64.encode64('secret:').chomp } - - it 'authorizes using the bundler credentials' do - cmd.download(file, uri) - expect(request).to include "Authorization: Basic #{encoded_secret}" - end - end - end -end diff --git a/spec/unit/bundix/gemset/builder_spec.rb b/spec/unit/bundix/gemset/builder_spec.rb new file mode 100644 index 0000000..2a77e9f --- /dev/null +++ b/spec/unit/bundix/gemset/builder_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +RSpec.describe Bundix::Gemset::Builder do + subject(:builder) { described_class.new(definition, **options) } + + let(:definition) { Bundler::Definition.build(gemfile, lockfile, false) } + let(:options) { {} } + + describe '.build' do + subject(:builder) { described_class.build(definition, **options) } + + let(:definition) { instance_double(Bundler::Definition, groups: []) } + + context 'when no platform is provided' do + it { expect(builder).to be_a described_class } + end + + context 'when a platform is provided' do + let(:options) { { bundler_env_format: 'some-platform' } } + + it { expect(builder).to be_a Bundix::Gemset::EnvFormatBuilder } + end + end + + describe '#call' do + subject(:gemset) { builder.call } + + let(:mock_api) { instance_spy(Bundix::Gemset::Builder::RubyGemsApi) } + + before do + allow(Bundix::Gemset::Builder::RubyGemsApi).to receive(:new).and_return(mock_api) + allow(mock_api).to receive(:call).and_return(mock_rubygems_result) + end + + context 'when using the "./bundler-audit/" test data' do + include_context 'with bundle', 'spec/support/data/bundler-audit/' + + let(:mock_rubygems_result) do + rubygems = builder.lockfile_parser.sources.first + + [ + Bundler::EndpointSpecification.new( + 'bundler-audit', '0.5.0', 'ruby', nil, + { 'bundler' => ['~> 1.2'], 'thor' => ['~> 0.18'] }, + checksum: ['2beb13862bd1ff50f953ac18297da675f5b4516dfef71c8da9473597aa9927bf'], + ruby: '>= 1.9.3', rubygems: '>= 1.8.0' + ), + Bundler::EndpointSpecification.new( + 'thor', '0.19.4', 'ruby', nil, {}, + checksum: ['da8aa62e197c5c203d9dc3db06314abdab2d8dc9807d0094a9c0503cd36ec506'], + ruby: '>= 1.8.7', rubygems: '>= 1.3.5' + ) + ].tap { _1.each { |spec| spec.source = rubygems } } + end + + let :ruby_platform_gems do + { + 'bundler-audit' => { + version: '0.5.0', + source: { + type: 'gem', + remotes: ['https://rubygems.org'], + sha256: '1gr7k6m9fda7m66irxzydm8v9xbmlryjj65cagwm1zyi5f317srb' + }, + dependencies: ['thor'] + }, + 'thor' => { + version: '0.19.4', + source: { + type: 'gem', + remotes: ['https://rubygems.org'], + sha256: '01n5dv9kql60m6a00zc0r66jvaxx98qhdny3klyj0p3w34pad2ns' + } + } + } + end + + it 'generates the expected gemset.nix contents' do + expect(gemset).to eq(dependencies: %w[bundler-audit], + platforms: { 'ruby' => ruby_platform_gems }) + end + + it 'queries the RubyGems API for all its dependencies' do + gemset + expect(mock_api).to have_received(:call).with(%w[bundler-audit thor]) + end + end + end +end diff --git a/spec/unit/bundix/nix/bundler_source_spec.rb b/spec/unit/bundix/nix/bundler_source_spec.rb new file mode 100644 index 0000000..0912e81 --- /dev/null +++ b/spec/unit/bundix/nix/bundler_source_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +RSpec.describe Bundix::Nix::BundlerSource do + describe '.build' do + subject(:source) { described_class.build(spec) } + + let(:spec) { Struct.new(:source).new(spec_source) } + + context 'with a Bundler::Source::Git' do + let(:spec_source) { Bundler::Source::Git.new({}) } + + it { expect(source).to be_a described_class::Git } + end + + context 'with a Bundler::Source::Path' do + let(:spec_source) { Bundler::Source::Path.new({}) } + + it { expect(source).to be_a described_class::Path } + end + + context 'with a Bundler::Source::Rubygems' do + let(:spec_source) { Bundler::Source::Rubygems.new({}) } + + it { expect(source).to be_a described_class::Rubygems } + end + + context 'with an unexpected source' do + let(:spec_source) { Object.new } + + it { expect { source }.to raise_error ArgumentError } + end + end +end diff --git a/spec/unit/bundix/nix/ruby_to_nix_spec.rb b/spec/unit/bundix/nix/ruby_to_nix_spec.rb new file mode 100644 index 0000000..aef4e3a --- /dev/null +++ b/spec/unit/bundix/nix/ruby_to_nix_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.describe Bundix::Nix::RubyToNix do + subject :ruby_to_nix do + described_class.new(dest, serializer_class: serializer_class) + end + + let :serializer_class do + Struct.new('Serializer', :serialized_string) do + def call + serialized_string + end + end + end + + describe '#call' do + subject(:body) { ruby_to_nix.call(serialized_string) && dest.read } + + let(:dest) { Tempfile.new(%w[ruby_to_nix- .nix]) } + let(:serialized_string) { '{ serialized = "nix code"; }' } + + around do |test| + test.call + ensure + dest.unlink + end + + it { expect(body).to start_with serialized_string } + it { expect(body).to end_with "\n" } + end +end diff --git a/spec/unit/bundix/nix/serializer_spec.rb b/spec/unit/bundix/nix/serializer_spec.rb index 7fcca40..19cdd18 100644 --- a/spec/unit/bundix/nix/serializer_spec.rb +++ b/spec/unit/bundix/nix/serializer_spec.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true RSpec.describe Bundix::Nix::Serializer do - subject(:serializer) { described_class.new(ruby_object) } + subject(:serializer) do + described_class.new(ruby_object, compact_width: compact_width) + end describe '#call' do subject(:nix_code) { serializer.call } @@ -9,42 +11,89 @@ context 'with an Array of Hashes' do let(:ruby_object) { [{ a: 'x', b: '7' }, { a: 'y', c: '8' }] } - it do - expect(nix_code).to eq <<~NIX.chomp - [{ - a = "x"; - b = "7"; - } { - a = "y"; - c = "8"; - }] - NIX + context 'with no compact_width' do + let(:compact_width) { nil } + + it do + expect(nix_code).to eq <<~NIX.chomp + [ + { + a = "x"; + b = "7"; + } + { + a = "y"; + c = "8"; + } + ] + NIX + end + end + + context 'with default compact_width' do + let(:compact_width) { described_class::DEFAULT_WIDTH } + + it do + expect(nix_code).to eq '[{ a = "x"; b = "7"; } { a = "y"; c = "8"; }]' + end end end context 'with an Array of Strings' do let(:ruby_object) { %w[a 7 string] } - it { expect(nix_code).to eq '["7" "a" "string"]' } + context 'with no compact_width' do + let(:compact_width) { nil } + + it do + expect(nix_code).to eq <<~NIX.chomp + [ + "7" + "a" + "string" + ] + NIX + end + end + + context 'with default compact_width' do + let(:compact_width) { described_class::DEFAULT_WIDTH } + + it { expect(nix_code).to eq '["7" "a" "string"]' } + end end context 'with a Hash' do let(:ruby_object) { { a: 'x', b: '7' } } - it do - expect(nix_code).to eq <<~NIX.chomp - { - a = "x"; - b = "7"; - } - NIX + context 'with no compact_width' do + let(:compact_width) { nil } + + it do + expect(nix_code).to eq <<~NIX.chomp + { + a = "x"; + b = "7"; + } + NIX + end + end + + context 'with default compact_width' do + let(:compact_width) { described_class::DEFAULT_WIDTH } + + it { expect(nix_code).to eq '{ a = "x"; b = "7"; }' } end end context 'with a Pathname' do let(:ruby_object) { Pathname.new('.') } - it { expect(nix_code).to eq './.' } + context 'with no compact_width' do + let(:compact_width) { nil } + + it { expect(nix_code).to eq './.' } + end end end end diff --git a/spec/unit/bundix/nix/template_spec.rb b/spec/unit/bundix/nix/template_spec.rb index 8a75c6e..9c53905 100644 --- a/spec/unit/bundix/nix/template_spec.rb +++ b/spec/unit/bundix/nix/template_spec.rb @@ -16,28 +16,33 @@ } end - context 'with the default shell.nix template' do + context 'with the default flake.nix template' do let :template_path do - Bundix::CommandLineOptions::FLAKE_NIX_TEMPLATES['default'] + Bundix::FLAKE_NIX_TEMPLATES['default'] end - it 'renders bundlerEnv nix code' do + it 'renders flake.nix' do expect(nix_code).to eq <<~EXPECTED_NIX { description = "test-project"; inputs = { nixpkgs.url = github:NixOS/nixpkgs; + bundix.url = github:sangster/bundix; }; - outputs = { self, nixpkgs }: + outputs = { self, nixpkgs, bundix }: let - name = "test-project"; + pname = "test-project"; system = "x86_64-linux"; version = "0.0.1"; - pkgs = import nixpkgs { inherit system; }; + pkgs = import nixpkgs { + inherit system; + overlays = [bundix.overlays.default]; + }; - gems = pkgs.bundlerEnv { + gems = pkgs.bundixEnv { + inherit system; name = "${pname}-${version}-bundler-env"; ruby = pkgs.test-ruby; gemfile = ./test-gemfile; @@ -46,21 +51,35 @@ }; in { packages.${system} = { - default = stdenv.mkDerivation { - inherit pname version; - buildInputs = [ - gems - gems.ruby - ]; + # Example package: + default = pkgs.stdenv.mkDerivation { + inherit gems pname version; + ruby = gems.wrappedRuby; + phases = "installPhase"; + installPhase = '' + mkdir -p $out/bin + cat << EOF > "$out/bin/${pname}" + #!/bin/sh -e + exec $ruby/bin/ruby << RUBY + require 'bundler' + puts "Bundled rubygems:" + Bundler.setup.gems.map(&:name).sort.each do |gem| + puts " - \#{gem}" + end + RUBY + EOF + chmod +x "$out/bin/${pname}" + ''; }; - gems = gems; + bundled-gems = gems; + }; + + apps.${system} = { + bundix = { type = "app"; program = "${pkgs.bundix}/bin/bundix"; }; }; devShell.${system} = pkgs.mkShell { - buildInputs = [ - gems - gems.ruby - ]; + buildInputs = [gems gems.wrappedRuby]; }; }; } diff --git a/template/flake-with-utils.nix.erb b/template/flake-with-utils.nix.erb deleted file mode 100644 index fdff62b..0000000 --- a/template/flake-with-utils.nix.erb +++ /dev/null @@ -1,43 +0,0 @@ -{ - description = "<%= project %>"; - - inputs = { - nixpkgs.url = github:NixOS/nixpkgs; - flake-utils.url = github:numtide/flake-utils; - }; - - outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem (system: - let - name = "<%= project %>"; - version = "0.0.1"; - pkgs = import nixpkgs { inherit system; }; - - gems = pkgs.bundlerEnv { - name = "${pname}-${version}-bundler-env"; - ruby = pkgs.<%= ruby %>; - gemfile = <%= gemfile_path %>; - lockfile = <%= lockfile_path %>; - gemset = <%= gemset_path %>; - }; - in { - packages = { - default = stdenv.mkDerivation { - inherit pname version; - buildInputs = [ - gems - gems.ruby - ]; - }; - gems = gems; - }; - - devShell = pkgs.mkShell { - buildInputs = [ - gems - gems.ruby - ]; - }; - } - ); -} diff --git a/template/flake.nix.erb b/template/flake.nix.erb deleted file mode 100644 index c85b7df..0000000 --- a/template/flake.nix.erb +++ /dev/null @@ -1,41 +0,0 @@ -{ - description = "<%= project %>"; - - inputs = { - nixpkgs.url = github:NixOS/nixpkgs; - }; - - outputs = { self, nixpkgs }: - let - name = "<%= project %>"; - system = "x86_64-linux"; - version = "0.0.1"; - pkgs = import nixpkgs { inherit system; }; - - gems = pkgs.bundlerEnv { - name = "${pname}-${version}-bundler-env"; - ruby = pkgs.<%= ruby %>; - gemfile = <%= gemfile_path %>; - lockfile = <%= lockfile_path %>; - gemset = <%= gemset_path %>; - }; - in { - packages.${system} = { - default = stdenv.mkDerivation { - inherit pname version; - buildInputs = [ - gems - gems.ruby - ]; - }; - gems = gems; - }; - - devShell.${system} = pkgs.mkShell { - buildInputs = [ - gems - gems.ruby - ]; - }; - }; -} diff --git a/template/nixer/list.erb b/template/nixer/list.erb deleted file mode 100644 index 586b1d5..0000000 --- a/template/nixer/list.erb +++ /dev/null @@ -1 +0,0 @@ -[<% obj.sort { |l,r| order(l,r) }.each_with_index do |o, n| %><%if n > 0 %> <% end %><%= sub(o) %><% end %>] diff --git a/templates/flake-nix/default.nix.erb b/templates/flake-nix/default.nix.erb new file mode 100644 index 0000000..5927801 --- /dev/null +++ b/templates/flake-nix/default.nix.erb @@ -0,0 +1,70 @@ +{ + description = "<%= project %>"; + + inputs = { + nixpkgs.url = github:NixOS/nixpkgs; + bundix.url = github:sangster/bundix; + }; + + outputs = { self, nixpkgs, bundix }: + let + pname = "<%= project %>"; + system = "x86_64-linux"; + version = "0.0.1"; + pkgs = import nixpkgs { + inherit system; + overlays = [bundix.overlays.default]; + }; + + gems = pkgs.bundixEnv { + inherit system; + name = "${pname}-${version}-gems"; + groups = ["default"]; + ruby = pkgs.<%= ruby %>; +<% if gemdir %> + gemdir = <%= gemdir_path %>; +<% else %> + gemfile = <%= gemfile_path %>; + lockfile = <%= lockfile_path %>; + gemset = <%= gemset_path %>; +<% end %> + }; + in { + packages.${system} = { + # Example package: + default = pkgs.stdenv.mkDerivation { + inherit gems pname version; + ruby = gems.wrappedRuby; + phases = "installPhase"; + installPhase = '' + mkdir -p $out/bin + cat << EOF > "$out/bin/${pname}" + #!/bin/sh + exec $ruby/bin/ruby << RUBY + require 'bundler' + Bundler.setup(:default) + puts "Loaded gems:" + Gem.loaded_specs.each_key { |gem| puts " - #{gem}" } + RUBY + EOF + chmod +x "$out/bin/${pname}" + ''; + }; + bundled-gems = gems; + }; + + apps.${system} = { + bundix = { type = "app"; program = "${pkgs.bundix}/bin/bundix"; }; + }; + + devShell.${system} = + let + dev-gems = with pkgs.bundix; gems.override { + name = "${pname}-${version}-development-gems"; + groups = ["development"]; + }; + in pkgs.mkShell { + buildInputs = with dev-gems; [basicEnv wrappedRuby pkgs.bundix]; + }; + }; +} diff --git a/templates/flake-nix/flake-utils.nix.erb b/templates/flake-nix/flake-utils.nix.erb new file mode 100644 index 0000000..b4dccf6 --- /dev/null +++ b/templates/flake-nix/flake-utils.nix.erb @@ -0,0 +1,70 @@ +{ + description = "<%= project %>"; + + inputs = { + nixpkgs.url = github:NixOS/nixpkgs; + flake-utils.url = github:numtide/flake-utils; + bundix.url = github:sangster/bundix; + }; + + outputs = { self, nixpkgs, flake-utils, bundix }: + flake-utils.lib.eachDefaultSystem (system: + let + pname = "<%= project %>"; + version = "0.0.1"; + pkgs = import nixpkgs { + inherit system; + overlays = [bundix.overlays.default]; + }; + + gems = pkgs.bundixEnv { + inherit system; + name = "${pname}-${version}-bundler-env"; + groups = ["default"]; + ruby = pkgs.<%= ruby %>; +<% if gemdir %> + gemdir = <%= gemdir_path %>; +<% else %> + gemfile = <%= gemfile_path %>; + lockfile = <%= lockfile_path %>; + gemset = <%= gemset_path %>; +<% end %> + }; + in { + packages = { + # Example package: + default = pkgs.stdenv.mkDerivation { + inherit gems pname version; + ruby = gems.wrappedRuby; + phases = "installPhase"; + installPhase = '' + mkdir -p $out/bin + cat << EOF > "$out/bin/${pname}" + #!/bin/sh + exec $ruby/bin/ruby << RUBY + require 'bundler' + Bundler.setup(:default) + puts "Loaded gems:" + Gem.loaded_specs.each_key { |gem| puts " - #{gem}" } + RUBY + EOF + chmod +x "$out/bin/${pname}" + ''; + }; + bundled-gems = gems; + }; + + apps.bundix = flake-utils.lib.mkApp { drv = pkgs.bundix; }; + + devShell = + let + dev-gems = with pkgs.bundix; gems.override { + name = "${pname}-${version}-development-gems"; + groups = ["development"]; + }; + in pkgs.mkShell { + buildInputs = with dev-gems; [basicEnv wrappedRuby pkgs.bundix]; + }; + } + ); +} diff --git a/templates/serializer/list.erb b/templates/serializer/list.erb new file mode 100644 index 0000000..16486d0 --- /dev/null +++ b/templates/serializer/list.erb @@ -0,0 +1,3 @@ +[ +<% obj.sort { |l,r| order(l,r) }.each_with_index do |o, n| %><%= indent %><%= sub(o, 2) %> +<% end %><%= outdent %>] diff --git a/template/nixer/set.erb b/templates/serializer/set.erb similarity index 100% rename from template/nixer/set.erb rename to templates/serializer/set.erb