Repository: bitfield/script
Pull request #9 (2019-06-29 15:03:47)

Add First filter
See #7 for a discussion and use case.


Patch:
diff --git a/README.md b/README.md
index 269c8af..710d308 100644
--- a/README.md
+++ b/README.md
@@ -40,10 +40,16 @@ That was almost too easy! So let's pass in a list of files on the command line,
 script.Args().Concat().Match("Error").Stdout()
 ```
 
-What's that? You want to append that output to a file instead of printing it to the terminal? No problem:
+Maybe we're only interested in the first 10 matches. No problem:
 
 ```go
-script.Args().Concat().Match("Error").AppendFile("/var/log/errors.txt")
+script.Args().Concat().Match("Error").First(10).Stdout()
+```
+
+What's that? You want to append that output to a file instead of printing it to the terminal? _You've got some attitude, mister_.
+
+```go
+script.Args().Concat().Match("Error").First(10).AppendFile("/var/log/errors.txt")
 ```
 
 ## How does it work?
@@ -323,6 +329,14 @@ fmt.Println(output)
 // Output: hello world
 ```
 
+### First
+
+`First()` reads its input and passes on the first N lines of it (like Unix [`head`](examples/head/main.go)):
+
+```go
+Stdin().First(10).Stdout()
+```
+
 ### Join
 
 `Join()` reads its input and replaces newlines with spaces, preserving a terminating newline if there is one.
@@ -567,11 +581,12 @@ These are some ideas I'm playing with for additional features. If you feel like
 
 ### Filters
 
-* Ideas welcome!
+* `Column()` reads columnar (whitespace-separated) data and cuts the specified column, like Unix `cut`
+* `CountFreq()` counts the frequency of input lines, and prepends each unique line with its frequency (like Unix `uniq -c`). The results are sorted in descending numerical order (that is, most frequent lines first).
 
 ### Sinks
 
-* Ideas equally welcome!
+* [Ideas welcome!](https://github.com/bitfield/script/issues/new)
 
 ### Examples
 
@@ -580,10 +595,13 @@ Since `script` is designed to help you write system administration programs, a f
 * [cat](examples/cat/main.go) (copies stdin to stdout)
 * [cat 2](examples/cat2/main.go) (takes a list of files on the command line and concatenates their contents to stdout)
 * [grep](examples/grep/main.go)
+* [head](examples/head/main.go)
 * [echo](examples/echo/main.go)
 
-More examples would be welcome!
+[More examples would be welcome!](https://github.com/bitfield/script/pulls)
 
 ### Use cases
 
-The best libraries are designed to satisfy real use cases. If you have a sysadmin task which you'd like to implement with `script`, let me know by opening an issue.
+The best libraries are designed to satisfy real use cases. If you have a sysadmin task which you'd like to implement with `script`, let me know by [opening an issue](https://github.com/bitfield/script/issues/new) - I'd love to hear from you.
+
+If you use `script` for real work (or, for that matter, real play), I'm always very interested to hear about it. Drop me a line to john@bitfieldconsulting.com and tell me how you're using `script` and what you think of it!
\ No newline at end of file
diff --git a/examples/head/main.go b/examples/head/main.go
new file mode 100644
index 0000000..bf15466
--- /dev/null
+++ b/examples/head/main.go
@@ -0,0 +1,17 @@
+package main
+
+import (
+	"log"
+	"os"
+	"strconv"
+
+	"github.com/bitfield/script"
+)
+
+func main() {
+	lines, err := strconv.Atoi(os.Args[1])
+	if err != nil {
+		log.Fatal(err)
+	}
+	script.Stdin().First(lines).Stdout()
+}
diff --git a/filters.go b/filters.go
index 1826adb..87b3ba6 100644
--- a/filters.go
+++ b/filters.go
@@ -134,3 +134,26 @@ func (p *Pipe) Concat() *Pipe {
 	})
 	return p.WithReader(io.MultiReader(readers...))
 }
