一个更好的终端自动装置

楽土

A Better Terminal Automator

不久前,我有了一个改善终端体验的想法–在这里有描述–这个想法是这样的:

把终端分成两半 - 底部只有输入的内容,顶部将有"交互式"会话。

其目的是为了消除输入的混乱,并在不同的环境中保持一个持久的本地历史。

后来我把这个想法扩展成了一个更大的项目 - tmeta

github 项目页面描述了大部分的功能,本文是关于实现的。

$ echo hello                 
hello                        
$ echo world                 
world                        
$                            
────────────────────────────────────────────────────
> echo hello                 
> echo world                 
> █                          

我写这篇文章的部分原因是它所使用的语言 - Raku。Raku 并不广为人知。所以我想写一写为什么这种语言很适合这样的应用。

但同时,这也是对潜在贡献者或用户的指南。让我知道你的想法。

作为本文的补充,我已经添加了一个链接,从每个命令的文档到其对应的源代码。在那里查看下面片段的完整版本。

总之,这里有一些 tmeta 命令以及它们在 Raku 中的实现方式。

\delay, \send

$ date
Sun Aug 16 22:15:25 EDT 2020
$ date
Sun Aug 16 22:15:26 EDT 2020
$



────────────────────────────────────────────────────
$ cat sendme
date
date
$ tmeta
Welcome to tmeta v0.0.2
> \delay 1
> \send sendme
~> date
date

 [q to abort, e to edit (from history)]>
>

让我们从一个简单的 send 开始,它只是将一个文件,一次一行,发送到另一个窗格。这基本上是一个复制和粘贴的操作,但是你可以控制行的粘贴速度。这对于 REPL 或不能立即响应的提示符是很方便的。输入 \delay 可以调整行间的延迟。

delaysend 的实现是这样的:

#= delay [num] -- set the delay
$*delay = val($meta.words[1])

$* 表示 $*delay 是一个动态变量 - 下文将详细介绍。

#= send <file> -- send a file
confirm-send( $file.IO.slurp , :big);

confirm-send prompts(如上图所示),然后在两次调用运行之间的 $*delay 中休眠。

run <<tmux send-keys -t "$window.$pane" -l "$send">>;

所以,这里没有什么深奥的东西 - 我只想提一下。

  • words 使用空格分隔输入。
  • valStr 转化为 IntStr
  • IntStr 是一个异构类型,根据上下文的不同,它表现为 IntStr
  • 调用 run 时,可以用引号和插值以及空格分隔参数。

\capture

$ date
...
────────────────────────────────────────────────────
> \capture /tmp/dates
> \send dates
... 

现在假设我们想把输出结果捕获到一个文件中。我们使用 \capture。其实现也很简单:

my $file = $meta.words[1];
tmux-start-pipe(:$*window, :$*pane, :$file);

tmux-start-pipe 的实现是:

sub tmux-start-pipe(:$window,:$pane,:$file) is export {
    shell "tmux pipe-pane -t $window.$pane 'cat >> $file'";
}

我们还有更多动态变量的例子 - $*window$*pane - 让我们来谈谈它们。

动态变量

这些都是拥有一个既不是全局的也不是词法作用域的变量的方法。把它想象成一个隐藏的参数 - 用词法声明 my $*delay,然后 $*delay 就可以在调用栈的任何较低的地方使用。

sub hello {
    say $*who;
}
my $*who = 'world';
hello; # prints "world";

如果你想的话,也可以显式传递。命名参数的发送方式为 foo => "bar",但 :$foo 将命名为 foo 的变量作为值 foo 发送,同样 :$*foo 将动态变量 $*foo 作为 foo 的命名参数发送。

为什么我们要这样做呢?因为在多线程的世界里,变量可能会被其他线程改变 - 在 tmux-start-pipe 中,我们要确保在运行 shell 命令之前,我们使用的变量不会被改变。这算是避免潜在的竞赛条件的一种简单直观的方法。

\repeat, \await

$ date
Sun Aug 16 22:58:31 EDT 2020
$ date
Sun Aug 16 22:58:36 EDT 2020
$ date
Sun Aug 16 22:58:41 EDT 2020
$ date
Sun Aug 16 22:58:46 EDT 2020
$
────────────────────────────────────────────────────
$ tmeta
Welcome to tmeta v0.0.2
> date
> \repeat
repeating (in @11.0) every 5 seconds: date
> \await :46
Waiting for ":46"
Done: saw ":46"
stopping @11.0
nothing queued
>

所以说到多线程 - 让我们反复发送一个 date 命令,直到我们看到字符串 :46。 就是这样:

