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
可以调整行间的延迟。
delay
和 send
的实现是这样的:
#= 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
使用空格分隔输入。val
将Str
转化为IntStr
。IntStr
是一个异构类型,根据上下文的不同,它表现为Int
或Str
。- 调用
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
,然后等待输出。这是一个很好的例子,说明与外部程序的交互是多么容易。do
和 dosh
的实现也是类似的 - 我们生成自己的程序,并将输出发送到另一个窗格。
用于启动 tmux
的 shell
命令是使用 shell 的 run
的替代方案。
运行程序
运行程序的方式有几种,包括 run
、shell
、Proc::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 命令的精神编写一些自动化的东西可以利用这些特性。