+
+// First reads from the pipe, and returns a new pipe containing only the first N
+// lines. If there is an error reading the pipe, the pipe's error status is also
+// set.
+func (p *Pipe) First(lines int) *Pipe {
+	if p == nil || p.Error() != nil {
+		return p
+	}
+	scanner := bufio.NewScanner(p.Reader)
+	output := strings.Builder{}
+	for i := 0; i < lines; i++ {
+		if !scanner.Scan() {
+			break
+		}
+		output.WriteString(scanner.Text())
+		output.WriteRune('\n')
+	}
+	err := scanner.Err()
+	if err != nil {
+		p.SetError(err)
+	}
+	return Echo(output.String())
+}
diff --git a/pipes.go b/pipes.go
index 467650a..22399d0 100644
--- a/pipes.go
+++ b/pipes.go
@@ -56,8 +56,9 @@ func (p *Pipe) ExitStatus() int {
 	return status
 }
 
-// WithReader takes an io.Reader which does not need to be closed after reading,
-// and associates the pipe with that reader.
+// WithReader takes an io.Reader, and associates the pipe with that reader. If
+// necessary, the reader will be automatically closed once it has been
+// completely read.
 func (p *Pipe) WithReader(r io.Reader) *Pipe {
 	if p == nil {
 		return p


Test patch:
diff --git a/filters_test.go b/filters_test.go
index cda92b0..15380c4 100644
--- a/filters_test.go
+++ b/filters_test.go
@@ -219,3 +219,39 @@ func TestConcat(t *testing.T) {
 		t.Errorf("want %q, got %q", want, got)
 	}
 }
+
+func TestFirst(t *testing.T) {
+	t.Parallel()
+	want, err := ioutil.ReadFile("testdata/first10.golden.txt")
+	if err != nil {
+		t.Fatal(err)
+	}
+	got, err := File("testdata/first.input.txt").First(10).Bytes()
+	if err != nil {
+		t.Error(err)
+	}
+	if !bytes.Equal(got, want) {
+		t.Errorf("want %q, got %q", want, got)
+	}
+	// First(0) should return zero lines
+	zero := File("testdata/first.input.txt").First(0)
+	gotZero, err := zero.CountLines()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if gotZero != 0 {
+		t.Errorf("want 0 lines, got %d lines", gotZero)
+	}
+	// First(N) where the input has less than N lines, should just return the input.
+	want, err = File("testdata/first.input.txt").Bytes()
+	if err != nil {
+		t.Fatal(err)
+	}
+	got, err = File("testdata/first.input.txt").First(100).Bytes()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !bytes.Equal(got, want) {
+		t.Errorf("want %q, got %q", want, got)
+	}
+}
diff --git a/pipes_test.go b/pipes_test.go
index 5f03340..5e93668 100644
--- a/pipes_test.go
+++ b/pipes_test.go
@@ -100,6 +100,8 @@ func doMethodsOnPipe(t *testing.T, p *Pipe, kind string) {
 	p.WithError(nil)
 	action = "Read()"
 	p.Read([]byte{})
+	action = "First()"
+	p.First(1)
 }
 
 func TestNilPipes(t *testing.T) {
diff --git a/testdata/first.input.txt b/testdata/first.input.txt
new file mode 100644
index 0000000..9522f81
--- /dev/null
+++ b/testdata/first.input.txt
@@ -0,0 +1,18 @@
+The tao that can be told
+is not the eternal Tao
+The name that can be named
+is not the eternal Name.
+
+The unnamable is the eternally real.
+Naming is the origin
+of all particular things.
+
+Free from desire, you realize the mystery.
+Caught in desire, you see only the manifestations.
+
+Yet mystery and manifestations
+arise from the same source.
+This source is called darkness.
+
+Darkness within darkness.
+The gateway to all understanding.
diff --git a/testdata/first10.golden.txt b/testdata/first10.golden.txt
new file mode 100644
index 0000000..cc2da8c
--- /dev/null
+++ b/testdata/first10.golden.txt
@@ -0,0 +1,10 @@
+The tao that can be told
+is not the eternal Tao
+The name that can be named
+is not the eternal Name.
+
+The unnamable is the eternally real.
+Naming is the origin
+of all particular things.
+
+Free from desire, you realize the mystery.