| // 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("--c-header", "/dev/stdout"), nil |
| case "client": |
| return fidlc("--c-client", "/dev/stdout"), nil |
| case "server": |
| return fidlc("--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 |
| } |