hacking_the_helix_flake

Understanding the Helix Flake and Modifying its Behavior
As we’ve seen from previous examples, the helix editor repository includes a few
.nix
files including a flake.nix
. Their flake uses a lot of idiomatic Nix
code and advanced features. First I will break down their flake.nix
and
default.nix
to understand why they do certain things. And finally, we will
change the build to “debug” mode demonstrating how easily you can modify the
behavior of a package defined within a Nix flake without changing the original
source code or the upstream flake directly.
- Let’s clone the Helix repository:
git clone https://github.com/helix-editor/helix.git
cd helix
- Enter the Development Shell:
The Helix project’s flake.nix
includes a devShells.default
output, specifically
designed for development.
nix develop
- You’re now in a fully configured development environment:
- When you run
nix develop
, Nix builds and drops you into a shell environment with all the dependencies specified indevShells.default
. This means you don’t have to manually install or manage tools like Rust, Cargo, or Clang—it’s all handled declaratively through Nix.
You can now build and run the project using its standard tooling:
cargo build
cargo run
- Making Changes and Testing Them
Since you’re in a reproducible environment, you can confidently hack on the
project without worrying about your system setup. Try modifying some code in
helix
and rebuilding with Cargo. The Nix shell ensures consistency for every
contributor or device you work on.
- Run Just the Binary
If you only want to run the compiled program without entering the shell, use the nix run command:
nix run
This builds and runs the default package defined by the flake. In the case of
Helix, this launches the hx
editor directly.
- Build Without Running
To just build the project and get the path to the output binary:
nix build
You’ll find the compiled binary under ./result/bin
.
- Pinning and Reproducing
Because the project uses a flake, you can ensure full reproducibility by pinning the inputs. For example, you can clone with –recurse-submodules and copy the flake.lock to ensure you’re using the same dependency versions as upstream. This is great for debugging or sharing exact builds.
✅ Recap:
With flakes, projects like Helix provide everything you need for development and running in a single flake.nix. You can nix develop to get started hacking, nix run to quickly try it out, and nix build to produce binaries—all without installing or polluting your system.
Understanding the Helix flake.nix
The helix flake is full of idiomatic Nix code and displays some of the more advanced things a flake can provide:
{
description = "A post-modern text editor.";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
self,
nixpkgs,
rust-overlay,
...
}: let
inherit (nixpkgs) lib;
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
eachSystem = lib.genAttrs systems;
pkgsFor = eachSystem (system:
import nixpkgs {
localSystem.system = system;
overlays = [(import rust-overlay) self.overlays.helix];
});
gitRev = self.rev or self.dirtyRev or null;
in {
packages = eachSystem (system: {
inherit (pkgsFor.${system}) helix;
/*
The default Helix build. Uses the latest stable Rust toolchain, and unstable
nixpkgs.
The build inputs can be overridden with the following:
packages.${system}.default.override { rustPlatform = newPlatform; };
Overriding a derivation attribute can be done as well:
packages.${system}.default.overrideAttrs { buildType = "debug"; };
*/
default = self.packages.${system}.helix;
});
checks =
lib.mapAttrs (system: pkgs: let
# Get Helix's MSRV toolchain to build with by default.
msrvToolchain = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
msrvPlatform = pkgs.makeRustPlatform {
cargo = msrvToolchain;
rustc = msrvToolchain;
};
in {
helix = self.packages.${system}.helix.override {
rustPlatform = msrvPlatform;
};
})
pkgsFor;
# Devshell behavior is preserved.
devShells =
lib.mapAttrs (system: pkgs: {
default = let
commonRustFlagsEnv = "-C link-arg=-fuse-ld=lld -C target-cpu=native --cfg tokio_unstable";
platformRustFlagsEnv = lib.optionalString pkgs.stdenv.isLinux "-Clink-arg=-Wl,--no-rosegment";
in
pkgs.mkShell {
inputsFrom = [self.checks.${system}.helix];
nativeBuildInputs = with pkgs;
[
lld
cargo-flamegraph
rust-bin.nightly.latest.rust-analyzer
]
++ (lib.optional (stdenv.isx86_64 && stdenv.isLinux) cargo-tarpaulin)
++ (lib.optional stdenv.isLinux lldb)
++ (lib.optional stdenv.isDarwin darwin.apple_sdk.frameworks.CoreFoundation);
shellHook = ''
export RUST_BACKTRACE="1"
export RUSTFLAGS="''${RUSTFLAGS:-""} ${commonRustFlagsEnv} ${platformRustFlagsEnv}"
'';
};
})
pkgsFor;
overlays = {
helix = final: prev: {
helix = final.callPackage ./default.nix {inherit gitRev;};
};
default = self.overlays.helix;
};
};
nixConfig = {
extra-substituters = ["https://helix.cachix.org"];
extra-trusted-public-keys = ["helix.cachix.org-1:ejp9KQpR1FBI2onstMQ34yogDm4OgU2ru6lIwPvuCVs="];
};
}
Top-Level Metadata
{
description = "A post-modern text editor.";
}
- This sets a human-readable description for the flake.
Inputs
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
nixpkgs
: Uses thenixos-unstable
branch of the Nixpkgs repository.rust-overlay
: follows the samenixpkgs
, ensuring compatibility between inputs.
Outputs Function
outputs = { self, nixpkgs, rust-overlay, ... }:
- This defines what this flake exports, including
packages
,devShells
, etc.
Common Setup
let
inherit (nixpkgs) lib;
systems = [ ... ];
eachSystem = lib.genAttrs systems;
systems
: A list of the supported systemseachSystem
: A Helper to map over all platforms.
pkgsFor = eachSystem (system:
import nixpkgs {
localSystem.system = system;
overlays = [(import rust-overlay) self.overlays.helix];
});
- This imports
nixpkgs
for each system and applies overlays
📦 packages
packages = eachSystem (system: {
inherit (pkgsFor.${system}) helix;
default = self.packages.${system}.helix;
});
For each platform:
Includes a
helix
package (defined in./default.nix
)Sets
default
tohelix
(used bynix build
,nix run
)
Let’s look at the helix default.nix
:
{
lib,
rustPlatform,
callPackage,
runCommand,
installShellFiles,
git,
gitRev ? null,
grammarOverlays ? [],
includeGrammarIf ? _: true,
}: let
fs = lib.fileset;
src = fs.difference (fs.gitTracked ./.) (fs.unions [
./.envrc
./rustfmt.toml
./screenshot.png
./book
./docs
./runtime
./flake.lock
(fs.fileFilter (file: lib.strings.hasInfix ".git" file.name) ./.)
(fs.fileFilter (file: file.hasExt "svg") ./.)
(fs.fileFilter (file: file.hasExt "md") ./.)
(fs.fileFilter (file: file.hasExt "nix") ./.)
]);
# Next we actually need to build the grammars and the runtime directory
# that they reside in. It is built by calling the derivation in the
# grammars.nix file, then taking the runtime directory in the git repo
# and hooking symlinks up to it.
grammars = callPackage ./grammars.nix {inherit grammarOverlays includeGrammarIf;};
runtimeDir = runCommand "helix-runtime" {} ''
mkdir -p $out
ln -s ${./runtime}/* $out
rm -r $out/grammars
ln -s ${grammars} $out/grammars
'';
in
rustPlatform.buildRustPackage (self: {
cargoLock = {
lockFile = ./Cargo.lock;
# This is not allowed in nixpkgs but is very convenient here: it allows us to
# avoid specifying `outputHashes` here for any git dependencies we might take
# on temporarily.
allowBuiltinFetchGit = true;
};
nativeBuildInputs = [
installShellFiles
git
];
buildType = "release";
name = with builtins; (fromTOML (readFile ./helix-term/Cargo.toml)).package.name;
src = fs.toSource {
root = ./.;
fileset = src;
};
# Helix attempts to reach out to the network and get the grammars. Nix doesn't allow this.
HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1";
# So Helix knows what rev it is.
HELIX_NIX_BUILD_REV = gitRev;
doCheck = false;
strictDeps = true;
# Sets the Helix runtime dir to the grammars
env.HELIX_DEFAULT_RUNTIME = "${runtimeDir}";
# Get all the application stuff in the output directory.
postInstall = ''
mkdir -p $out/lib
installShellCompletion ${./contrib/completion}/hx.{bash,fish,zsh}
mkdir -p $out/share/{applications,icons/hicolor/{256x256,scalable}/apps}
cp ${./contrib/Helix.desktop} $out/share/applications/Helix.desktop
cp ${./logo.svg} $out/share/icons/hicolor/scalable/apps/helix.svg
cp ${./contrib/helix.png} $out/share/icons/hicolor/256x256/apps/helix.png
'';
meta.mainProgram = "hx";
})
Breaking Down helix/default.nix
This default.nix
file is a Nix derivation that defines how to build the Helix
editor itself. It’s designed to be called by the main flake.nix
as part of its
packages
output.
Here’s a breakdown of its components:
- Function Arguments:
{
lib,
rustPlatform,
callPackage,
runCommand,
installShellFiles,
git,
gitRev ? null,
grammarOverlays ? [],
includeGrammarIf ? _: true,
}:
lib
: The Nixpkgs lib
(library) functions, essential for common operations
like fileset
and strings
.
rustPlatform
: A helper function from Nixpkgs specifically for building Rust
projects. It provides a buildRustPackage
function, which simplifies the
process significantly.
callPackage
: A Nixpkgs function used to instantiate a Nix expression (like
grammars.nix
) with its dependencies automatically supplied from the current
Nix environment.
runCommand
: A Nixpkgs primitive that creates a derivation by running a shell
command. It’s used here to construct the runtimeDir
.
installShellFiles
: A utility from Nixpkgs for installing shell completion
files.
git
: The Git package, needed for determining the gitRev
.
gitRev ? null
: The Git revision of the Helix repository. It’s an optional
argument, defaulting to null. This is passed in from the main flake.nix
.
grammarOverlays ? []
: An optional list of overlays for grammars, allowing
customization.
includeGrammarIf ? _: true
: An optional function to control which grammars
are included.
- Local Variables (
let ... in
)
let
fs = lib.fileset;
src = fs.difference (fs.gitTracked ./.) (fs.unions [
./.envrc
./rustfmt.toml
./screenshot.png
./book
./docs
./runtime
./flake.lock
(fs.fileFilter (file: lib.strings.hasInfix ".git" file.name) ./.)
(fs.fileFilter (file: file.hasExt "svg") ./.)
(fs.fileFilter (file: file.hasExt "md") ./.)
(fs.fileFilter (file: file.hasExt "nix") ./.)
]);
grammars = callPackage ./grammars.nix { inherit grammarOverlays includeGrammarIf; };
runtimeDir = runCommand "helix-runtime" {} ''
mkdir -p $out
ln -s ${./runtime}/* $out
rm -r $out/grammars
ln -s ${grammars} $out/grammars
'';
in
fs = lib.fileset;
: Aliases lib.fileset
for convenient file set operations.
src
: This is a crucial part. It defines the source files that will be used to
build Helix by:
Taking all Git-tracked files in the current directory (
fs.gitTracked ./.
).Excluding configuration files (e.g., .envrc, flake.lock), documentation (.md), images (.svg), and Nix files (.nix) using fs.difference and fs.unions. This ensures a clean build input, reducing Nix store size and avoiding unnecessary rebuilds.
grammars
: Builds syntax grammars by callinggrammars.nix
, passinggrammarOverlays
(for customizing grammar builds) andincludeGrammarIf
(a filter for selecting grammars).runtimeDir
: Creates a runtime directory for Helix by:Symlinking the
runtime
directory from the source.Replacing the
grammars
subdirectory with a symlink to thegrammars
derivation, ensuring Helix uses Nix-managed grammars.
- The Build Derivation (
rustPlatform.buildRustPackage
)
The core of this default.nix
is the rustPlatform.buildRustPackage
call, which
is a specialized builder for Rust projects:
in
rustPlatform.buildRustPackage (self: {
cargoLock = {
lockFile = ./Cargo.lock;
# ... comments ...
allowBuiltinFetchGit = true;
};
cargoLock
: Specifies how Cargo dependencies are handled.
lockFile = ./Cargo.lock;
Points to the Cargo.lock
file for reproducible builds.
allowBuiltinFetchGit = true
: Allows Cargo to fetch Git dependencies directly
from repositories specified in Cargo.lock
. This is discouraged in Nixpkgs
because it can break build reproducibility, but it’s used here for convenience
during development, eliminating the need to manually specify outputHashes
for
Git dependencies.
nativeBuildInputs = [
installShellFiles
git
];
nativeBuildInputs
: Are tools needed during the build process but not necessarily
at runtime.
buildType = "release";
buildType
: Specifies that Helix should be built in “release” mode (optimized).
name = with builtins; (fromTOML (readFile ./helix-term/Cargo.toml)).package.name;
src = fs.toSource {
root = ./.;
fileset = src;
};
name
: Dynamically sets the package name by reading it from the Cargo.toml
file.
src
: Uses the src
file set defined earlier as the source for the build.
# Helix attempts to reach out to the network and get the grammars. Nix doesn't allow this.
HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1";
# So Helix knows what rev it is.
HELIX_NIX_BUILD_REV = gitRev;
Environment Variables: Sets environment variables that Helix uses.
HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1"
: Prevents Helix from downloading
grammars during the build, as Nix’s sandboxed environment disallows network
access. Instead, grammars are provided via the runtimeDir
derivation.
HELIX_NIX_BUILD_REV = gitRev
: Embeds the specified Git revision (or null
if
unspecified) into the Helix binary, allowing Helix to display its version or
commit hash.
doCheck = false;
strictDeps = true;
doCheck = false;
: Skips running tests during the build. This is common for
faster builds, especially in CI/CD, but tests are often run in a separate
checks
output (as seen in the flake.nix
).
strictDeps = true;
: Ensures that all dependencies are explicitly declared.
# Sets the Helix runtime dir to the grammars
env.HELIX_DEFAULT_RUNTIME = "${runtimeDir}";
# Sets the Helix runtime dir to the grammars
env.HELIX_DEFAULT_RUNTIME = "${runtimeDir}";
env.HELIX_DEFAULT_RUNTIME
: Tells Helix where to find its runtime files
(including the Nix-managed grammars).
# Get all the application stuff in the output directory.
postInstall = ''
mkdir -p $out/lib
installShellCompletion ${./contrib/completion}/hx.{bash,fish,zsh}
mkdir -p $out/share/{applications,icons/hicolor/{256x256,scalable}/apps}
cp ${./contrib/Helix.desktop} $out/share/applications/Helix.desktop
cp ${./logo.svg} $out/share/icons/hicolor/scalable/apps/helix.svg
cp ${./contrib/helix.png} $out/share/icons/hicolor/256x256/apps/helix.png
'';
postInstall
: A shell script that runs after the main build is complete. This
is used for installing additional files that are part of the Helix distribution
but not directly built by Cargo.
Installs shell completion files (hx.bash
, hx.fish
, hx.zsh
). This enables
tab completion.
Installs desktop entry files (Helix.desktop
) and icons (logo.svg
, helix.png
)
for desktop integration for GUI environments.
meta.mainProgram = "hx";
})
meta.mainProgram
: Specifies the primary executable provided by this package,
allowing nix run
to automatically execute hx
.
A lot going on in this derivation!
Making Actual Changes
- Locate the
packages
output section. It looks like this:
packages = eachSystem (system: {
inherit (pkgsFor.${system}) helix;
/*
The default Helix build. Uses the latest stable Rust toolchain, and unstable
nixpkgs.
The build inputs can be overridden with the following:
packages.${system}.default.override { rustPlatform = newPlatform; };
Overriding a derivation attribute can be done as well:
packages.${system}.default.overrideAttrs { buildType = "debug"; };
*/
default = self.packages.${system}.helix;
});
- Modify the
default
package. The comments actually tell us exactly how to do this. We want to useoverrideAttrs
to change thebuildType
Change this line:
default = self.packages.${system}.helix;
To this:
default = self.packages.${system}.helix.overrideAttrs { buildType = "debug"; };
- This tells Nix to take the standard Helix package definition and override one
of its internal attributes (
buildType
) to “debug” instead of “release”.
- Build the “Hacked” Helix:
nix build
- Nix will now rebuild Helix, but this time, it will compile it in debug mode. You’ll likely notice the build takes a bit longer, and the resulting binary will be larger due to the included debugging symbols.
- Run the Debug Binary:
./result/bin/hx
- You’re now running your custom-built debug version of Helix! This is useful if you were, for example, attatching a debugger.
This is a simple yet powerful “hack” that demonstrates how easily you can modify the behavior of a package defined within a Nix flake without changing the original source code or the upstream flake directly. You’re simply telling Nix how you’d like your version of the package to be built.
Another way to Modify Behavior
Since we are already familiar with the structure and behavior of Helix’s
flake.nix
, we can leverage that understanding to create our own Nix flake.
By analyzing how Helix organizes its inputs
, outputs
, and package definitions,
we gain the confidence to modify and extend a flake’s functionality to suit our
specific needs—whether that’s customizing builds, adding overlays, or
integrating with home-manager.
- Create a
flake.nix
in your own directory (outside the helix repo):
{
description = "Customized Helix build with debug features";
inputs = {
helix.url = "github:helix-editor/helix";
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
self,
helix,
nixpkgs,
rust-overlay,
}: let
system = "x86_64-linux";
pkgs = import nixpkgs {
system = system;
overlays = [rust-overlay.overlay.overlays.default];
};
in {
packages.${system}.default = helix.packages.${system}.helix.overrideAttrs (old: {
buildType = "debug";
# Add additional cargo features
cargoBuildFlags =
(old.cargoBuildFlags or [])
++ [
"--features"
"tokio-console"
];
# Inject custom RUSTFLAGS
RUSTFLAGS = (old.RUSTFLAGS or "") + " -C debuginfo=2 -C opt-level=1";
});
};
}
Check it:
nix flake check
warning: creating lock file '"/home/jr/world/flake.lock"':
• Added input 'helix':
'github:helix-editor/helix/8961ae1dc66633ea6c9f761896cb0d885ae078ed?narHash=sha256-f14perPUk%2BH15GyGRbg0Akqhn3rxFnc6Ez5onqpzu6A%3D' (2025-05-29)
• Added input 'helix/nixpkgs':
'github:nixos/nixpkgs/5135c59491985879812717f4c9fea69604e7f26f?narHash=sha256-Vr3Qi346M%2B8CjedtbyUevIGDZW8LcA1fTG0ugPY/Hic%3D' (2025-02-26)
• Added input 'helix/rust-overlay':
'github:oxalica/rust-overlay/d342e8b5fd88421ff982f383c853f0fc78a847ab?narHash=sha256-3SdPQrZoa4odlScFDUHd4CUPQ/R1gtH4Mq9u8CBiK8M%3D' (2025-02-27)
• Added input 'helix/rust-overlay/nixpkgs':
follows 'helix/nixpkgs'
• Added input 'nixpkgs':
'github:nixos/nixpkgs/96ec055edbe5ee227f28cdbc3f1ddf1df5965102?narHash=sha256-7doLyJBzCllvqX4gszYtmZUToxKvMUrg45EUWaUYmBg%3D' (2025-05-28)
• Added input 'rust-overlay':
'github:oxalica/rust-overlay/405ef13a5b80a0a4d4fc87c83554423d80e5f929?narHash=sha256-k0nhPtkVDQkVJckRw6fGIeeDBktJf1BH0i8T48o7zkk%3D' (2025-05-30)
• Added input 'rust-overlay/nixpkgs':
follows 'nixpkgs'
- The
nix flake check
command will generate aflake.lock
file if one doesn’t exist, and the warnings you see indicate that new inputs are being added and locked to specific versions for reproducibility. This is expected behavior for a new or modified flake.
Inspect the outputs:
nix flake show
path:/home/jr/world?lastModified=1748612128&narHash=sha256-WEYtptarRrrm0Jb/0PJ/b5VPqLkCk5iEenjbKYU4Xm8%3D
└───packages
└───x86_64-linux
└───default: package 'helix-term'
The
└───packages
line indicates that our flake exposes a top-levelpackages
attribute.└───x86_64-linux
: System architecture specificity└───default: package 'helix-term'
Signifies that within thex86_64-linux
packages, there’s a package nameddefault
. This is a special name that allows you to omit the package name when using commands likenix build
.package 'helix-term'
This is the most direct confirmation of our “hack”. It tells us that ourdefault
package ishelix-term
. This confirms that ouroverrideAttrs
in thepackages.${system}.default
section successfully targeted and modified the Helix editor package, which is internally namedhelix-term
by the Helix flake.
What This Does:
overrideAttrs
lets you change only parts of the derivation without rewriting everything.buildType = "debug"
enables debug builds.cargoBuildFlags
adds extra features passed to Cargo, e.g.,--features tokio-console
RUSTFLAGS
gives you even more control over compiler behavior, optimization levels, etc.
Run It:
nix run
Or drop into the dev shell:
nix develop
- (assuming you also wire in a
devShells
output)
Adding the devShells
output:
Since we already have the helix flake as an input to our own flake.nix
we can
now forward or extend Helix’s devShells
like this:
outputs = { self, nixpkgs, helix, rust-overlay, ... }: {
devShells = helix.devShells;
};
Or if you want to pick a specific system:
outputs = { self, nixpkgs, helix, rust-overlay ... }:
let
system = "x86_64-linux";
in {
devShells.${system} = helix.devShells.${system};
};
Optional: Combine with your own devShell
You can also extend or merge it with your own shell like so:
outputs = { self, nixpkgs, helix, rust-overlay, ... }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in {
devShells.${system} = {
default = pkgs.mkShell {
name = "my-shell";
inputsFrom = [ helix.devShells.${system}.default ];
buildInputs = [ pkgs.git ];
};
};
};