Fish importing (#234)

* make a start on fish

* fix

* test

* enable fish

* fmt

* update histpath

set up fish init script

* update readme

* cover edge case

* fmt

* fix session variables

Co-authored-by: PJ <me@panekj.dev>

* respect NOBIND

Co-authored-by: PJ <me@panekj.dev>

* fix env var setting

Co-authored-by: PJ <me@panekj.dev>

* fix whitespace

Co-authored-by: PJ <me@panekj.dev>

* add fish to supported shells

Co-authored-by: PJ <me@panekj.dev>
This commit is contained in:
Conrad Ludgate 2021-12-11 09:48:53 +00:00 committed by GitHub
parent 6daaeb22b0
commit 87df7d80ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 280 additions and 0 deletions

View file

@ -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

View file

@ -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<R> {
file: BufReader<R>,
strbuf: String,
loc: usize,
}
impl<R: Read + Seek> Fish<R> {
fn new(r: R) -> Result<Self> {
let mut buf = BufReader::new(r);
let loc = count_lines(&mut buf)?;
Ok(Self {
file: buf,
strbuf: String::new(),
loc,
})
}
}
impl<R: Read> Fish<R> {
fn new_entry(&mut self) -> io::Result<bool> {
let inner = self.file.fill_buf()?;
Ok(inner.starts_with(b"- "))
}
}
impl Importer for Fish<File> {
const NAME: &'static str = "fish";
/// see https://fishshell.com/docs/current/interactive.html#searchable-command-history
fn histpath() -> Result<PathBuf> {
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<Path>) -> Result<Self> {
Self::new(File::open(path)?)
}
}
impl<R: Read> Iterator for Fish<R> {
type Item = Result<History>;
fn next(&mut self) -> Option<Self::Item> {
let mut time: Option<DateTime<Utc>> = None;
let mut cmd: Option<String> = 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::<i64>() {
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<usize>) {
// 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::<Result<Vec<_>, _>>().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"),
]
);
}
}

View file

@ -6,6 +6,7 @@ use eyre::Result;
use crate::history::History;
pub mod bash;
pub mod fish;
pub mod resh;
pub mod zsh;

View file

@ -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::<Zsh<_>, _>(db, BATCH_SIZE).await
} else if shell.ends_with("/fish") {
println!("Detected Fish");
import::<Fish<_>, _>(db, BATCH_SIZE).await
} else {
println!("cannot import {} history", shell);
Ok(())
@ -63,6 +73,7 @@ impl Cmd {
Self::Zsh => import::<Zsh<_>, _>(db, BATCH_SIZE).await,
Self::Bash => import::<Bash<_>, _>(db, BATCH_SIZE).await,
Self::Resh => import::<Resh, _>(db, BATCH_SIZE).await,
Self::Fish => import::<Fish<_>, _>(db, BATCH_SIZE).await,
}
}
}

View file

@ -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(),
}
}
}

28
src/shell/atuin.fish Normal file
View file

@ -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