Skip to content

Latest commit

 

History

History
557 lines (443 loc) · 18.7 KB

util.org

File metadata and controls

557 lines (443 loc) · 18.7 KB

Miscellaneous utility functions for Elvish

Various utility functions.

This file is written in literate programming style, to make it easy to explain. See util.elv for the generated file.

Table of Contents

Usage

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:

Usage: Dotifying strings

util:dotify-string shortens a string to a maximum length, followed by dots.

util:dotify-string somelongstring 5
▶ somel…

Usage: Parallel redirection of stdout/stderr to different commands

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

Usage: Reading a line of input

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 the read-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.'

Usage: Yes-or-no prompts

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!

Usage: Get a filename from the macOS Finder

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'

Usage: Maximum/minimum

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

Usage: Conditionals

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

Usage: Getting nested items from a map structure

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'

Implementation

Dotifying strings

fn dotify-string {|str dotify-length|
  if (or (<= $dotify-length 0) (<= (count $str) $dotify-length)) {
    put $str
  } else {
    put $str[..$dotify-length]'…'
  }
}

Tests

(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)
])

Parallel redirection of stdout/stderr to different commands

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]
  }
}

Tests

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)
])

Reading a line of input

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

Tests

(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)
])

Yes-or-no prompts

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
}

Get a filename from the macOS Finder

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
}

Maximum/minimum

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
}

Tests

(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)
])

Conditionals

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

Tests

(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)
])

Pipeline-or-argument input

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
}

Tests

(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)
])

Functional programming utilities

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

Tests

(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)
])

Getting nested items from a map structure

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
}

Tests

(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)  
])

Fix deprecated functions

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
}

Test suite

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>>
])