Merge "Show warning annotations and add unit tests"
diff --git a/Makefile b/Makefile
index 7a62a56..e8a68e7 100644
--- a/Makefile
+++ b/Makefile
@@ -40,7 +40,7 @@
 # Path to the server binary.
 server := backend/dist/fidlbolt
 
-.PHONY: all help frontend backend run format clean
+.PHONY: all help frontend backend test run format clean
 
 all: frontend backend
 
@@ -51,6 +51,7 @@
 	@echo "    frontend   Build the frontend/dist bundle using webpack."
 	@echo "               For more build options: cd frontend && npm run."
 	@echo "    backend    Build the $(server) server binary."
+	@echo "    test       Build and run all tests.
 	@echo "    run        Run the fidlbolt server on \$$PORT."
 	@echo "               Set VERBOSE=1 to enable verbose logging."
 	@echo "               Set DEPLOYMENT=1 to use files in ./deployment instead"
@@ -73,6 +74,9 @@
 $(server): backend/go.mod backend/*.go
 	cd backend && go build -o ../$@
 
+test:
+	cd backend && go test -v
+
 run: $(if $(wildcard frontend/dist),,frontend)
 run: $(if $(wildcard $(server)),,backend)
 ifndef FUCHSIA_DIR
diff --git a/backend/analyze.go b/backend/analyze.go
index dd83525..38f8ef5 100644
--- a/backend/analyze.go
+++ b/backend/analyze.go
@@ -472,7 +472,7 @@
 var fidlAnnotationRegexp = regexp.MustCompile(`` +
 	// Multi-line mode: ^ and $ match begin/end of line OR text.
 	`(?m)` +
-	`^fidlbolt\.fidl:(\d+):(\d+).*?` +
+	`^/.*/fidlbolt\.fidl:(\d+):(\d+).*?` +
 	`(` +
 	string(Info) + `|` +
 	string(Warning) + `|` +
diff --git a/backend/server.go b/backend/server.go
index 18e795e..7df1db3 100644
--- a/backend/server.go
+++ b/backend/server.go
@@ -322,43 +322,47 @@
 		return response{}, err
 	}
 	defer os.RemoveAll(temp.path)
-	res, err := s.handleFIDLWithTemp(ctx, r, temp)
+	fidl, err := temp.createFile("fidlbolt.fidl", r.Content)
+	if err != nil {
+		return response{}, err
+	}
+	libs, anns := s.libraryAnalyzer.analyze(fidl, r.Content)
+	res, err := s.handleFIDLWithTemp(ctx, r, temp, fidl, libs)
 	if err != nil {
 		return res, err
 	}
+	res.Annotations = append(res.Annotations, anns...)
 	// Remove all occurrences of the temp path to avoid exposing it to the user.
 	// This is mostly needed for error output, but it is also needed e.g. for
 	// the JSON IR, which includes source paths.
 	res.Content = strings.ReplaceAll(res.Content, temp.path+"/", "")
 	res.Content = strings.ReplaceAll(res.Content, temp.path, "")
-	if !res.Ok {
-		res.Annotations = append(res.Annotations, parseFidlAnnotations(res.Content)...)
-	}
 	return res, nil
 }
 
 const jsonDepIndentSize = 4
 
-func (s *server) handleFIDLWithTemp(ctx context.Context, r *request, temp tempDir) (response, error) {
-	fidl, err := temp.createFile("fidlbolt.fidl", r.Content)
-	if err != nil {
-		return response{}, err
-	}
-
-	// Run fidlc, automatically appending the correct --files arguments and
-	// producing info annotations for the library declaration and imports.
+func (s *server) handleFIDLWithTemp(ctx context.Context, r *request, temp tempDir, fidl string, libs libraryMap) (response, error) {
+	// Run fidlc, automatically appending the correct --files arguments.
 	fidlc := func(args ...string) (response, error) {
-		libs, anns := s.libraryAnalyzer.analyze(fidl, r.Content)
 		fileArgs, err := libs.fidlcFileArgs()
 		if err != nil {
 			msg := fmt.Sprintf("%s:1:1: error: %s", fidl, err)
-			return response{Ok: false, Content: msg, Annotations: anns}, nil
+			return response{Ok: false, Content: msg}, nil
 		}
-		res, err := s.fidlc.run(ctx, append(args, fileArgs...)...)
+		run, err := s.fidlc.run(ctx, append(args, fileArgs...)...)
 		if err != nil {
 			return response{}, err
 		}
-		res.Annotations = anns
+		res := response{
+			Ok:          run.success,
+			Annotations: parseFidlAnnotations(run.stderr),
+		}
+		if run.success {
+			res.Content = run.stdout
+		} else {
+			res.Content = run.stderr
+		}
 		return res, nil
 	}
 
@@ -371,32 +375,50 @@
 
 	switch r.OutputMode {
 	case FIDL:
+		prog := s.fidlFormat
 		if r.Options.Lint {
-			if res, err := s.fidlLint.run(ctx, fidl); err != nil || !res.Ok {
-				return res, err
-			}
-			return response{Ok: true, Content: "// Lint passed!"}, nil
+			prog = s.fidlLint
 		}
-		return s.fidlFormat.run(ctx, fidl)
+		run, err := prog.run(ctx, fidl)
+		if err != nil {
+			return response{}, err
+		}
+		res := response{
+			Ok:          run.success,
+			Annotations: parseFidlAnnotations(run.stderr),
+		}
+		if run.success {
+			if r.Options.Lint {
+				res.Content = "// Lint passed!"
+			} else {
+				res.Content = run.stdout
+			}
+		} else {
+			res.Content = run.stderr
+		}
+		return res, nil
 	case JSON:
 		switch r.Options.File {
 		case "ir":
 			return fidlc("--json", "/dev/stdout")
 		case "schema":
-			return s.fidlc.run(ctx, "--json-schema")
+			run, err := s.fidlc.runInfallible(ctx, "--json-schema")
+			if err != nil {
+				return response{}, err
+			}
+			return response{Ok: true, Content: run.stdout}, nil
 		case "deps":
-			libs, anns := s.libraryAnalyzer.analyze(fidl, r.Content)
 			graph, err := libs.dependencyGraph()
 			if err != nil {
 				msg := fmt.Sprintf("%s:1:1: error: %s", fidl, err)
-				return response{Ok: false, Content: msg, Annotations: anns}, nil
+				return response{Ok: false, Content: msg}, nil
 			}
 			b, err := json.MarshalIndent(graph, "", strings.Repeat(" ", jsonDepIndentSize))
 			if err != nil {
 				msg := fmt.Sprintf("%s:1:1: error: %s", fidl, err)
-				return response{Ok: false, Content: msg, Annotations: anns}, nil
+				return response{Ok: false, Content: msg}, nil
 			}
-			return response{Ok: true, Content: string(b), Annotations: anns}, nil
+			return response{Ok: true, Content: string(b)}, nil
 		default:
 			return response{}, fmt.Errorf("invalid file: %q", r.Options.File)
 		}
@@ -414,30 +436,29 @@
 	}
 
 	jsonIR := fidl + ".json"
-	fidlcRes, err := fidlc("--json", jsonIR)
+	res, err := fidlc("--json", jsonIR)
 	if err != nil {
 		return response{}, err
 	}
-	if !fidlcRes.Ok {
-		return fidlcRes, nil
+	if !res.Ok {
+		return res, nil
 	}
-	res, err := s.handleFIDLWithIR(ctx, r, temp, jsonIR)
+	res.Content, err = s.handleFIDLWithIR(ctx, r, temp, jsonIR)
 	if err != nil {
 		return response{}, err
 	}
-	res.Annotations = append(res.Annotations, fidlcRes.Annotations...)
 	return res, nil
 }
 
-func (s *server) handleFIDLWithIR(ctx context.Context, r *request, temp tempDir, jsonIR string) (response, error) {
+func (s *server) handleFIDLWithIR(ctx context.Context, r *request, temp tempDir, jsonIR string) (string, error) {
 	switch r.OutputMode {
 	case HLCPP:
-		if res, err := s.fidlgenHlcpp.run(ctx,
+		if _, err := s.fidlgenHlcpp.runInfallible(ctx,
 			"-json", jsonIR,
 			"-include-base", temp.path,
 			"-output-base", temp.join("impl"),
-		); err != nil || !res.Ok {
-			return res, err
+		); err != nil {
+			return "", err
 		}
 		switch r.Options.File {
 		case "header":
@@ -445,15 +466,15 @@
 		case "source":
 			return temp.readFile("impl.cc")
 		default:
-			return response{}, fmt.Errorf("invalid file: %q", r.Options.File)
+			return "", fmt.Errorf("invalid file: %q", r.Options.File)
 		}
 	case LLCPP:
-		if res, err := s.fidlgenLlcpp.run(ctx,
+		if _, err := s.fidlgenLlcpp.runInfallible(ctx,
 			"-json", jsonIR,
 			"-header", temp.join("impl.h"),
 			"-source", temp.join("impl.cc"),
-		); err != nil || !res.Ok {
-			return res, err
+		); err != nil {
+			return "", err
 		}
 		switch r.Options.File {
 		case "header":
@@ -461,24 +482,24 @@
 		case "source":
 			return temp.readFile("impl.cc")
 		default:
-			return response{}, fmt.Errorf("invalid file: %q", r.Options.File)
+			return "", fmt.Errorf("invalid file: %q", r.Options.File)
 		}
 	case Rust:
-		if res, err := s.fidlgenRust.run(ctx,
+		if _, err := s.fidlgenRust.runInfallible(ctx,
 			"-json", jsonIR,
 			"-output-filename", temp.join("impl.rs"),
 			"-rustfmt", s.rustfmt.path,
 			"-rustfmt-config", s.rustfmtToml,
-		); err != nil || !res.Ok {
-			return res, err
+		); err != nil {
+			return "", err
 		}
 		return temp.readFile("impl.rs")
 	case Go:
-		if res, err := s.fidlgenGo.run(ctx,
+		if _, err := s.fidlgenGo.runInfallible(ctx,
 			"-json", jsonIR,
 			"-output-impl", temp.join("impl.go"),
-		); err != nil || !res.Ok {
-			return res, err
+		); err != nil {
+			return "", err
 		}
 		return temp.readFile("impl.go")
 	case Dart:
@@ -489,17 +510,17 @@
 		case "test":
 			name = "test"
 		default:
-			return response{}, fmt.Errorf("invalid file: %q", r.Options.File)
+			return "", fmt.Errorf("invalid file: %q", r.Options.File)
 		}
-		if res, err := s.fidlgenDart.run(ctx,
+		if _, err := s.fidlgenDart.runInfallible(ctx,
 			"-json", jsonIR,
 			"-output-"+name, temp.join("impl.dart"),
-		); err != nil || !res.Ok {
-			return res, err
+		); err != nil {
+			return "", err
 		}
 		return temp.readFile("impl.dart")
 	}
-	return response{}, fmt.Errorf("invalid output mode: %q", r.OutputMode)
+	return "", fmt.Errorf("invalid output mode: %q", r.OutputMode)
 }
 
 func (s *server) handleFIDLText(ctx context.Context, r *request) (response, error) {
diff --git a/backend/system.go b/backend/system.go
index b889396..d887192 100644
--- a/backend/system.go
+++ b/backend/system.go
@@ -39,6 +39,16 @@
 	return "", fmt.Errorf("cannot find %s (searched in %s)", name, strings.Join(dirs, ", "))
 }
 
+/// runResult stores the result of running a program.
+type runResult struct {
+	// True if the exit code is zero, false otherwise.
+	success bool
+	// Standard output.
+	stdout string
+	// Standard error.
+	stderr string
+}
+
 // normalErrorExitCode is the exit code indicating that a program failed in a
 // normal or expected way. For example, fidlc exits with this code when it finds
 // a syntax error. We conservatively assume this is the only nonzero exit code
@@ -52,32 +62,53 @@
 // the future if fidlbolt uses programs that exit with codes besides 0 and 1.
 const normalErrorExitCode = 1
 
-// run runs a program with arguments. If it completes successfully, returns an
-// Ok response containing stdout. If it exits with normalErrorExitCode,
-// returns a not-Ok response containing stderr. If it fails to launch or exits
-// with any other exit code, returns an error. If ctx is cancelled while the
-// program is running, stops the program and returns an error.
-func (p program) run(ctx context.Context, arg ...string) (response, error) {
+// run runs a program with arguments. If it fails to launch or exits with a code
+// other than 0 or normalErrorExitCode, returns an error. If ctx is cancelled
+// while the program is running, stops the program and returns an error.
+func (p program) run(ctx context.Context, arg ...string) (runResult, error) {
 	cmd := exec.CommandContext(ctx, p.path, arg...)
 	var stdout, stderr bytes.Buffer
 	cmd.Stdout = &stdout
 	cmd.Stderr = &stderr
 	switch err := cmd.Run().(type) {
 	case nil:
-		return response{
-			Ok:      true,
-			Content: stdout.String(),
+		return runResult{
+			success: true,
+			stdout:  stdout.String(),
+			stderr:  stderr.String(),
 		}, nil
 	case *exec.ExitError:
 		if err.ExitCode() != normalErrorExitCode {
-			return response{}, p.augmentError(err, arg, stdout.String(), stderr.String())
+			return runResult{}, p.augmentError(err, arg, stdout.String(), stderr.String())
 		}
-		return response{
-			Ok:      false,
-			Content: stderr.String(),
+		return runResult{
+			success: false,
+			stdout:  stdout.String(),
+			stderr:  stderr.String(),
 		}, nil
 	default:
-		return response{}, err
+		return runResult{}, err
+	}
+}
+
+// runInfallible is like run, but for programs that are not expected to fail. It
+// returns an error for any nonzero exit code, including normalErrorExitCode.
+func (p program) runInfallible(ctx context.Context, arg ...string) (runResult, error) {
+	cmd := exec.CommandContext(ctx, p.path, arg...)
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	switch err := cmd.Run().(type) {
+	case nil:
+		return runResult{
+			success: true,
+			stdout:  stdout.String(),
+			stderr:  stderr.String(),
+		}, nil
+	case *exec.ExitError:
+		return runResult{}, p.augmentError(err, arg, stdout.String(), stderr.String())
+	default:
+		return runResult{}, err
 	}
 }
 
@@ -128,10 +159,10 @@
 }
 
 // readFile creates a response from a file in the temporary directory.
-func (d tempDir) readFile(name string) (response, error) {
+func (d tempDir) readFile(name string) (string, error) {
 	b, err := ioutil.ReadFile(d.join(name))
 	if err != nil {
-		return response{}, err
+		return "", err
 	}
-	return response{Ok: true, Content: string(b)}, nil
+	return string(b), nil
 }
diff --git a/backend/system_test.go b/backend/system_test.go
new file mode 100644
index 0000000..35b7cc6
--- /dev/null
+++ b/backend/system_test.go
@@ -0,0 +1,172 @@
+// Copyright 2020 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// +build linux darwin
+
+package main
+
+import (
+	"context"
+	"fmt"
+	"math/rand"
+	"os"
+	"testing"
+	"time"
+)
+
+func TestTempDir(t *testing.T) {
+	d, err := newTempDir()
+	if err != nil {
+		t.Fatal("failed to create temp dir: ", err)
+	}
+	defer os.RemoveAll(d.path)
+	written := "the contents of foo"
+	filename, err := d.createFile("foo", written)
+	if err != nil {
+		t.Fatal("failed to create temp file: ", err)
+	}
+	expectedFilename := d.join("foo")
+	if filename != expectedFilename {
+		t.Errorf("got %q, want %q", filename, expectedFilename)
+	}
+	read, err := d.readFile("foo")
+	if err != nil {
+		t.Fatal("failed to read temp file: ", err)
+	}
+	if read != written {
+		t.Errorf("got %q, want %q", read, written)
+	}
+}
+
+func TestFindFile(t *testing.T) {
+	d1, err := newTempDir()
+	if err != nil {
+		t.Fatal("failed to create temp dir: ", err)
+	}
+	defer os.RemoveAll(d1.path)
+	d2, err := newTempDir()
+	if err != nil {
+		t.Fatal("failed to create temp dir: ", err)
+	}
+	defer os.RemoveAll(d2.path)
+
+	d1a, err := d1.createFile("a", "stuff")
+	if err != nil {
+		t.Fatal("failed to create temp file: ", err)
+	}
+	d2a, err := d2.createFile("a", "stuff")
+	if err != nil {
+		t.Fatal("failed to create temp file: ", err)
+	}
+	d2b, err := d2.createFile("b", "stuff")
+	if err != nil {
+		t.Fatal("failed to create temp file: ", err)
+	}
+
+	findD1a, err := findFile("a", []string{d1.path, d2.path})
+	if err != nil {
+		t.Fatal("failed to find d1/a: ", err)
+	}
+	if findD1a != d1a {
+		t.Errorf("got %q, want %q", findD1a, d1a)
+	}
+
+	findD2a, err := findFile("a", []string{d2.path, d1.path})
+	if err != nil {
+		t.Fatal("failed to find d2/a: ", err)
+	}
+	if findD2a != d2a {
+		t.Errorf("got %q, want %q", findD2a, d2a)
+	}
+
+	findD2b, err := findFile("b", []string{d1.path, d2.path})
+	if err != nil {
+		t.Fatal("failed to find d2/b: ", err)
+	}
+	if findD2b != d2b {
+		t.Errorf("got %q, want %q", findD2b, d2b)
+	}
+}
+
+func init() {
+	rand.Seed(time.Now().UTC().UnixNano())
+}
+
+var nonexistentPath = fmt.Sprintf("/does_not_exist.%020d", rand.Uint64())
+
+func TestProgramRunSuccess(t *testing.T) {
+	p := program{"/bin/echo"}
+	ctx := context.Background()
+	run, err := p.run(ctx, "hi")
+	if err != nil {
+		t.Fatal("failed to run echo: ", err)
+	}
+	if !run.success {
+		t.Error("echo did not succeed")
+	}
+	stdout := "hi\n"
+	stderr := ""
+	if run.stdout != stdout {
+		t.Errorf("got %q, want %q", run.stdout, stdout)
+	}
+	if run.stderr != stderr {
+		t.Errorf("got %q, want %q", run.stderr, stderr)
+	}
+}
+
+func TestProgramRunFailure(t *testing.T) {
+	p := program{"/bin/cat"}
+	ctx := context.Background()
+	run, err := p.run(ctx, nonexistentPath)
+	if err != nil {
+		t.Fatalf("%s %s: %s", p.path, nonexistentPath, err)
+	}
+	if run.success {
+		t.Errorf("%s %s: run.success: got %v, want false", p.path, nonexistentPath, run.success)
+	}
+	if run.stdout != "" {
+		t.Errorf("%s %s: run.stdout: got %q, want %q", p.path, nonexistentPath, run.stdout, "")
+	}
+	if run.stderr == "" {
+		t.Errorf("%s %s: run.stderr: got %q, want nonempty", p.path, nonexistentPath, run.stderr)
+	}
+}
+
+func TestProgramRunNoLaunch(t *testing.T) {
+	p := program{nonexistentPath}
+	ctx := context.Background()
+	_, err := p.run(ctx)
+	if err == nil {
+		t.Fatalf("%s: expected error", p.path)
+	}
+}
+
+func TestProgramRunInfallibleSuccess(t *testing.T) {
+	p := program{"/bin/echo"}
+	ctx := context.Background()
+	run, err := p.runInfallible(ctx, "hi")
+	if err != nil {
+		t.Fatal("failed to run echo: ", err)
+	}
+	if !run.success {
+		t.Error("echo did not succeed")
+	}
+	stdout := "hi\n"
+	stderr := ""
+	if run.stdout != stdout {
+		t.Errorf("got %q, want %q", run.stdout, stdout)
+	}
+	if run.stderr != stderr {
+		t.Errorf("got %q, want %q", run.stderr, stderr)
+	}
+}
+
+func TestProgramRunInfallibleFailure(t *testing.T) {
+	p := program{"/bin/cat"}
+	ctx := context.Background()
+	_, err := p.runInfallible(ctx, nonexistentPath)
+	if err == nil {
+		t.Fatalf("%s %s: expected error", p.path, nonexistentPath)
+	}
+}