diff --git a/Cargo.lock b/Cargo.lock index 4677aae..b02eff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -917,6 +917,12 @@ dependencies = [ "hashbrown 0.12.3", ] +[[package]] +name = "indoc" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f2cb48b81b1dc9f39676bf99f5499babfec7cd8fe14307f7b3d747208fb5690" + [[package]] name = "instant" version = "0.1.12" @@ -1110,6 +1116,7 @@ dependencies = [ "hyper", "hyper-proxy", "indexmap", + "indoc", "memchr", "num_cpus", "owo-colors", diff --git a/Cargo.toml b/Cargo.toml index 9dd5e87..c0c86ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ grep = "0.2.12" headers = "0.3.8" http = "0.2.9" hyper-proxy = "0.9.1" +indoc = "2.0.1" atty = "0.2.14" memchr = "2.5.0" num_cpus = "1.15.0" diff --git a/command-not-found.sh b/command-not-found.sh deleted file mode 100755 index 5f30bad..0000000 --- a/command-not-found.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/bin/sh - -# for bash 4 -# this will be called when a command is entered -# but not found in the user’s path + environment -command_not_found_handle () { - - # TODO: use "command not found" gettext translations - - # taken from http://www.linuxjournal.com/content/bash-command-not-found - # - do not run when inside Midnight Commander or within a Pipe - if [ -n "${MC_SID-}" ] || ! [ -t 1 ]; then - >&2 echo "$1: command not found" - return 127 - fi - - toplevel=nixpkgs # nixpkgs should always be available even in NixOS - cmd=$1 - attrs=$(@out@/bin/nix-locate --minimal --no-group --type x --type s --top-level --whole-name --at-root "/bin/$cmd") - len=$(echo -n "$attrs" | grep -c "^") - - case $len in - 0) - >&2 echo "$cmd: command not found" - ;; - 1) - # if only 1 package provides this, then we can invoke it - # without asking the users if they have opted in with one - # of 2 environment variables - - # they are based on the ones found in - # command-not-found.sh: - - # NIX_AUTO_INSTALL : install the missing command into the - # user’s environment - # NIX_AUTO_RUN : run the command transparently inside of - # nix shell - - # these will not return 127 if they worked correctly - - if ! [ -z "${NIX_AUTO_INSTALL-}" ]; then - >&2 cat <&2 cat <" - if [ "$?" -eq 0 ]; then - # how nix-shell handles commands is weird - # $(echo $@) is need to handle this - nix-shell -p $attrs --run "$(echo $@)" - return $? - else - >&2 cat <&2 cat <&2 cat <&2 cat <&2 echo " nix profile install $toplevel#$attr" - else - >&2 echo " nix-env -iA $toplevel.$attr" - fi - done <<< "$attrs" - - >&2 cat <&2 echo " nix shell $toplevel#$attr -c $cmd ..." - else - >&2 echo " nix-shell -p $attr --run '$cmd ...'" - fi - done <<< "$attrs" - ;; - esac - - return 127 # command not found should always exit with 127 -} - -# for zsh... -# we just pass it to the bash handler above -# apparently they work identically -command_not_found_handler () { - command_not_found_handle $@ - return $? -} diff --git a/etc/command-not-found.bash b/etc/command-not-found.bash new file mode 100644 index 0000000..ef4f71e --- /dev/null +++ b/etc/command-not-found.bash @@ -0,0 +1,3 @@ +command_not_found_handle() { + @out@/bin/nix-locate --command-not-found "$@" +} diff --git a/etc/command-not-found.fish b/etc/command-not-found.fish new file mode 100644 index 0000000..449ae5e --- /dev/null +++ b/etc/command-not-found.fish @@ -0,0 +1,3 @@ +function fish_command_not_found + @out@/bin/nix-locate --command-not-found $argv +end diff --git a/etc/command-not-found.zsh b/etc/command-not-found.zsh new file mode 100644 index 0000000..3cc2583 --- /dev/null +++ b/etc/command-not-found.zsh @@ -0,0 +1,3 @@ +command_not_found_handler() { + @out@/bin/nix-locate --command-not-found "$@" +} diff --git a/flake.lock b/flake.lock index f6fadf1..19ffa81 100644 --- a/flake.lock +++ b/flake.lock @@ -18,11 +18,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1686501370, - "narHash": "sha256-G0WuM9fqTPRc2URKP9Lgi5nhZMqsfHGrdEbrLvAPJcg=", + "lastModified": 1687412861, + "narHash": "sha256-Z/g0wbL68C+mSGerYS2quv9FXQ1RRP082cAC0Bh4vcs=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "75a5ebf473cd60148ba9aec0d219f72e5cf52519", + "rev": "e603dc5f061ca1d8a19b3ede6a8cf9c9fcba6cdc", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 084149e..6f5091b 100644 --- a/flake.nix +++ b/flake.nix @@ -23,9 +23,8 @@ inherit ((lib.importTOML ./Cargo.toml).package) version; src = lib.sourceByRegex self [ - "(examples|src)(/.*)?" + "(etc|examples|src)(/.*)?" ''Cargo\.(toml|lock)'' - ''command-not-found\.sh'' ]; cargoLock = { @@ -37,9 +36,9 @@ ++ lib.optionals stdenv.isDarwin [ darwin.apple_sdk.frameworks.Security ]; postInstall = '' - substituteInPlace command-not-found.sh \ + substituteInPlace etc/command-not-found.* \ --subst-var out - install -Dm555 command-not-found.sh -t $out/etc/profile.d + install -Dm444 etc/command-not-found.* -t $out/etc/profile.d ''; meta = with lib; { diff --git a/src/bin/nix-locate.rs b/src/bin/nix-locate.rs index 760d398..7599029 100644 --- a/src/bin/nix-locate.rs +++ b/src/bin/nix-locate.rs @@ -1,14 +1,26 @@ //! Tool for searching for files in nixpkgs packages use std::collections::HashSet; +use std::env::args_os; +use std::env::var_os; use std::ffi::OsStr; +use std::ffi::OsString; +use std::fs::File; +use std::io::stderr; +use std::io::stdout; +use std::io::BufRead; +use std::io::BufReader; +use std::io::IsTerminal; +use std::io::Write; use std::path::PathBuf; use std::process; +use std::process::Command; use std::result; use std::str; use std::str::FromStr; use clap::{value_parser, Parser}; use error_chain::error_chain; +use indoc::writedoc; use nix_index::database; use nix_index::files::{self, FileTreeEntry, FileType}; use owo_colors::{OwoColorize, Stream}; @@ -149,6 +161,222 @@ fn locate(args: &Args) -> Result<()> { Ok(()) } +fn has_env(env: &str) -> bool { + var_os(env).map_or(false, |var| !var.is_empty()) +} + +fn has_flakes() -> bool { + // TODO: user config + let mut files = vec![PathBuf::from("/etc/nix/nix.conf")]; + + while let Some(file) = files.pop() { + let Ok(file) = File::open(file) else { + continue; + }; + + for line in BufReader::new(file).lines() { + let Ok(line) = line else { + break; + }; + + let mut tokens = line.split_whitespace(); + let Some(name) = tokens.next() else { + continue; + }; + + match name { + "experimental-features" => { + if tokens.any(|feat| feat == "flakes") { + return true; + } + } + "include" | "!include" => { + if let Some(file) = tokens.next() { + files.push(file.into()); + } + } + _ => {} + } + } + } + + false +} + +fn command_not_found(args: Vec) -> Result<()> { + let mut args = args.into_iter().skip(2); + let cmd = args.next().expect("there should be a command"); + let cmd_str = cmd.to_string_lossy(); + let database = var_os("NIX_INDEX_DATABASE").map_or_else(|| cache_dir().into(), PathBuf::from); + let mut err = stderr().lock(); + + // TODO: use "command not found" gettext translations + + // taken from http://www.linuxjournal.com/content/bash-command-not-found + // - do not run when inside Midnight Commander or within a Pipe + if has_env("MC_SID") || !stdout().is_terminal() { + let _ = writeln!(err, "{cmd_str}: command not found"); + process::exit(127); + } + + // Build the regular expression matcher + let pattern = format!("^/bin/{}$", regex::escape(&cmd_str)); + let regex = Regex::new(&pattern).chain_err(|| ErrorKind::Grep(pattern.clone()))?; + + // Open the database + let index_file = database.join("files"); + let db = database::Reader::open(&index_file) + .chain_err(|| ErrorKind::ReadDatabase(index_file.clone()))?; + + let results = db + .query(®ex) + .run() + .chain_err(|| ErrorKind::Grep(pattern.clone()))? + .filter(|v| { + v.as_ref().ok().map_or(true, |(store_path, entry)| { + store_path.origin().toplevel + && entry.node.get_type() == FileType::Regular { executable: true } + }) + }); + + let mut attrs = HashSet::new(); + for v in results { + let (store_path, _) = v.chain_err(|| ErrorKind::ReadDatabase(index_file.clone()))?; + + attrs.insert(format!( + "{}.{}", + store_path.origin().attr, + store_path.origin().output, + )); + } + + let mut it = attrs.iter(); + if let Some(attr) = it.next() { + if it.next().is_some() { + writedoc! {err, " + The program '{cmd_str}' is currently not installed. It is provided by; + several packages. You can install it by typing one of the following: + "} + .unwrap(); + + let has_flakes = has_flakes(); + + for attr in &attrs { + if has_flakes { + writeln!(err, " nix profile install nixpkgs#{attr}").unwrap(); + } else { + writeln!(err, " nix-env -iA nixpkgs.{attr}").unwrap(); + } + } + + writeln!(err, "\nOr run it once with:").unwrap(); + + for attr in attrs { + if has_flakes { + writeln!(err, " nix shell nixpkgs#{attr} -c {cmd_str} ...").unwrap(); + } else { + writeln!(err, " nix-shell -p {attr} --run '{cmd_str} ...'").unwrap(); + } + } + } else if has_env("NIX_AUTO_INSTALL") { + writedoc! {err, " + The program '{cmd_str}' is currently not installed. It is provided by + the package 'nixpkgs.{attr}', which I will now install for you. + "} + .unwrap(); + + let res = if has_flakes() { + Command::new("nix") + .arg("profile") + .arg("install") + .arg(format!("nixpkgs#{attr}")) + .status() + } else { + Command::new("nix-env") + .arg("-iA") + .arg(format!("nixpkgs.{attr}")) + .status() + }; + + if res.is_ok_and(|status| status.success()) { + let res = Command::new(cmd).args(args).status(); + if let Ok(status) = res { + if let Some(code) = status.code() { + process::exit(code); + } + } + } else { + writedoc! {err, " + Failed to install nixpkgs.{attr} + {cmd_str}: command not found + "} + .unwrap(); + } + } else if has_env("NIX_AUTO_RUN") { + let res = Command::new("nix-build") + .arg("--no-out-link") + .arg("-A") + .arg(attr) + .arg("") + .status(); + + if res.is_ok_and(|status| status.success()) { + // TODO: escape or find and alternative + let mut cmd = cmd; + for arg in args { + cmd.push(" "); + cmd.push(&arg); + } + + let res = Command::new("nix-shell") + .arg("-p") + .arg(attr) + .arg("--run") + .arg(cmd) + .status(); + + if let Ok(status) = res { + if let Some(code) = status.code() { + process::exit(code); + } + } + } else { + writedoc! {err, " + Failed to install nixpkgs.{attr} + {cmd_str}: command not found + "} + .unwrap(); + } + } else { + let has_flakes = has_flakes(); + + writedoc! {err, " + The program '{cmd_str}' is currently not installed. You can install it + by typing: + "} + .unwrap(); + + if has_flakes { + writeln!(err, " nix profile install nixpkgs#{attr}").unwrap(); + } else { + writeln!(err, " nix-env -iA nixpkgs.{attr}").unwrap(); + } + + writeln!(err, "\nOr run it once with:").unwrap(); + + if has_flakes { + writeln!(err, " nix shell nixpkgs#{attr} -c {cmd_str} ...").unwrap(); + } else { + writeln!(err, " nix-shell -p {attr} --run '{cmd_str} ...'").unwrap(); + } + } + } else { + writeln!(err, "{cmd_str}: command not found").unwrap(); + } + + Ok(()) +} + /// Extract the parsed arguments for clap's arg matches. /// /// Handles parsing the values of more complex arguments. @@ -316,8 +544,23 @@ impl FromStr for Color { } fn main() { - let args = Opts::parse(); + let args: Vec<_> = args_os().collect(); + if matches!(args.get(1), Some(arg) if arg == "--command-not-found") { + if let Err(e) = command_not_found(args) { + eprintln!("error: {e}"); + + for e in e.iter().skip(1) { + eprintln!("caused by: {e}"); + } + + if let Some(backtrace) = e.backtrace() { + eprintln!("backtrace: {backtrace:?}"); + } + } + process::exit(127); + } + let args = Opts::parse_from(args); let args = process_args(args).unwrap_or_else(|e| e.exit()); if let Err(e) = locate(&args) {