blob: 9da200b3fd5d3d539e5fb523c7706942ff181c6d [file]
// Copyright 2026 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"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
const unknownTarget = "Unknown to Build"
// CompileCommand represents an entry in compile_commands.json.
type CompileCommand struct {
Directory string `json:"directory"`
File string `json:"file"`
Command string `json:"command,omitempty"`
Arguments []string `json:"arguments,omitempty"`
}
// GetArgs returns the command line arguments for the compilation.
func (c *CompileCommand) GetArgs() []string {
if len(c.Arguments) > 0 {
return c.Arguments
}
return splitCommand(c.Command)
}
// splitCommand splits a shell command string into arguments, handling simple quoting.
func splitCommand(cmd string) []string {
var args []string
var arg strings.Builder
inQuote := false
var quoteChar rune
runes := []rune(cmd)
for i := 0; i < len(runes); i++ {
r := runes[i]
// Handle backslash escapes. Note that this simple implementation
// treats backslash as an escape character both inside and outside quotes.
if r == '\\' && i+1 < len(runes) {
arg.WriteRune(runes[i+1])
i++
continue
}
if inQuote {
if r == quoteChar {
inQuote = false
} else {
arg.WriteRune(r)
}
} else {
if r == '"' || r == '\'' {
inQuote = true
quoteChar = r
} else if r == ' ' {
if arg.Len() > 0 {
args = append(args, arg.String())
arg.Reset()
}
} else {
arg.WriteRune(r)
}
}
}
if arg.Len() > 0 {
args = append(args, arg.String())
}
return args
}
// ExtractOutput finds the primary output file (-o) in the compilation command.
func ExtractOutput(cmd CompileCommand) (string, error) {
args := cmd.GetArgs()
for i := 0; i < len(args)-1; i++ {
if args[i] == "-o" {
out := args[i+1]
if !filepath.IsAbs(out) {
out = filepath.Join(cmd.Directory, out)
}
return out, nil
}
}
return "", fmt.Errorf("no output file found in command")
}
// PopulateTargets identifies the GN build targets for the files in the context.
func (ctx *WorkspaceContext) PopulateTargets() error {
if ctx.BuildDir == "" {
return nil
}
compDbPath := filepath.Join(ctx.BuildDir, "compile_commands.json")
if _, err := os.Stat(compDbPath); os.IsNotExist(err) {
return nil // No compilation database, can't find targets.
}
data, err := os.ReadFile(compDbPath)
if err != nil {
return fmt.Errorf("failed to read compile_commands.json: %w", err)
}
var commands []CompileCommand
if err := json.Unmarshal(data, &commands); err != nil {
return fmt.Errorf("failed to unmarshal compile_commands.json: %w", err)
}
// Maps for exact match and heuristic fallback.
fileToCmd := make(map[string][]CompileCommand)
dirToCmd := make(map[string]CompileCommand)
dirAndBaseToCmd := make(map[string][]CompileCommand)
for _, cmd := range commands {
absFile := cmd.File
if !filepath.IsAbs(absFile) {
absFile = filepath.Join(cmd.Directory, absFile)
}
relFile := ctx.toRel(absFile)
fileToCmd[relFile] = append(fileToCmd[relFile], cmd)
relDir := filepath.Dir(relFile)
// For the general directory fallback, we just need one example command
// to "borrow" flags or output structure from.
if _, ok := dirToCmd[relDir]; !ok {
dirToCmd[relDir] = cmd
}
base := strings.TrimSuffix(filepath.Base(relFile), filepath.Ext(relFile))
key := relDir + ":" + base
dirAndBaseToCmd[key] = append(dirAndBaseToCmd[key], cmd)
}
// ninjaToLabel caches the resolution of Ninja paths to GN labels.
ninjaToLabel := make(map[string]string)
for i, f := range ctx.Files {
if f.Status != StatusFound || f.IsDirectory {
continue
}
// Only attempt to find targets for C-family files.
ext := filepath.Ext(f.AbsPath)
switch ext {
case ".cc", ".cpp", ".cxx", ".c", ".h", ".hh", ".hpp":
// proceed
default:
continue
}
relFile := ctx.toRel(f.AbsPath)
var candidateCmds []CompileCommand
relDir := filepath.Dir(relFile)
if cmds, ok := fileToCmd[relFile]; ok {
candidateCmds = cmds
} else {
// Heuristic 1: look for a file with the same base name in the same directory.
base := strings.TrimSuffix(filepath.Base(relFile), filepath.Ext(relFile))
if cmds, ok := dirAndBaseToCmd[relDir+":"+base]; ok {
candidateCmds = cmds
}
}
if len(candidateCmds) == 0 {
// Heuristic 2: walk up the tree and find a file with similar base name.
base := strings.TrimSuffix(filepath.Base(relFile), filepath.Ext(relFile))
curr := filepath.Dir(relDir)
for {
if cmds, found := dirAndBaseToCmd[curr+":"+base]; found {
candidateCmds = cmds
break
}
parent := filepath.Dir(curr)
if parent == curr {
break
}
curr = parent
}
}
if len(candidateCmds) == 0 {
// Heuristic 3: try neighbor in the same directory.
if cmd, ok := dirToCmd[relDir]; ok {
candidateCmds = []CompileCommand{cmd}
}
}
if len(candidateCmds) == 0 {
f.BuildTargets = []string{unknownTarget}
ctx.Files[i] = f
continue
}
// Resolve all candidate commands to unique labels.
var labels []string
for _, cmd := range candidateCmds {
output, err := ExtractOutput(cmd)
if err != nil {
continue
}
// build/api/client expects paths relative to the build directory.
relOutput, err := filepath.Rel(ctx.BuildDir, output)
if err != nil {
relOutput = output
}
label, ok := ninjaToLabel[relOutput]
if !ok {
label, err = resolveNinjaPath(ctx, relOutput)
if err != nil || label == "" {
label = unknownTarget
}
ninjaToLabel[relOutput] = label
}
if label != unknownTarget {
labels = append(labels, label)
}
}
if len(labels) == 0 {
f.BuildTargets = []string{unknownTarget}
} else {
// Deduplicate and sort labels, then pick the first one alphabetically.
sort.Strings(labels)
unique := labels[:0]
for _, l := range labels {
if len(unique) == 0 || l != unique[len(unique)-1] {
unique = append(unique, l)
}
}
f.BuildTargets = []string{unique[0]}
}
ctx.Files[i] = f
}
if err := ctx.VerifyBuild(); err != nil {
return fmt.Errorf("failed during build verification: %w", err)
}
return nil
}
// VerifyBuild handles the synchronization of the shadow build directory and executes the build.
func (ctx *WorkspaceContext) VerifyBuild() error {
if ctx.BuildDir == "" {
ctx.setAnalysisErrorOnFoundFiles("Build directory not specified and .fx-build-dir is missing.")
return nil
}
ideAnalysisDir := filepath.Join(ctx.FuchsiaDir, "out", ".ide-analysis")
argsGnPath := filepath.Join(ctx.BuildDir, "args.gn")
destArgsGnPath := filepath.Join(ideAnalysisDir, "args.gn")
// 1. Check for args.gn in primary build directory.
if _, err := os.Stat(argsGnPath); os.IsNotExist(err) {
ctx.setAnalysisErrorOnFoundFiles("args.gn missing in primary build directory: %s", ctx.BuildDir)
return nil
}
// 2. Sync args.gn and run fx gen if needed.
changed, err := syncFile(argsGnPath, destArgsGnPath)
if err != nil {
ctx.setAnalysisErrorOnFoundFiles("failed to sync args.gn: %v", err)
return nil
}
if changed {
if err := ctx.runFx(ideAnalysisDir, "gen"); err != nil {
ctx.setAnalysisErrorOnFoundFiles("fx gen failed: %v", err)
return nil
}
}
// 3. Collect targets and execute build.
targets := make([]string, 0)
targetToFiles := make(map[string][]int)
for i, f := range ctx.Files {
if f.Status == StatusNotFound {
ctx.Files[i].AnalysisResult = &AnalysisResult{Status: AnalysisStatusNotFound}
continue
}
if len(f.BuildTargets) == 0 || f.BuildTargets[0] == unknownTarget {
ctx.Files[i].AnalysisResult = &AnalysisResult{Status: AnalysisStatusUnknown}
continue
}
for _, t := range f.BuildTargets {
if _, ok := targetToFiles[t]; !ok {
targets = append(targets, t)
}
targetToFiles[t] = append(targetToFiles[t], i)
}
}
if len(targets) == 0 {
return nil
}
// 4. Execute build and populate results.
// Try to build all targets at once first for performance.
// If that fails, fall back to building them one by one to identify specific failures.
batchArgs := append([]string{"build"}, targets...)
batchErr := ctx.runFx(ideAnalysisDir, batchArgs...)
if batchErr == nil {
// All targets built successfully.
for _, target := range targets {
for _, i := range targetToFiles[target] {
if ctx.Files[i].AnalysisResult == nil || ctx.Files[i].AnalysisResult.Status != AnalysisStatusBuildFailed {
ctx.Files[i].AnalysisResult = &AnalysisResult{Status: AnalysisStatusOk}
}
}
}
} else {
// Fallback to serial builds to identify specific failures.
for _, target := range targets {
buildErr := ctx.runFx(ideAnalysisDir, "build", target)
for _, i := range targetToFiles[target] {
if buildErr != nil {
ctx.Files[i].AnalysisResult = &AnalysisResult{
Status: AnalysisStatusBuildFailed,
Message: "File failed to build.",
}
} else {
// Only set to OK if it hasn't been set as failed by another target.
if ctx.Files[i].AnalysisResult == nil || ctx.Files[i].AnalysisResult.Status != AnalysisStatusBuildFailed {
ctx.Files[i].AnalysisResult = &AnalysisResult{Status: AnalysisStatusOk}
}
}
}
}
}
return nil
}
// setAnalysisErrorOnFoundFiles sets the AnalysisError field on all files with StatusFound.
func (ctx *WorkspaceContext) setAnalysisErrorOnFoundFiles(format string, a ...interface{}) {
errMsg := fmt.Sprintf(format, a...)
for i := range ctx.Files {
if ctx.Files[i].Status == StatusFound {
ctx.Files[i].AnalysisError = errMsg
}
}
}
func (ctx *WorkspaceContext) runFx(dir string, args ...string) error {
return runFx(ctx, dir, args...)
}
var runFx = func(ctx *WorkspaceContext, dir string, args ...string) error {
fxPath := filepath.Join(ctx.FuchsiaDir, ".jiri_root", "bin", "fx")
fullArgs := append([]string{"--dir", dir}, args...)
cmd := exec.Command(fxPath, fullArgs...)
cmd.Dir = ctx.FuchsiaDir
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("fx %v failed: %w\nOutput: %s", args, err, string(out))
}
return nil
}
func syncFile(src, dst string) (bool, error) {
srcContent, err := os.ReadFile(src)
if err != nil {
return false, err
}
dstContent, err := os.ReadFile(dst)
if err == nil && bytes.Equal(srcContent, dstContent) {
return false, nil
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return false, err
}
if err := os.WriteFile(dst, srcContent, 0644); err != nil {
return false, err
}
return true, nil
}
// toRel normalizes a path to be relative to the Fuchsia root,
// handling cases where absolute paths might use different Cog/CartFS mount points.
func (ctx *WorkspaceContext) toRel(path string) string {
// Try standard relative path first.
rel, err := filepath.Rel(ctx.FuchsiaDir, path)
if err == nil && !strings.HasPrefix(rel, "..") {
return rel
}
// Heuristic: If we can't get a direct relative path (likely due to mount point mismatch),
// find the part of the path that follows the fuchsia root basename.
rootBase := filepath.Base(ctx.FuchsiaDir)
search := "/" + rootBase + "/"
if idx := strings.LastIndex(path, search); idx != -1 {
return path[idx+len(search):]
}
return path
}
// resolveNinjaPath resolves a Ninja path to a GN label.
// This is a variable so it can be overridden in tests.
var resolveNinjaPath = func(ctx *WorkspaceContext, ninjaPath string) (string, error) {
clientPath := filepath.Join(ctx.FuchsiaDir, "build", "api", "client")
// Use --allow-unknown so we get the path back instead of an error if it's not found.
args := []string{"ninja_path_to_gn_label", "--allow-unknown", ninjaPath}
cmd := exec.Command(clientPath, args...)
cmd.Dir = ctx.FuchsiaDir
out, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("build/api/client failed with stderr: %s", string(exitErr.Stderr))
}
return "", err
}
label := strings.TrimSpace(string(out))
if label == ninjaPath {
return "", nil // Tool returned the input path, which means it's unknown.
}
return label, nil
}