Add io.Reader/io.Writer support to Format (#96)

diff --git a/tfexec/fmt.go b/tfexec/fmt.go
index d80a39b..de30890 100644
--- a/tfexec/fmt.go
+++ b/tfexec/fmt.go
@@ -4,6 +4,7 @@
 	"bytes"
 	"context"
 	"fmt"
+	"io"
 	"os/exec"
 	"path/filepath"
 	"strings"
@@ -42,22 +43,27 @@
 
 // FormatString formats a passed string.
 func (tf *Terraform) FormatString(ctx context.Context, content string) (string, error) {
+	in := strings.NewReader(content)
+	var outBuf bytes.Buffer
+	err := tf.Format(ctx, in, &outBuf)
+	if err != nil {
+		return "", err
+	}
+	return outBuf.String(), nil
+}
+
+// Format performs formatting on the unformatted io.Reader (as stdin to the CLI) and returns
+// the formatted result on the formatted io.Writer.
+func (tf *Terraform) Format(ctx context.Context, unformatted io.Reader, formatted io.Writer) error {
 	cmd, err := tf.formatCmd(ctx, nil, Dir("-"))
 	if err != nil {
-		return "", err
+		return err
 	}
 
-	cmd.Stdin = strings.NewReader(content)
+	cmd.Stdin = unformatted
+	cmd.Stdout = mergeWriters(cmd.Stdout, formatted)
 
-	var outBuf bytes.Buffer
-	cmd.Stdout = mergeWriters(cmd.Stdout, &outBuf)
-
-	err = tf.runTerraformCmd(cmd)
-	if err != nil {
-		return "", err
-	}
-
-	return outBuf.String(), nil
+	return tf.runTerraformCmd(cmd)
 }
 
 // FormatWrite attempts to format and modify all config files in the working or selected (via DirOption) directory.
diff --git a/tfexec/internal/e2etest/fmt_test.go b/tfexec/internal/e2etest/fmt_test.go
index f081b00..c1622de 100644
--- a/tfexec/internal/e2etest/fmt_test.go
+++ b/tfexec/internal/e2etest/fmt_test.go
@@ -1,11 +1,15 @@
 package e2etest
 
 import (
+	"bytes"
 	"context"
+	"io"
+	"io/ioutil"
 	"path/filepath"
 	"reflect"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/hashicorp/go-version"
 
@@ -84,3 +88,89 @@
 		}
 	})
 }
+
+func TestFormat(t *testing.T) {
+	runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+		unformatted := strings.TrimSpace(`
+resource     "foo"      "bar" {
+	baz = 1
+		qux      =        2
+}
+`)
+
+		expected := strings.TrimSpace(`
+resource "foo" "bar" {
+  baz = 1
+  qux = 2
+}
+`)
+
+		start := time.Now()
+		var actual bytes.Buffer
+		err := tf.Format(context.Background(), strings.NewReader(unformatted), &actual)
+		if err != nil {
+			t.Fatal(err)
+		}
+		duration := time.Since(start)
+		t.Logf("formatting took %dms", duration.Milliseconds())
+
+		actualString := strings.TrimSpace(actual.String())
+		if actualString != expected {
+			t.Fatalf("expected:\n%s\ngot:\n%s\n", expected, actualString)
+		}
+	})
+}
+
+func TestFormat_warmFormatter(t *testing.T) {
+	runTest(t, "", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+		unformatted := strings.TrimSpace(`
+resource     "foo"      "bar" {
+	baz = 1
+		qux      =        2
+}
+`)
+
+		expected := strings.TrimSpace(`
+resource "foo" "bar" {
+  baz = 1
+  qux = 2
+}
+`)
+
+		inR, inW := io.Pipe()
+		outR, outW := io.Pipe()
+
+		go func() {
+			err := tf.Format(context.Background(), inR, outW)
+			if err != nil {
+				outW.CloseWithError(err)
+			}
+			_ = outW.Close()
+		}()
+
+		t.Log("Sleeping while CLI is warmed...")
+		time.Sleep(5 * time.Second)
+		t.Log("Sending unformatted data...")
+		start := time.Now()
+		_, err := inW.Write([]byte(unformatted))
+		if err != nil {
+			t.Fatal(err)
+		}
+		err = inW.Close()
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		actual, err := ioutil.ReadAll(outR)
+		if err != nil {
+			t.Fatal(err)
+		}
+		duration := time.Since(start)
+		t.Logf("formatting took %dms", duration.Milliseconds())
+
+		actualString := strings.TrimSpace(string(actual))
+		if actualString != expected {
+			t.Fatalf("expected:\n%s\ngot:\n%s\n", expected, actualString)
+		}
+	})
+}