> date
> \repeat
> \await :46

它是这样实现的:

# repeat
%repeating{"$window.$pane"} = Supply.interval($interval).tap: {
  for @repeat {
    sendit($_,...);
  }
}

await 部分基本上是:

react whenever output-stream(:$*window,:$*pane) -> $l {
    done if $l ~~ $regex;
}

什么时候线程编程变得这么好玩了?这些构造又是什么呢?

供应、分流器和事件循环

所以 Supply.interval 每隔 $interval 秒就会发射出递增的值。每当一个值被发射时,Supply.interval 上的 tap 就会运行。

output-stream 使用与 tmux-start-pipe 相同的机制,它们在自己的线程中运行(我们在 %repeating 散列中跟踪 taps,以便我们可以关闭 taps)。

react whenever $supply {...} 基本上等同于写 $supply.tap { ... } - 每当供应产生一个值时,代码就会运行。

\find

───────────────────────────────────────────
~ $ tmeta
Welcome to tmeta v0.0.2
> echo "this is the first command"
> echo "this is the second command"
> \find this is the
───────────────────────────────────────────
> this is the █                          
\ 2/8084
  echo "this is the second command"
> echo "this is the first command"

\find 命令是用来搜索历史记录的。它使用 fzf。它的实现方式是这样的:

#= find <phrase> -- Find commands in the history.
my $what = arg($meta);

my $proc = run <<fzf -e --no-sort --layout=reverse -q "$what">>, :in, :out;

$proc.in.put($_)
  for $*log-file.IO.lines.reverse.unique;

my $send = $proc.out.get or return;

confirm-send($send, :add-to-history);

在这里,run 启动 fzf 时有几个选项,而 :in:out 表示我们要连接到标准输入和标准输出。请注意,<<...>> 结构允许我们在一个单词中加引号,并在没有任何转义字符的情况下插值 $what

我们只需将日志文件中的所有行按相反的顺序发送到 fzf,然后等待输出。这是一个很好的例子,说明与外部程序的交互是多么容易。dodosh 的实现也是类似的 - 我们生成自己的程序,并将输出发送到另一个窗格。

用于启动 tmuxshell 命令是使用 shell 的 run 的替代方案。

运行程序

运行程序的方式有几种,包括 runshellProc::Async.new,这取决于你是想同步、异步、使用 shell 还是直接生成一个程序。它们都把标准输入和标准输出变成了 Supply(见上图)。

最后快速看看我们是如何生成文档的。

\help

$
────────────────────────────────────────────────────
> \help clear
     \clear            clear this pane
> \help shell
     \shell            Run a command in a shell
> \help help
     \help             this help
>

生成文档和内联帮助使用相同的代码。下面是它的工作原理。

有两种方法来内省代码和获取文档 - 一种是使用 WHY:

#| Run a command in a shell
method shell {
    ...
}

for self.^methods -> $m {
    my $desc = $m.WHY.Str.trim;
    ...
}

调用 WHY 返回与某事相关联的文档。这被称为声明符 pod,因为它与代码中的声明相关联。#| 表示它与下一个被声明的东西相关联。( #= 表示上一个实体)

声明符 pod

你也可以反其道而行之 - 即给定文档,找到相关实体。WHY 的反义词是 WHEREFORE

for @$=pod -> $p {
    my $method = $p.WHEREFORE;
    my $line = $method.line;
    ...
}

当实现只有一两行的时候,一个方法就显得矫枉过正了,所以看文档比看方法好。如果附近没有声明,我们可以退回到 grepping:

without $file {
    state @lines = $?FILE.words[0].IO.lines;
    $line = @lines.first(:k, {.contains("$pod")})
    $file = $?FILE if $line;
}

without 语句是 if not defined 的简称,state 变量只初始化一次。用 :k - 一个命名参数,有时也叫副词 - 调用 first 方法,意味着不是返回行,而是返回键值对的键 - 对于数组,键值对是索引加上行的对儿 - 也就是说,这将返回行号。

结论

谢谢你读到这里 - 或者如果你没有读到,这里是 tl;dr。

  • Raku 支持多种作用域规则,包括词法、静态的和动态的。
  • 有很多 Raku 方法可以让文字编程变得更加简单有趣。
  • 有方便的惯用法,让线程安全编程更直观。
  • 元编程让编写文档更容易。
  • 有许多线程原语是该语言的一等公民。
  • 进程的生成机制也是内置的。来自这些程序的数据流使用内置的异步结构。
  • expect 命令的精神编写一些自动化的东西可以利用这些特性。

原文链接: https://blog.matatu.org/raku-tmeta

comments powered by Disqus