Various utility functions.
This file is written in literate programming style, to make it easy to explain. See util.elv for the generated file.
- Usage
- Implementation
- Dotifying strings
- Parallel redirection of stdout/stderr to different commands
- Reading a line of input
- Yes-or-no prompts
- Get a filename from the macOS Finder
- Maximum/minimum
- Conditionals
- Pipeline-or-argument input
- Functional programming utilities
- Getting nested items from a map structure
- Fix deprecated functions
- Test suite
Install the elvish-modules
package using epm:
use epm
epm:install github.com/zzamboni/elvish-modules
In your rc.elv
, load this module:
use github.com/zzamboni/elvish-modules/util
The following functions are included:
util:dotify-string
shortens a string to a maximum length, followed by dots.
util:dotify-string somelongstring 5
▶ somel…
util:pipesplit
does parallel redirection of stdout and stderr to different commands. It takes three lambdas: The first one is executed, its stdout is redirected to the second one, and its stderr to the third one.
util:pipesplit { echo stdout-test; echo stderr-test >&2 } { echo STDOUT: (cat) } { echo STDERR: (cat) }
STDOUT: stdout-test STDERR: stderr-test
util:readline
reads a line from the current input pipe, until the first end of line. Depending on the version of Elvish you have, it either uses an external command, or an internal function, but the result is the same.
echo "hi there\nhow are you" | util:readline
▶ 'hi there'
util:readline
can take some optional arguments:
&eol
: the end-of-line character to use (defaults to newline). This argument is only fully functional in newer versions of Elvish (after 0.13), which have theread-upto
function.&nostrip
: whether to strip the EOL character from the end of the string. Defaults to$false
, which means the EOL character is stripped.&prompt
: optional prompt to print before reading the input. If a prompt is specified, then input/output is forced from the terminal (/dev/tty
) instead of the input pipe, since the existence of a prompt presumes interactive use.
echo "hi there\nhow are you" | util:readline &nostrip
echo "hi there.how are you" | util:readline &eol=.
echo "hi there.how are you" | util:readline &eol=. &nostrip
▶ "hi there\n" ▶ 'hi there' ▶ 'hi there.'
y-or-n
receives a prompt string, shows the prompt to the user and accepts y
or n
as an answer. Returns $true
if the user responds with y
. The &style
option can be used to specify the style for the prompt, as accepted by styled.
[~]─> util:y-or-n "Do you agree?"
Do you agree? [y/n] y
▶ $true
[~]─> util:y-or-n &style=red "Is this green?"
Is this green? [y/n] n
▶ $false
Typical use is as part of an if
statement:
[~]─> if (util:y-or-n "Are you OK?") { echo "Great!" }
Are you OK? [y/n] y
Great!
On macOS, dragging a file from the Finder into a Terminal window results in its path being pasted. Unfortunately it gets pasted with escape characters which Elvish does not always interpret correctly, and also with an extra space at the end. The util:getfile
function can be used instead of the filename, and when you drag the file it captures and fixes the filename.
[~]─> util:getfile
Drop a file here: /System/Library/Address\ Book\ Plug-Ins
# (The pathname is entered by drag-and-dropping a file from the Finder)
▶ '/System/Library/Address Book Plug-Ins'
Typical use is in place of the filename you want to drag into the Terminal:
[~]─> ls -ld (util:getfile)
Drop a file here: /System/Library/Address\ Book\ Plug-Ins
drwxr-xr-x 8 root wheel 256 Oct 25 18:16 '/System/Library/Address Book Plug-Ins'
Return the maximum/minimum in a list of numbers. If the &with
option is provided, it must be a function which receives on argument and returns a number, which is used for the comparison instead of the actual values. In this case, the list elements can be anything, as long as the &with
function returns a numeric value.
util:max 3 4 5 2 -1 4 0
util:min 3 4 5 2 -1 4 0
util:max a bc def ghijkl &with=$count~
util:min a bc def ghijkl &with=$count~
▶ 5 ▶ -1 ▶ ghijkl ▶ a
util:cond
emulates Clojure’s cond function. It receives a list of expression value pairs. Puts the first value whose expression is a true value, if any. Expressions can be closures (in which case they are executed and their return value used) or other types, which are used as-is. Values are always returned as-is, even if they are closures.
In the example below, the values are scalars. Note that :else
has no special significance - it’s simply evaluated as a string, which represents a “booleanly true” value. Any other true value (e.g. :default
, $true
, etc.) could be used.
fn pos-neg-or-zero [n]{
util:cond [
{ < $n 0 } "negative"
{ > $n 0 } "positive"
:else "zero"
]
}
pos-neg-or-zero 5
pos-neg-or-zero -1
pos-neg-or-zero 0
▶ positive ▶ negative ▶ zero
path-in
follows a “path” within a nested map structure and gives you the element at the end.
util:path-in [&a=[&b=[&c=foo]]] [a b]
util:path-in [&a=[&b=[&c=foo]]] [a b c]
util:path-in [&a=[&b=[&c=foo]]] [a b d]
util:path-in [&a=[&b=[&c=foo]]] [a b d] &default="not found"
▶ [&c=foo] ▶ foo ▶ $nil ▶ 'not found'
fn dotify-string {|str dotify-length|
if (or (<= $dotify-length 0) (<= (count $str) $dotify-length)) {
put $str
} else {
put $str[..$dotify-length]'…'
}
}
(test:set dotify-string [
(test:is { util:dotify-string "somelongstring" 5 } "somel…" Long string gets dotified)
(test:is { util:dotify-string "short" 5 } "short" Equal-as-limit string stays the same)
(test:is { util:dotify-string "bah" 5 } "bah" Short string stays the same)
])
The implementation of this function was inspired by the discussion in this issue.
use file
fn pipesplit {|l1 l2 l3|
var pout = (file:pipe)
var perr = (file:pipe)
run-parallel {
$l1 > $pout 2> $perr
file:close $pout[w]
file:close $perr[w]
} {
$l2 < $pout
file:close $pout[r]
} {
$l3 < $perr
file:close $perr[r]
}
}
We sort the output of pipesplit
because the functions run in parallel, to ensure a predictable order.
(test:set pipesplit [
(test:is { put [(util:pipesplit { echo stdout; echo stderr >&2 } { echo STDOUT: (cat) } { echo STDERR: (cat) } | sort)] } ["STDERR: stderr" "STDOUT: stdout"] Parallel redirection)
])
The base of reading a line of input is a low-level function which reads the actual text. We define a default version of the -read-upto-eol
function which uses the external head
command to read a line. Note that this version does not respect the value of $eol
, since the end of line is always marked by a newline.
var -read-upto-eol~ = {|eol| put (head -n1) }
However, in recent versions of Elvish, the read-upto
function can be used to read a line of text without invoking an external command, and can make proper use of different $eol
values (default is still newline).
use builtin
if (has-key $builtin: read-upto~) {
set -read-upto-eol~ = {|eol| read-upto $eol }
}
Finally, we build the util:readline
function on top of -read-upto-eol
. This function was written by and is included here with the kind permission of Harald Hanche-Olsen. Note that if &prompt
is specified, all input/output is forced to /dev/tty
, as the existence of a prompt implies interactive use. Otherwise input is read from stdin.
fn readline {|&eol="\n" &nostrip=$false &prompt=$nil|
if $prompt {
print $prompt > /dev/tty
}
var line = (if $prompt {
-read-upto-eol $eol < /dev/tty
} else {
-read-upto-eol $eol
})
if (and (not $nostrip) (!=s $line '') (==s $line[-1..] $eol)) {
put $line[..-1]
} else {
put $line
}
}
(test:set readline [
(test:is { echo "line1\nline2" | util:readline } line1 Readline)
(test:is { echo "line1\nline2" | util:readline &nostrip } "line1\n" Readline with nostrip)
(test:is { echo | util:readline } '' Readline empty line)
(test:is { echo "line1.line2" | util:readline &eol=. } line1 Readline with different EOL)
(test:is { echo "line1.line2" | util:readline &eol=. &nostrip } line1. Readline with different EOL)
])
fn y-or-n {|&style=default prompt|
set prompt = $prompt" [y/n] "
if (not-eq $style default) {
set prompt = (styled $prompt $style)
}
print $prompt > /dev/tty
var resp = (readline)
eq $resp y
}
Thanks to @hanche in the Elvish channel, a short utility to convert a filename as dragged-and-dropped from the Finder into a usable filename.
fn getfile {
use re
print 'Drop a file here: ' >/dev/tty
var fname = (read-line)
each {|p|
set fname = (re:replace $p[0] $p[1] $fname)
} [['\\(.)' '$1'] ['^''' ''] ['\s*$' ''] ['''$' '']]
put $fname
}
Choose the maximum and minimum numbers from the given list.
fn max {|a @rest &with={|v|put $v}|
var res = $a
var val = ($with $a)
each {|n|
var nval = ($with $n)
if (> $nval $val) {
set res = $n
set val = $nval
}
} $rest
put $res
}
fn min {|a @rest &with={|v|put $v}|
var res = $a
var val = ($with $a)
each {|n|
var nval = ($with $n)
if (< $nval $val) {
set res = $n
set val = $nval
}
} $rest
put $res
}
(test:set max-min [
(test:is { util:max 1 2 3 -1 5 0 } 5 Maximum)
(test:is { util:min 1 2 3 -1 5 0 } -1 Minimum)
(test:is { util:max a bc def ghijkl &with=$count~ } ghijkl Maximum with function)
(test:is { util:min a bc def ghijkl &with=$count~ } a Minimum with function)
])
We simply step through the expression value pairs, and put
the first value whose expression (or its result, if it’s a closure) returns true.
fn cond {|clauses|
range &step=2 (count $clauses) | each {|i|
var exp = $clauses[$i]
if (eq (kind-of $exp) fn) { set exp = ($exp) }
if $exp {
put $clauses[(+ $i 1)]
return
}
}
}
(test:set cond [
(test:is { util:cond [ $false no $true yes ] } yes Conditional with constant test)
(test:is { util:cond [ $false no { eq 1 1 } yes ] } yes Conditional with function test)
(test:is { util:cond [ $false no { eq 0 1 } yes :else final ] } final Default option with :else)
(test:is { put [(util:cond [ $false no ])] } [] No conditions match, no output)
(test:is { put [(util:cond [ ])] } [] Empty conditions, no output)
(test:is { util:cond [ { eq 1 1 } $eq~ ] } $eq~ Return value is a function)
])
util:optional-input
gets optional pipeline input for any function, mimicking the behavior of each
. If an argument is given, it is interpreted as an array and its contents is used as the input. Otherwise, it reads the input from the pipeline using all
. Returns the data as an array
fn optional-input {|@input|
if (eq $input []) {
set input = [(all)]
} elif (== (count $input) 1) {
set input = [ (all $input[0]) ]
} else {
fail "util:optional-input: want 0 or 1 arguments, got "(count $input)
}
put $input
}
(test:set optional-input [
(test:is { util:optional-input [foo bar] } [foo bar] Input from list)
(test:is { put foo bar baz | util:optional-input } [foo bar baz] Input from pipeline)
(test:is { put | util:optional-input } [] Empty input)
])
util:select
and util:remove
filter those for which the provided closure is true/false.
fn select {|p @input|
each {|i| if ($p $i) { put $i} } (optional-input $@input)
}
fn remove {|p @input|
each {|i| if (not ($p $i)) { put $i} } (optional-input $@input)
}
util:partial
, build a partial function call.
fn partial {|f @p-args|
put {|@args|
$f $@p-args $@args
}
}
(test:set select-and-remove [
(test:is { put [(util:select {|n| eq $n 0 } [ 3 2 0 2 -1 ])] } [0] Select zeros from a list)
(test:is { put [(util:remove {|n| eq $n 0 } [ 3 2 0 2 -1 ])] } [3 2 2 -1] Remove zeros from a list)
])
(test:set partial [
(test:is { (util:partial $'+~' 3) 5 } (num 8) Partial addition)
(test:is { (util:partial $eq~ 3) 3 } $true Partial eq)
(test:is { (util:partial {|@args| * $@args } 1 2) 3 4 5 } (num 120) Partial custom function with rest arg)
])
path-in
finds an element within nested map structure $obj
, following the keys contained in the list $path
. If not found, return &default
.
fn path-in {|obj path &default=$nil|
each {|k|
try {
set obj = $obj[$k]
} catch {
set obj = $default
break
}
} $path
put $obj
}
(test:set select-and-remove [
(test:is { util:path-in [&a=[&b=[&c=foo]]] [a b] } [&c=foo] Middle element from nested map)
(test:is { util:path-in [&a=[&b=[&c=foo]]] [a b c] } foo Leaf element from nested map)
(test:is { util:path-in [&a=[&b=[&c=foo]]] [a b d] } $nil Non-existing path in nested map)
(test:is { util:path-in &default="not found" [&a=[&b=[&c=foo]]] [a b d] } 'not found' Non-existing element with custom default value)
])
Takes a single file, and replaces all occurrences of deprecated functions by their replacements.
Note: this does dumb string replacement. Please check the result to make sure there are no unintended replacements. Also, you still need to manually add use str
at the top of the files where any of the str:
functions are introduced.
use str
fn fix-deprecated {|f|
var deprecated = [
&all= all
&str:join= str:join
&str:split= str:split
&str:replace= str:replace
]
var sed-cmd = (str:join "; " [(keys $deprecated | each {|d| put "s/"$d"/"$deprecated[$d]"/" })])
sed -i '' -e $sed-cmd $f
}
All the test cases above are collected by the <<tests>>
stanza below, and stored in the file util_test.elv
, which can be executed as follows:
elvish util_test.elv
use github.com/zzamboni/elvish-modules/test
use github.com/zzamboni/elvish-modules/util
(test:set github.com/zzamboni/elvish-modules/util [
<<tests>>
])