Repository: bitfield/script
Pull request #31 (2019-09-28 15:53:09)

Add Basename and Dirname filters
Based on discussion in #27 and implementation work by @stuartherbert in #28.


Patch:
diff --git a/README.md b/README.md
index 03ffd33..7d4e095 100644
--- a/README.md
+++ b/README.md
@@ -85,8 +85,10 @@ script.Args().Concat().Match("Error").First(10).AppendFile("/var/log/errors.txt"
 	- [Slice](#slice)
 	- [Stdin](#stdin)
 - [Filters](#filters)
+	- [Basename](#basename)
 	- [Column](#column)
 	- [Concat](#concat)
+	- [Dirname](#dirname)
 	- [EachLine](#eachline)
 	- [Exec](#exec-1)
 	- [First](#first)
@@ -278,8 +280,10 @@ If you're already familiar with shell scripting and the Unix toolset, here is a
 | `>`                | [`WriteFile()`](#writefile)                                   |
 | `>>`               | [`AppendFile()`](#appendfile)                                 |
 | `$*`               | [`Args()`](#args)                                             |
+| `basename`         | [`Basename()`](#basename)                                     |
 | `cat`              | [`File()`](#file) / [`Concat()`](#concat)                     |
 | `cut`              | [`Column()`](#column)                                         |
+| `dirname`          | [`Dirname()`](#dirname)                                       |
 | `echo`             | [`Echo()`](#echo)                                             |
 | `grep`             | [`Match()`](#match) / [`MatchRegexp()`](#matchregexp)         |
 | `grep -v`          | [`Reject()`](#reject) / [`RejectRegexp()`](#rejectregexp)     |
@@ -445,6 +449,24 @@ fmt.Println(output)
 
 Filters are operations on an existing pipe that also return a pipe, allowing you to chain filters indefinitely.
 
+## Basename
+
+`Basename()` reads a list of filepaths from the pipe, one per line, and removes any leading directory components from each line (so, for example, `/usr/local/bin/foo` would become just `foo`). This is the complement of [Dirname](#dirname).
+
+If a line is empty, `Basename()` will produce a single dot: `.`. Trailing slashes are removed.
+
+Examples:
+
+| Input              | `Basename` output |
+| ------------------ | ----------------- |
+|                    | `.`               |
+| `/`                | `.`               |
+| `/root`            | `root`            |
+| `/tmp/example.php` | `example.php`     |
+| `/var/tmp/`        | `tmp`             |
+| `./src/filters`    | `filters`         |
+| `C:/Program Files` | `Program Files`   |
+
 ## Column
 
 `Column()` reads input tabulated by whitespace, and outputs only the Nth column of each input line (like Unix `cut`). Lines containing less than N columns will be ignored.
@@ -508,6 +530,26 @@ p := Exec("ls /var/app/config/").Concat().Stdout()
 
 Each input file will be closed once it has been fully read.
 
+## Dirname
+
+`Dirname()` reads a list of pathnames from the pipe, one per line, and returns a pipe which contains only the parent directories of each pathname (so, for example, `/usr/local/bin/foo` would become just `/usr/local/bin`). This is the complement of [Basename](#basename).
+
+If a line is empty, `Dirname()` will convert it to a single dot: `.` (this is the behaviour of Unix `dirname` and the Go standard library's `filepath.Dir`).
+
+Trailing slashes are removed, unless `Dirname()` returns the root folder.
+
+Examples:
+
+| Input              | `Dirname` output |
+| ------------------ | ---------------- |
+|                    | `.`              |
+| `/`                | `/`              |
+| `/root`            | `/`              |
+| `/tmp/example.php` | `/tmp`           |
+| `/var/tmp/`        | `/var`           |
+| `./src/filters`    | `./src`          |
+| `C:/Program Files` | `C:`             |
+
 ## EachLine
 
 `EachLine()` lets you create custom filters. You provide a function, and it will be called once for each line of input. If you want to produce output, your function can write to a supplied `strings.Builder`. The return value from EachLine is a pipe containing your output.
diff --git a/filters.go b/filters.go
index 4c472b1..e4cfa1e 100644
--- a/filters.go
+++ b/filters.go
@@ -8,12 +8,26 @@ import (
 	"io"
 	"os"
 	"os/exec"
+	"path/filepath"
 	"regexp"
 	"sort"
 	"strconv"
 	"strings"
 )
 
+// Basename reads a list of filepaths from the pipe, one per line, and removes
+// any leading directory components from each line. If a line is empty, Basename
+// will produce '.'. Trailing slashes are removed.
+func (p *Pipe) Basename() *Pipe {
+	if p == nil || p.Error() != nil {
+		return p
+	}
+	return p.EachLine(func(line string, out *strings.Builder) {
+		out.WriteString(filepath.Base(line))
+		out.WriteRune('\n')
+	})
+}
+
 // Column reads from the pipe, and returns a new pipe containing only the Nth
 // column of each line in the input, where '1' means the first column, and
 // columns are delimited by whitespace. Specifically, whatever Unicode defines
@@ -49,6 +63,29 @@ func (p *Pipe) Concat() *Pipe {
 	return p.WithReader(io.MultiReader(readers...))
 }
 
+// Dirname reads a list of pathnames from the pipe, one per line, and returns a
+// pipe which contains only the parent directories of each pathname. If a line
+// is empty, Dirname will produce a '.'. Trailing slashes are removed, unless
+// Dirname returns the root folder.
+func (p *Pipe) Dirname() *Pipe {
+	if p == nil || p.Error() != nil {
+		return p
+	}
+	return p.EachLine(func(line string, out *strings.Builder) {
+		// filepath.Dir() does not handle trailing slashes correctly
+		if len(line) > 1 && strings.HasSuffix(line, "/") {
+			line = line[0 : len(line)-1]
+		}
+		dirname := filepath.Dir(line)
+		// filepath.Dir() does not preserve a leading './'
+		if len(dirname) > 1 && strings.HasPrefix(line, "./") {
+			dirname = "./" + dirname
+		}
+		out.WriteString(dirname)
+		out.WriteRune('\n')
+	})
+}
+
 // EachLine calls the specified function for each line of input, passing it the
 // line as a string, and a *strings.Builder to write its output to. The return
 // value from EachLine is a pipe containing the contents of the strings.Builder.


Test patch:
diff --git a/filters_test.go b/filters_test.go
index 70a2106..cba4cc9 100644
--- a/filters_test.go
+++ b/filters_test.go
@@ -9,87 +9,6 @@ import (
 	"testing"
 )
 
-// doFiltersOnPipe calls every kind of filter method on the supplied pipe and
-// tries to trigger a panic.
-func doFiltersOnPipe(t *testing.T, p *Pipe, kind string) {
-	var action string
-	defer func() {
-		if r := recover(); r != nil {
-			t.Errorf("panic: %s on %s pipe", action, kind)
-		}
-	}()
-	// also tests methods that wrap EachLine, such as Match*/Reject*
-	action = "EachLine()"
-	output, err := p.EachLine(func(string, *strings.Builder) {}).String()
-	if err != nil {
-		t.Error(err)
-	}
-	if output != "" {
-		t.Errorf("want zero output from %s on %s pipe, but got %q", action, kind, output)
-	}
-	action = "Exec()"
-	output, err = p.Exec("bogus").String()
-	if err != nil && kind == "nil" {
-		t.Errorf("%s on %s pipe: %v", action, kind, err)
-	}
-	if err == nil && kind == "zero" {
-		t.Errorf("want error from %s on %s pipe, but got nil", action, kind)
-	}
-	if output != "" {
-		t.Errorf("want zero output from %s on %s pipe, but got %q", action, kind, output)
-	}
-	action = "Concat()"
-	output, err = p.Concat().String()
-	if err != nil {
-		t.Errorf("%s on %s pipe: %v", action, kind, err)
-	}
-	if output != "" {
-		t.Errorf("want zero output from %s on %s pipe, but got %q", action, kind, output)
-	}
-	action = "First()"
-	output, err = p.First(1).String()
-	if err != nil {
-		t.Errorf("%s on %s pipe: %v", action, kind, err)
-	}
-	if output != "" {
-		t.Errorf("want zero output from %s on %s pipe, but got %q", action, kind, output)
-	}
-	action = "Freq()"
-	output, err = p.Freq().String()
-	if err != nil {
-		t.Errorf("%s on %s pipe: %v", action, kind, err)
-	}
-	if output != "" {
-		t.Errorf("want zero output from %s on %s pipe, but got %q", action, kind, output)
-	}
-	action = "Join()"
-	output, err = p.Join().String()
-	if err != nil {
-		t.Errorf("%s on %s pipe: %v", action, kind, err)
-	}
-	if output != "" {
-		t.Errorf("want zero output from %s on %s pipe, but got %q", action, kind, output)
-	}
-	action = "Last()"
-	output, err = p.Last(1).String()
-	if err != nil {
-		t.Errorf("%s on %s pipe: %v", action, kind, err)
-	}
-	if output != "" {
-		t.Errorf("want zero output from %s on %s pipe, but got %q", action, kind, output)
-	}
-}
-
-func TestNilPipeFilters(t *testing.T) {
-	t.Parallel()
-	doFiltersOnPipe(t, nil, "nil")
-}
-
-func TestZeroPipeFilters(t *testing.T) {
-	t.Parallel()
-	doFiltersOnPipe(t, &Pipe{}, "zero")
-}
-
 func TestMatch(t *testing.T) {
 	t.Parallel()
 	testCases := []struct {
@@ -424,3 +343,71 @@ func TestColumn(t *testing.T) {
 		t.Errorf("want %q, got %q", want, got)
 	}
 }
+
+func TestBasename(t *testing.T) {
+	t.Parallel()
+	testCases := []struct {
+		testFileName string
+		want         string
+	}{
+		{"\n", ".\n"},
+		{"/", "/\n"},
+		{"/root", "root\n"},
+		{"/tmp/example.php", "example.php\n"},
+		{"./src/filters", "filters\n"},
+		{"/var/tmp/example.php", "example.php\n"},
+		{"/tmp/script-21345.txt\n/tmp/script-5371253.txt", "script-21345.txt\nscript-5371253.txt\n"},
+		{"C:/Program Files", "Program Files\n"},
+		{"C:/Program Files/", "Program Files\n"},
+	}
+	for _, tc := range testCases {
+		got, err := Echo(tc.testFileName).Basename().String()
+		if err != nil {
+			t.Error(err)
+		}
+		if got != tc.want {
+			t.Errorf("%q: want %q, got %q", tc.testFileName, tc.want, got)
+		}
+	}
+}
+
+func TestDirname(t *testing.T) {
+	t.Parallel()
+	testCases := []struct {
+		testFileName string
+		want         string
+	}{
+		{"/", "/\n"},
+		{"\n", ".\n"},
+		{"/root", "/\n"},
+		{"/tmp/example.php", "/tmp\n"},
+		{"/var/tmp/example.php", "/var/tmp\n"},
+		{"/var/tmp", "/var\n"},
+		{"/var/tmp/", "/var\n"},
+		{"./src/filters", "./src\n"},
+		{"./src/filters/", "./src\n"},
+		{"/tmp/script-21345.txt\n/tmp/script-5371253.txt", "/tmp\n/tmp\n"},
+		{"C:/Program Files/PHP", "C:/Program Files\n"},
+		{"C:/Program Files/PHP/", "C:/Program Files\n"},
+		{"C:/Program Files", "C:\n"},
+
+		// NOTE:
+		// there are no tests for Windows-style directory separators,
+		// because these are not supported by filepath at this time
+		//
+		// the following test data currently does not work with the
+		// Golang filepath library:
+		//
+		// {"C:\\Program Files\\PHP", "C:\\Program Files\n"},
+		// {"C:\\Program Files\\PHP\\", "C:\\Program Files\n"},
+	}
+	for _, tc := range testCases {
+		got, err := Echo(tc.testFileName).Dirname().String()
+		if err != nil {
+			t.Error(err)
+		}
+		if got != tc.want {
+			t.Errorf("%q: want %q, got %q", tc.testFileName, tc.want, got)
+		}
+	}
+}
diff --git a/pipes_test.go b/pipes_test.go
index 7a8aa16..373be47 100644
--- a/pipes_test.go
+++ b/pipes_test.go
@@ -91,6 +91,8 @@ func doMethodsOnPipe(t *testing.T, p *Pipe, kind string) {
 	defer os.Remove("testdata/bogus.txt")
 	action = "AppendFile()"
 	p.AppendFile("testdata/bogus.txt")
+	action = "Basename()"
+	p.Basename()
 	action = "Bytes()"
 	p.Bytes()
 	action = "Close()"
@@ -101,6 +103,10 @@ func doMethodsOnPipe(t *testing.T, p *Pipe, kind string) {
 	p.Concat()
 	action = "CountLines()"
 	p.CountLines()
+	action = "Dirname()"
+	p.Dirname()
+	action = "EachLine()"
+	p.EachLine(func(string, *strings.Builder) {})
 	action = "Error()"
 	p.Error()
 	action = "Exec()"
@@ -113,6 +119,8 @@ func doMethodsOnPipe(t *testing.T, p *Pipe, kind string) {
 	p.Freq()
 	action = "Join()"
 	p.Join()
+	action = "Last()"
+	p.Last(1)
 	action = "Match()"
 	p.Match("foo")
 	action = "MatchRegexp()"