diff --git a/README.md b/README.md index 5f3fe338..0edd8a44 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ I wanted to. And I **really** don't want to. - zsh - bash +- fish # Quickstart @@ -166,6 +167,16 @@ Then setup Atuin echo 'eval "$(atuin init bash)"' >> ~/.bashrc ``` +### fish + +Add + +``` +atuin init fish | source +``` + +to your `is-interactive` block in your `~/.config/fish/config.fish` file + ## ...what's with the name? Atuin is named after "The Great A'Tuin", a giant turtle from Terry Pratchett's diff --git a/atuin-client/src/import/fish.rs b/atuin-client/src/import/fish.rs new file mode 100644 index 00000000..4079e122 --- /dev/null +++ b/atuin-client/src/import/fish.rs @@ -0,0 +1,221 @@ +// import old shell history! +// automatically hoover up all that we can find + +use std::{ + fs::File, + io::{self, BufRead, BufReader, Read, Seek}, + path::{Path, PathBuf}, +}; + +use chrono::prelude::*; +use chrono::Utc; +use directories::BaseDirs; +use eyre::{eyre, Result}; + +use super::{count_lines, Importer}; +use crate::history::History; + +#[derive(Debug)] +pub struct Fish { + file: BufReader, + strbuf: String, + loc: usize, +} + +impl Fish { + fn new(r: R) -> Result { + let mut buf = BufReader::new(r); + let loc = count_lines(&mut buf)?; + + Ok(Self { + file: buf, + strbuf: String::new(), + loc, + }) + } +} + +impl Fish { + fn new_entry(&mut self) -> io::Result { + let inner = self.file.fill_buf()?; + Ok(inner.starts_with(b"- ")) + } +} + +impl Importer for Fish { + const NAME: &'static str = "fish"; + + /// see https://fishshell.com/docs/current/interactive.html#searchable-command-history + fn histpath() -> Result { + let base = BaseDirs::new().ok_or_else(|| eyre!("could not determine data directory"))?; + let data = base.data_local_dir(); + + // fish supports multiple history sessions + // If `fish_history` var is missing, or set to `default`, use `fish` as the session + let session = std::env::var("fish_history").unwrap_or_else(|_| String::from("fish")); + let session = if session == "default" { + String::from("fish") + } else { + session + }; + + let mut histpath = data.join("fish"); + histpath.push(format!("{}_history", session)); + + if histpath.exists() { + Ok(histpath) + } else { + Err(eyre!("Could not find history file. Try setting $HISTFILE")) + } + } + + fn parse(path: impl AsRef) -> Result { + Self::new(File::open(path)?) + } +} + +impl Iterator for Fish { + type Item = Result; + + fn next(&mut self) -> Option { + let mut time: Option> = None; + let mut cmd: Option = None; + + loop { + self.strbuf.clear(); + match self.file.read_line(&mut self.strbuf) { + // no more content to read + Ok(0) => break, + // bail on IO error + Err(e) => return Some(Err(e.into())), + _ => (), + } + + // `read_line` adds the line delimeter to the string. No thanks + self.strbuf.pop(); + + if let Some(c) = self.strbuf.strip_prefix("- cmd: ") { + // using raw strings to avoid needing escaping. + // replaces double backslashes with single backslashes + let c = c.replace(r"\\", r"\"); + // replaces escaped newlines + let c = c.replace(r"\n", "\n"); + // TODO: any other escape characters? + + cmd = Some(c); + } else if let Some(t) = self.strbuf.strip_prefix(" when: ") { + // if t is not an int, just ignore this line + if let Ok(t) = t.parse::() { + time = Some(Utc.timestamp(t, 0)); + } + } else { + // ... ignore paths lines + } + + match self.new_entry() { + // next line is a new entry, so let's stop here + // only if we have found a command though + Ok(true) if cmd.is_some() => break, + // bail on IO error + Err(e) => return Some(Err(e.into())), + _ => (), + } + } + + let cmd = cmd?; + let time = time.unwrap_or_else(Utc::now); + + Some(Ok(History::new( + time, + cmd, + "unknown".into(), + -1, + -1, + None, + None, + ))) + } + + fn size_hint(&self) -> (usize, Option) { + // worst case, entry per line + (0, Some(self.loc)) + } +} + +#[cfg(test)] +mod test { + use chrono::{TimeZone, Utc}; + use std::io::Cursor; + + use super::Fish; + use crate::history::History; + + // simple wrapper for fish history entry + macro_rules! fishtory { + ($timestamp:literal, $command:literal) => { + History::new( + Utc.timestamp($timestamp, 0), + $command.into(), + "unknown".into(), + -1, + -1, + None, + None, + ) + }; + } + + #[test] + fn parse_complex() { + // complicated input with varying contents and escaped strings. + let input = r#"- cmd: history --help + when: 1639162832 +- cmd: cat ~/.bash_history + when: 1639162851 + paths: + - ~/.bash_history +- cmd: ls ~/.local/share/fish/fish_history + when: 1639162890 + paths: + - ~/.local/share/fish/fish_history +- cmd: cat ~/.local/share/fish/fish_history + when: 1639162893 + paths: + - ~/.local/share/fish/fish_history +ERROR +- CORRUPTED: ENTRY + CONTINUE: + - AS + - NORMAL +- cmd: echo "foo" \\\n'bar' baz + when: 1639162933 +- cmd: cat ~/.local/share/fish/fish_history + when: 1639162939 + paths: + - ~/.local/share/fish/fish_history +- cmd: echo "\\"" \\\\ "\\\\" + when: 1639163063 +- cmd: cat ~/.local/share/fish/fish_history + when: 1639163066 + paths: + - ~/.local/share/fish/fish_history +"#; + let cursor = Cursor::new(input); + let fish = Fish::new(cursor).unwrap(); + + let history = fish.collect::, _>>().unwrap(); + assert_eq!( + history, + vec![ + fishtory!(1639162832, "history --help"), + fishtory!(1639162851, "cat ~/.bash_history"), + fishtory!(1639162890, "ls ~/.local/share/fish/fish_history"), + fishtory!(1639162893, "cat ~/.local/share/fish/fish_history"), + fishtory!(1639162933, "echo \"foo\" \\\n'bar' baz"), + fishtory!(1639162939, "cat ~/.local/share/fish/fish_history"), + fishtory!(1639163063, r#"echo "\"" \\ "\\""#), + fishtory!(1639163066, "cat ~/.local/share/fish/fish_history"), + ] + ); + } +} diff --git a/atuin-client/src/import/mod.rs b/atuin-client/src/import/mod.rs index d73d3857..471b7f98 100644 --- a/atuin-client/src/import/mod.rs +++ b/atuin-client/src/import/mod.rs @@ -6,6 +6,7 @@ use eyre::Result; use crate::history::History; pub mod bash; +pub mod fish; pub mod resh; pub mod zsh; diff --git a/src/command/import.rs b/src/command/import.rs index 53940abb..166fcd3e 100644 --- a/src/command/import.rs +++ b/src/command/import.rs @@ -1,5 +1,6 @@ use std::{env, path::PathBuf}; +use atuin_client::import::fish::Fish; use eyre::{eyre, Result}; use structopt::StructOpt; @@ -33,6 +34,12 @@ pub enum Cmd { aliases=&["r", "re", "res"], )] Resh, + + #[structopt( + about="import history from the fish history file", + aliases=&["f", "fi", "fis"], + )] + Fish, } const BATCH_SIZE: usize = 100; @@ -54,6 +61,9 @@ impl Cmd { if shell.ends_with("/zsh") { println!("Detected ZSH"); import::, _>(db, BATCH_SIZE).await + } else if shell.ends_with("/fish") { + println!("Detected Fish"); + import::, _>(db, BATCH_SIZE).await } else { println!("cannot import {} history", shell); Ok(()) @@ -63,6 +73,7 @@ impl Cmd { Self::Zsh => import::, _>(db, BATCH_SIZE).await, Self::Bash => import::, _>(db, BATCH_SIZE).await, Self::Resh => import::(db, BATCH_SIZE).await, + Self::Fish => import::, _>(db, BATCH_SIZE).await, } } } diff --git a/src/command/init.rs b/src/command/init.rs index b6fbe4b3..5d3ffed2 100644 --- a/src/command/init.rs +++ b/src/command/init.rs @@ -6,6 +6,8 @@ pub enum Cmd { Zsh, #[structopt(about = "bash setup")] Bash, + #[structopt(about = "fish setup")] + Fish, } fn init_zsh() { @@ -18,11 +20,17 @@ fn init_bash() { println!("{}", full); } +fn init_fish() { + let full = include_str!("../shell/atuin.fish"); + println!("{}", full); +} + impl Cmd { pub fn run(&self) { match self { Self::Zsh => init_zsh(), Self::Bash => init_bash(), + Self::Fish => init_fish(), } } } diff --git a/src/shell/atuin.fish b/src/shell/atuin.fish new file mode 100644 index 00000000..5d59d01d --- /dev/null +++ b/src/shell/atuin.fish @@ -0,0 +1,28 @@ + +set -gx ATUIN_SESSION (atuin uuid) +set -gx ATUIN_HISTORY (atuin history list) + +function _atuin_preexec --on-event fish_preexec + set -gx ATUIN_HISTORY_ID (atuin history start "$argv[1]") +end + +function _atuin_postexec --on-event fish_postexec + set s $status + if test -n "$ATUIN_HISTORY_ID" + RUST_LOG=error atuin history end $ATUIN_HISTORY_ID --exit $s &; disown + end +end + +function _atuin_search + set h (RUST_LOG=error atuin search -i (commandline -b) 3>&1 1>&2 2>&3) + commandline -f repaint + if test -n "$h" + commandline -r $h + end +end + +if test -z $ATUIN_NOBIND + bind -k up '_atuin_search' + bind \eOA '_atuin_search' + bind \e\[A '_atuin_search' +end