blob: a1ee2f5b62d6ca9a43eb40258da81321bbaffeda [file] [log] [blame]
// 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.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// A Server handles fidlbolt requests.
type Server struct {
// Deployment information, or nil.
deployment *Deployment
// External programs used by fidlbolt.
fidlFormat, fidlLint, fidlc, fidlgenLlcpp, fidlgenHlcpp, fidlgenRust,
fidlgenGo, fidlgenDart, rustfmt program
// External files used by fidlbolt.
rustfmtToml string
// Cache of FIDL library information.
libraryAnalyzer *libraryAnalyzer
}
// NewServer returns a new fidlbolt server. It takes three lists of paths: bin,
// for locating binaries, etc, for locating config files, and fidl, for locating
// FIDL libraries. Returns an error if a required file could not be located.
func NewServer(bin, etc, fidl []string) (*Server, error) {
var s Server
var err error
if deploymentFile, err := findFile("fidlbolt_deployment.json", etc); err == nil {
deploymentBytes, err := ioutil.ReadFile(deploymentFile)
if err != nil {
return nil, fmt.Errorf("failed to read etc/fidlbolt_deployment.json: %s", err)
}
s.deployment = new(Deployment)
if err = json.Unmarshal(deploymentBytes, s.deployment); err != nil {
return nil, fmt.Errorf("failed to parse etc/fidlbolt_deployment.json: %s", err)
}
}
s.fidlFormat, err = findProgram("fidl-format", bin)
if err != nil {
return nil, err
}
s.fidlLint, err = findProgram("fidl-lint", bin)
if err != nil {
return nil, err
}
s.fidlc, err = findProgram("fidlc", bin)
if err != nil {
return nil, err
}
s.fidlgenLlcpp, err = findProgram("fidlgen_llcpp", bin)
if err != nil {
return nil, err
}
s.fidlgenHlcpp, err = findProgram("fidlgen_hlcpp", bin)
if err != nil {
return nil, err
}
s.fidlgenRust, err = findProgram("fidlgen_rust", bin)
if err != nil {
return nil, err
}
s.fidlgenGo, err = findProgram("fidlgen_go", bin)
if err != nil {
return nil, err
}
s.fidlgenDart, err = findProgram("fidlgen_dart", bin)
if err != nil {
return nil, err
}
s.rustfmt, err = findProgram("rustfmt", bin)
if err != nil {
return nil, err
}
s.rustfmtToml, err = findFile("rustfmt.toml", etc)
if err != nil {
return nil, err
}
s.libraryAnalyzer = newLibraryAnalyzer(fidl)
return &s, nil
}
// A Request provides input to be converted to an output mode.
type Request struct {
// Content to be converted.
Content string `json:"content"`
// The mode of Content.
InputMode Mode `json:"inputMode"`
// The desired output mode.
OutputMode Mode `json:"outputMode"`
// Options for configuring the conversion.
Options Options `json:"options"`
}
// A Response contains the result of converting to an output mode.
type Response struct {
// True if conversion was successful.
Ok bool `json:"ok"`
// If Ok is true, Content contains the conversion result (in the requested
// output mode). Otherwise, it contains an error message.
Content string `json:"content"`
// List of annotations to apply to on the input.
Annotations []Annotation `json:"annotations"`
// Information about the deployment that served this response.
Deployment *Deployment `json:"deployment,omitempty"`
}
// A Deployment describes the provenance of all resources used to service a
// request, including the fidlbolt server itself, external binaries, FIDL
// libaries, configuration files, etc.
type Deployment struct {
// This repository.
// https://fuchsia.googlesource.com/fidlbolt
Fidlbolt Commit `json:"fidlbolt"`
// Fuchsia provides most resources used by fidlbolt.
// https://fuchsia.googlesource.com/fuchsia
Fuchsia Commit `json:"fuchsia"`
// Topaz provides the fidlgen_dart binary.
// https://fuchsia.googlesource.com/topaz
Topaz Commit `json:"topaz"`
// Rustfmt provides the rustfmt binary.
// https://github.com/rust-lang/rustfmt
Rustfmt Version `json:"rustfmt"`
}
// A Commit designates a commit in a source code repository.
type Commit struct {
// Full hash of the commit.
Hash string `json:"hash"`
// Unix timestamp of the commit, in seconds.
Timestamp uint64 `json:"timestamp"`
}
// A Version designates a software version by a string like "1.2.3".
type Version struct {
Version string `json:"version"`
}
// Options are used to configure mode conversions.
type Options struct {
// File to generate (header, source, etc.).
File string `json:"file"`
// Lint in addition to formatting.
Lint bool `json:"lint"`
// Number of byte columns.
Columns int `json:"columns"`
// Byte grouping size.
Group int `json:"group"`
// Use capital letters for hex values.
Capital bool `json:"capital"`
// Show byte offsets at the start of each line.
Offsets bool `json:"offsets"`
// Show ASCII characters at the end of lines like xxd.
ASCII bool `json:"ascii"`
}
// An Annotation is a message targeted at a specific location of a file.
type Annotation struct {
// One-based line number.
Line int `json:"line"`
// One-based column number.
Column int `json:"column"`
// Kind of annotation.
Kind AnnotationKind `json:"kind"`
// Body of the annotation.
Text string `json:"text"`
}
// AnnotationKind is a string enum for Annotation.
type AnnotationKind string
// Enumeration of AnnotationKind values.
const (
Info AnnotationKind = "info"
Warning = "warning"
Error = "error"
)
// A Mode is an input or output format.
type Mode string
// Enumeration of Mode values.
const (
Bytes Mode = "bytes"
BytesPlus = "bytes+"
C = "c"
Dart = "dart"
Diff = "diff"
FIDL = "fidl"
FIDLText = "fidltext"
Go = "go"
HLCPP = "hlcpp"
JSON = "json"
LLCPP = "llcpp"
Rust = "rust"
)
type modeSet = map[Mode]struct{}
var inputModes = modeSet{
FIDL: {},
FIDLText: {},
Bytes: {},
}
var outputModes = map[Mode]modeSet{
FIDL: {
FIDL: {},
JSON: {},
C: {},
LLCPP: {},
HLCPP: {},
Rust: {},
Go: {},
Dart: {},
},
FIDLText: {
FIDLText: {},
Bytes: {},
BytesPlus: {},
},
Bytes: {
Bytes: {},
Diff: {},
FIDLText: {},
BytesPlus: {},
},
}
// Validate validates a request. Once validated, any errors in processing the
// request are considered internal server errors.
func (r *Request) Validate() error {
if err := r.InputMode.validate(inputModes); err != nil {
return err
}
if err := r.OutputMode.validate(outputModes[r.InputMode]); err != nil {
return err
}
switch r.OutputMode {
case JSON:
switch r.Options.File {
case "ir", "schema", "deps":
default:
return fmt.Errorf("invalid JSON IR file option: %q", r.Options.File)
}
case HLCPP, LLCPP:
switch r.Options.File {
case "header", "source", "tables":
default:
return fmt.Errorf("invalid C++ file option: %q", r.Options.File)
}
case C:
switch r.Options.File {
case "header", "client", "server", "tables":
default:
return fmt.Errorf("invalid C file option: %q", r.Options.File)
}
case Dart:
switch r.Options.File {
case "library", "test":
default:
return fmt.Errorf("invalid Dart file option: %q", r.Options.File)
}
default:
if r.Options.File != "" {
return fmt.Errorf("unexpected file option: %q", r.Options.File)
}
}
if r.OutputMode == Bytes {
if r.Options.Columns <= 0 {
return fmt.Errorf("invalid columns: %d", r.Options.Columns)
}
if r.Options.Group <= 0 {
return fmt.Errorf("invalid group: %d", r.Options.Group)
}
}
return nil
}
func (m Mode) validate(allowed modeSet) error {
if _, ok := allowed[m]; !ok {
return fmt.Errorf("invalid mode: %q", m)
}
return nil
}
// Serve processes a request and serves a response. It returns an error if the
// request could not be processed for some reason (e.g., an IO failure).
func (s *Server) Serve(ctx context.Context, r *Request) (Response, error) {
res, err := s.handle(ctx, r)
if err != nil {
return Response{}, err
}
res.Deployment = s.deployment
return res, nil
}
func (s *Server) handle(ctx context.Context, r *Request) (Response, error) {
switch r.InputMode {
case FIDL:
return s.handleFIDL(ctx, r)
case FIDLText:
return s.handleFIDLText(ctx, r)
case Bytes:
return s.handleBytes(ctx, r)
}
return internalErrorf("invalid input mode: %q", r.InputMode)
}
func (s *Server) handleFIDL(ctx context.Context, r *Request) (Response, error) {
temp, err := newTempDir()
if err != nil {
return internalError(err)
}
defer os.RemoveAll(temp.path)
res, err := s.handleFIDLWithTemp(ctx, r, temp)
if err != nil {
return res, err
}
// 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 internalError(err)
}
// Run fidlc, automatically appending the correct --files arguments and
// producing info annotations for the library declaration and imports.
fidlc := func(args ...string) Response {
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}
}
res := s.fidlc.run(ctx, append(args, fileArgs...)...)
res.Annotations = anns
return res
}
switch r.OutputMode {
case C, LLCPP, HLCPP:
if r.Options.File == "tables" {
return fidlc("--tables", "/dev/stdout"), nil
}
}
switch r.OutputMode {
case FIDL:
if r.Options.Lint {
if res := s.fidlLint.run(ctx, fidl); !res.Ok {
return res, nil
}
return Response{Ok: true, Content: "// Lint passed!"}, nil
}
return s.fidlFormat.run(ctx, fidl), nil
case JSON:
switch r.Options.File {
case "ir":
return fidlc("--json", "/dev/stdout"), nil
case "schema":
return s.fidlc.run(ctx, "--json-schema"), 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
}
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: true, Content: string(b), Annotations: anns}, nil
default:
return internalErrorf("invalid file: %q", r.Options.File)
}
case C:
switch r.Options.File {
case "header":
return fidlc("--fuchsia-only-c-header", "/dev/stdout"), nil
case "client":
return fidlc("--fuchsia-only-c-client", "/dev/stdout"), nil
case "server":
return fidlc("--fuchsia-only-c-server", "/dev/stdout"), nil
default:
return internalErrorf("invalid file: %q", r.Options.File)
}
}
jsonIR := fidl + ".json"
fidlcRes := fidlc("--json", jsonIR)
if !fidlcRes.Ok {
return fidlcRes, nil
}
res, err := s.handleFIDLWithIR(ctx, r, temp, jsonIR)
if err != nil {
return internalError(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) {
switch r.OutputMode {
case HLCPP:
if res := s.fidlgenHlcpp.run(ctx,
"-json", jsonIR,
"-include-base", temp.path,
"-output-base", temp.join("impl"),
); !res.Ok {
return res, nil
}
switch r.Options.File {
case "header":
return temp.readFile("impl.h")
case "source":
return temp.readFile("impl.cc")
default:
return internalErrorf("invalid file: %q", r.Options.File)
}
case LLCPP:
if res := s.fidlgenLlcpp.run(ctx,
"-json", jsonIR,
"-header", temp.join("impl.h"),
"-source", temp.join("impl.cc"),
); !res.Ok {
return res, nil
}
switch r.Options.File {
case "header":
return temp.readFile("impl.h")
case "source":
return temp.readFile("impl.cc")
default:
return internalErrorf("invalid file: %q", r.Options.File)
}
case Rust:
if res := s.fidlgenRust.run(ctx,
"-json", jsonIR,
"-output-filename", temp.join("impl.rs"),
"-rustfmt", s.rustfmt.path,
"-rustfmt-config", s.rustfmtToml,
); !res.Ok {
return res, nil
}
return temp.readFile("impl.rs")
case Go:
if res := s.fidlgenGo.run(ctx,
"-json", jsonIR,
"-output-impl", temp.join("impl.go"),
); !res.Ok {
return res, nil
}
return temp.readFile("impl.go")
case Dart:
var name string
switch r.Options.File {
case "library":
name = "async"
case "test":
name = "test"
default:
return internalErrorf("invalid file: %q", r.Options.File)
}
if res := s.fidlgenDart.run(ctx,
"-json", jsonIR,
"-output-"+name, temp.join("impl.dart"),
); !res.Ok {
return res, nil
}
return temp.readFile("impl.dart")
}
return internalErrorf("invalid output mode: %q", r.OutputMode)
}
func (s *Server) handleFIDLText(ctx context.Context, r *Request) (Response, error) {
return Response{Ok: false, Content: "Not implemented"}, nil
}
func (s *Server) handleBytes(ctx context.Context, r *Request) (Response, error) {
switch r.OutputMode {
case Bytes:
b, pos, err := parseBytes(r.Content)
if err != nil {
msg := fmt.Sprintf("%s: error: %s", pos, err)
ann := Annotation{
Line: pos.Line,
Column: pos.Column,
Kind: Error,
Text: err.Error(),
}
return Response{Ok: false, Content: msg, Annotations: []Annotation{ann}}, nil
}
formatted, err := formatBytes(b, &r.Options)
if err != nil {
return internalError(err)
}
return Response{Ok: true, Content: formatted}, nil
case Diff, FIDLText, BytesPlus:
return Response{Ok: false, Content: "Not implemented"}, nil
}
return internalErrorf("invalid output mode: %q", r.OutputMode)
}
// internalError reports an internal server error.
func internalError(err error) (Response, error) {
return Response{}, err
}
// internalErrorf is like internalError but takes fmt.Printf arguments.
func internalErrorf(format string, a ...interface{}) (Response, error) {
return internalError(fmt.Errorf(format, a...))
}
// program represents an external program by its executable path.
type program struct {
path string
}
// findProgram searches the directories in bin for the named program. It returns
// an error if the program cannot be found.
func findProgram(name string, bin []string) (program, error) {
path, err := findFile(name, bin)
return program{path}, err
}
// findFile searches the directories in dirs for the file with the given name.
// It returns the full path, or an error if the file cannot be found.
func findFile(name string, dirs []string) (string, error) {
for _, dir := range dirs {
path := filepath.Join(dir, name)
if _, err := os.Stat(path); err == nil {
return path, nil
}
}
return "", fmt.Errorf("cannot find %s (searched in %s)", name, strings.Join(dirs, ", "))
}
// run runs a program with arguments and produces a response. It stops the
// program and returns if ctx is cancelled.
// TODO: Consider returning a string and error here, and letting the top-level
// handlers produce responses instead.
func (p program) run(ctx context.Context, arg ...string) Response {
cmd := exec.CommandContext(ctx, p.path, arg...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
switch err := cmd.Run().(type) {
case nil, *exec.ExitError:
return Response{
Ok: err == nil,
Content: out.String(),
}
default:
msg := fmt.Sprintf("Failed to execute %s", filepath.Base(p.path))
return Response{Ok: false, Content: msg}
}
}
// A tempDir is temporary directory.
type tempDir struct {
path string
}
// newTempDir creates a new temporary directory.
func newTempDir() (tempDir, error) {
path, err := ioutil.TempDir("", "fidlbolt-")
return tempDir{path}, err
}
// join returns a path inside the temporary directory.
func (d tempDir) join(elem ...string) string {
elem = append([]string{d.path}, elem...)
return filepath.Join(elem...)
}
// createFile creates a file in the temporary directory, writes content to it,
// and returns its path.
func (d tempDir) createFile(name, content string) (string, error) {
f, err := os.Create(d.join(name))
if err != nil {
return "", err
}
defer f.Close()
if _, err = f.WriteString(content); err != nil {
return "", err
}
return f.Name(), nil
}
// readFile creates a response from a file in the temporary directory.
func (d tempDir) readFile(name string) (Response, error) {
b, err := ioutil.ReadFile(d.join(name))
if err != nil {
return Response{}, err
}
return Response{Ok: true, Content: string(b)}, nil
}
var fidlAnnotationRegexp = regexp.MustCompile(`` +
// Multi-line mode: ^ and $ match begin/end of line OR text.
`(?m)` +
`^fidlbolt\.fidl:(\d+):(\d+).*?` +
`(` +
string(Info) + `|` +
string(Warning) + `|` +
string(Error) + `)` +
`:\s*(.*)$`)
// parseFidlAnnotations parses a list of annotations from error output. It
// assumes that the output refers to a file called "fidlbolt.fidl".
func parseFidlAnnotations(output string) []Annotation {
matches := fidlAnnotationRegexp.FindAllStringSubmatch(output, -1)
var anns []Annotation
for _, m := range matches {
line, err := strconv.Atoi(m[1])
if err != nil {
continue
}
column, err := strconv.Atoi(m[2])
if err != nil {
continue
}
anns = append(anns, Annotation{
Line: line,
Column: column,
Kind: AnnotationKind(m[3]),
Text: m[4],
})
}
return anns
}