From 39186971525c8b97eb48bc92720e05744e671afa Mon Sep 17 00:00:00 2001 From: Dave Cheney Date: Wed, 21 Oct 2026 15:08:35 +1100 Subject: [PATCH] Make tinygo fix useful for TinyGo sources The standard go fix command cannot analyze TinyGo source packages and target-specific code. Make fix output practical for TinyGo sources and easier to review. --- .github/workflows/compat.yml | 2 +- builder/config.go | 8 +- compileopts/config.go | 6 + fix.go | 118 +++++++++ fix/fix.go | 309 ++++++++++++++++++++++++ fix/fix_test.go | 426 +++++++++++++++++++++++++++++++++ fix_test.go | 209 ++++++++++++++++ go.mod | 1 + internal/diff/diff.go | 261 ++++++++++++++++++++ internal/diff/diff_test.go | 30 +++ loader/analysis.go | 346 ++++++++++++++++++++++++++ loader/analysispath_test.go | 123 ++++++++++ loader/analysispath_unix.go | 9 + loader/analysispath_windows.go | 32 +++ loader/loader.go | 69 +++++- main.go | 4 + make/build.mk | 2 +- make/config.mk | 2 +- 18 files changed, 1942 insertions(+), 15 deletions(-) create mode 100644 fix.go create mode 100644 fix/fix.go create mode 100644 fix/fix_test.go create mode 100644 fix_test.go create mode 100644 internal/diff/diff.go create mode 100644 internal/diff/diff_test.go create mode 100644 loader/analysis.go create mode 100644 loader/analysispath_test.go create mode 100644 loader/analysispath_unix.go create mode 100644 loader/analysispath_windows.go diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index ea7ef41e3a..d42dc97b04 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -20,7 +20,7 @@ jobs: env: # Oldest versions currently supported by TinyGo LLVM: "15" - Go: "1.25" # when updating this, also update minorMin in builder/config.go + Go: "1.25" # when updating this, also update MinimumGoMinorVersion in compileopts/config.go steps: - name: Checkout uses: actions/checkout@v6 diff --git a/builder/config.go b/builder/config.go index 8aa2363336..bf4d57c9b8 100644 --- a/builder/config.go +++ b/builder/config.go @@ -25,10 +25,6 @@ func NewConfig(options *compileopts.Options) (*compileopts.Config, error) { spec.OpenOCDCommands = options.OpenOCDCommands } - // Version range supported by TinyGo. - const minorMin = 25 // when updating the min version, also update .github/workflows/compat.yml - const minorMax = 27 - // Check that we support this Go toolchain version. gorootMajor, gorootMinor, err := goenv.GetGorootVersion() if err != nil { @@ -36,10 +32,10 @@ func NewConfig(options *compileopts.Options) (*compileopts.Config, error) { } if options.GoCompatibility { - if gorootMajor != 1 || gorootMinor < minorMin || gorootMinor > minorMax { + if gorootMajor != 1 || gorootMinor < compileopts.MinimumGoMinorVersion || gorootMinor > compileopts.MaximumGoMinorVersion { // Note: when this gets updated, also update the Go compatibility matrix: // https://github.com/tinygo-org/tinygo-site/blob/dev/content/docs/reference/go-compat-matrix.md - return nil, fmt.Errorf("requires go version 1.%d through 1.%d, got go%d.%d", minorMin, minorMax, gorootMajor, gorootMinor) + return nil, fmt.Errorf("requires go version 1.%d through 1.%d, got go%d.%d", compileopts.MinimumGoMinorVersion, compileopts.MaximumGoMinorVersion, gorootMajor, gorootMinor) } } diff --git a/compileopts/config.go b/compileopts/config.go index 4bfd912fac..944322eb69 100644 --- a/compileopts/config.go +++ b/compileopts/config.go @@ -17,6 +17,12 @@ import ( "github.com/tinygo-org/tinygo/goenv" ) +// Go minor version range supported by TinyGo. +const ( + MinimumGoMinorVersion = 25 + MaximumGoMinorVersion = 27 +) + // Library versions. Whenever an existing library is changed, this number should // be added/increased so that existing caches are invalidated. // diff --git a/fix.go b/fix.go new file mode 100644 index 0000000000..2a920151b8 --- /dev/null +++ b/fix.go @@ -0,0 +1,118 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/tinygo-org/tinygo/builder" + "github.com/tinygo-org/tinygo/compileopts" + "github.com/tinygo-org/tinygo/compiler" + "github.com/tinygo-org/tinygo/fix" + "github.com/tinygo-org/tinygo/goenv" + "github.com/tinygo-org/tinygo/loader" + "golang.org/x/tools/go/buildutil" +) + +const usageFix = `usage: tinygo fix [flags] [packages] + +Apply Go fix analyzers using TinyGo's package loader, including package tests. +Use -diff to print changes without writing files. A nonempty diff exits with 1. +Use -tests=false to exclude test files from analysis. +Use -target and -tags to select one target configuration. +Only named packages are changed, not their dependencies or host Go sources. + +Run tinygo fix help for analyzer names or tinygo fix help for details. +The -fixtool and -toolexec flags are not supported. +` + +func runFix(args []string, stdout, stderr io.Writer) int { + set := flag.NewFlagSet("fix", flag.ContinueOnError) + set.SetOutput(stderr) + set.Usage = func() { + fmt.Fprint(stderr, usageFix) + set.PrintDefaults() + } + diff := set.Bool("diff", false, "print a unified diff without writing files") + includeTests := set.Bool("tests", true, "include test files when analyzing packages") + options := &compileopts.Options{ + GOOS: goenv.Get("GOOS"), GOARCH: goenv.Get("GOARCH"), + GOARM: goenv.Get("GOARM"), GOMIPS: goenv.Get("GOMIPS"), + GoCompatibility: true, Opt: "z", PanicStrategy: "print", + } + set.StringVar(&options.Target, "target", "", "chip/board name or JSON target specification file") + set.StringVar(&options.GC, "gc", "", "garbage collector") + set.StringVar(&options.Scheduler, "scheduler", "", "goroutine scheduler") + set.StringVar(&options.Serial, "serial", "", "serial output") + set.StringVar(&options.PanicUnwind, "panic-unwind", "", "panic unwind strategy") + set.StringVar(&options.LLVMFeatures, "llvm-features", "", "LLVM target features") + set.StringVar(&options.Directory, "C", "", "change to directory before loading packages") + var tags buildutil.TagsFlag + set.Var(&tags, "tags", "build tags") + analyzerFlags := fix.RegisterFlags(set) + if err := set.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + patterns := set.Args() + if len(patterns) > 0 && patterns[0] == "help" { + for _, a := range fix.Analyzers() { + if len(patterns) == 1 { + fmt.Fprintf(stdout, "%-18s %s\n", a.Name, strings.SplitN(a.Doc, "\n", 2)[0]) + } else if len(patterns) == 2 && patterns[1] == a.Name { + fmt.Fprintln(stdout, a.Doc) + a.Flags.SetOutput(stdout) + a.Flags.PrintDefaults() + return 0 + } + } + if len(patterns) == 1 { + return 0 + } + fmt.Fprintln(stderr, "unknown analyzer or invalid help arguments") + return 2 + } + options.Tags = tags + if options.Directory != "" { + if err := os.Chdir(options.Directory); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + options.Directory = "" + } + if err := options.Verify(); err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + config, err := builder.NewConfig(options) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + machine, err := compiler.NewTargetMachine(&compiler.Config{ + Triple: config.Triple(), CPU: config.CPU(), Features: config.Features(), + CodeModel: config.CodeModel(), RelocationModel: config.RelocationModel(), + }) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + defer machine.Dispose() + sizes := compiler.Sizes(machine) + changed, err := fix.Run(func(overlay map[string][]byte) (*loader.Analysis, error) { + return loader.LoadForAnalysis(config, patterns, sizes, overlay, *includeTests) + }, analyzerFlags.Selected(), *diff, stdout, stderr) + if err != nil { + printBuildOutput(err, false) + return 1 + } + if *diff && changed { + return 1 + } + return 0 +} diff --git a/fix/fix.go b/fix/fix.go new file mode 100644 index 0000000000..0072719bb0 --- /dev/null +++ b/fix/fix.go @@ -0,0 +1,309 @@ +// Package fix runs Go fix analyzers on packages loaded by TinyGo. +package fix + +import ( + "bytes" + "errors" + "flag" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "go/types" + "io" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + + internaldiff "github.com/tinygo-org/tinygo/internal/diff" + "github.com/tinygo-org/tinygo/loader" + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/checker" + "golang.org/x/tools/go/analysis/passes/buildtag" + "golang.org/x/tools/go/analysis/passes/hostport" + "golang.org/x/tools/go/analysis/passes/inline" + "golang.org/x/tools/go/analysis/passes/modernize" + "golang.org/x/tools/go/ast/astutil" +) + +// Analyzers returns the fixers supported by the pinned x/tools version. +func Analyzers() []*analysis.Analyzer { + all := append([]*analysis.Analyzer{}, modernize.Suite...) + all = append(all, buildtag.Analyzer, hostport.Analyzer, inline.Analyzer) + slices.SortFunc(all, func(a, b *analysis.Analyzer) int { return strings.Compare(a.Name, b.Name) }) + return all +} + +// Flags registers analyzer switches and options on a command's flag set. +type Flags struct { + set *flag.FlagSet + enabled map[*analysis.Analyzer]*bool +} + +func RegisterFlags(set *flag.FlagSet) *Flags { + flags := &Flags{set: set, enabled: make(map[*analysis.Analyzer]*bool)} + for _, a := range Analyzers() { + flags.enabled[a] = set.Bool(a.Name, false, "enable "+a.Name+" analysis") + a.Flags.VisitAll(func(f *flag.Flag) { + set.Var(f.Value, a.Name+"."+f.Name, f.Usage) + }) + } + return flags +} + +// Selected follows Go's rule: a true switch selects only enabled analyzers. +// Otherwise all analyzers run except those explicitly disabled. +func (f *Flags) Selected() []*analysis.Analyzer { + only := false + for _, enabled := range f.enabled { + only = only || *enabled + } + explicit := make(map[string]bool) + f.set.Visit(func(flag *flag.Flag) { explicit[flag.Name] = true }) + var selected []*analysis.Analyzer + for _, a := range Analyzers() { + enabled := *f.enabled[a] + if enabled || !only && !explicit[a.Name] { + selected = append(selected, a) + } + } + return selected +} + +// Load reloads the same roots with optional source replacements. +type Load func(overlay map[string][]byte) (*loader.Analysis, error) + +// Run prepares and type-checks all fixes before it writes files. +// It returns whether fixes were available, including in diff mode. +func Run(load Load, analyzers []*analysis.Analyzer, diff bool, stdout, stderr io.Writer) (bool, error) { + program, err := load(nil) + if err != nil { + return false, err + } + for _, name := range program.Skipped { + if _, err := fmt.Fprintf(stderr, "%s: skipping source rewritten by CGo\n", name); err != nil { + return false, err + } + } + graph, err := checker.Analyze(analyzers, program.Packages, nil) + if err != nil { + return false, err + } + for action := range graph.All() { + if action.Err != nil { + return false, fmt.Errorf("%s: %w", action, action.Err) + } + } + changes, err := prepare(program, graph, stderr) + if err != nil { + return false, err + } + if len(changes) == 0 { + return false, nil + } + overlay := make(map[string][]byte, len(changes)) + for _, change := range changes { + overlay[change.name] = change.after + } + if _, err := load(overlay); err != nil { + return false, fmt.Errorf("fixed packages do not type-check; no files changed: %w", err) + } + for name, before := range program.Sources { + resolved, err := filepath.EvalSymlinks(name) + if err != nil { + return false, err + } + if resolved != name { + return false, fmt.Errorf("%s became a symlink during analysis; no files changed", name) + } + current, err := os.ReadFile(name) + if err != nil { + return false, err + } + if !bytes.Equal(current, before) { + return false, fmt.Errorf("%s changed during analysis; no files changed", name) + } + } + for _, change := range changes { + if diff { + if _, err := stdout.Write(internaldiff.Diff(change.name, change.before, change.name, change.after)); err != nil { + return false, err + } + } else { + if err := write(change); err != nil { + return false, fmt.Errorf("writing %s (earlier files may have changed): %w", change.name, err) + } + } + } + return true, nil +} + +func write(change change) error { + if change.mode&0222 == 0 { + return fmt.Errorf("%s: %w", change.name, os.ErrPermission) + } + file, err := os.CreateTemp(filepath.Dir(change.name), ".tinygo-fix-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if _, err := file.Write(change.after); err != nil { + file.Close() + return err + } + if err := file.Chmod(change.mode); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(file.Name(), change.name) +} + +type edit struct { + start, end int + text string +} + +type change struct { + name string + before, after []byte + mode os.FileMode +} + +func prepare(program *loader.Analysis, graph *checker.Graph, stderr io.Writer) ([]change, error) { + byFile := make(map[string][]edit) + filePackages := make(map[string]*types.Package) + roots := slices.Clone(graph.Roots) + slices.SortFunc(roots, func(a, b *checker.Action) int { return strings.Compare(a.String(), b.String()) }) + for _, action := range roots { + diagnostics: + for _, diagnostic := range action.Diagnostics { + if len(diagnostic.SuggestedFixes) == 0 { + if _, err := fmt.Fprintf(stderr, "%s: %s\n", action.Package.Fset.Position(diagnostic.Pos), diagnostic.Message); err != nil { + return nil, err + } + continue + } + // Suggested fixes are alternatives. Use the first, like Go fix. + candidate := make(map[string][]edit) + for _, textEdit := range diagnostic.SuggestedFixes[0].TextEdits { + file := action.Package.Fset.File(textEdit.Pos) + if file == nil || action.Package.Fset.File(textEdit.End) != file { + return nil, errors.New("fix contains an invalid source range") + } + name := file.Name() + filePackages[name] = action.Package.Types + writable, selected := program.Writable[name] + if !selected { + continue diagnostics + } + if !writable { + return nil, fmt.Errorf("%s: cannot safely fix generated or CGo source; no files changed", name) + } + start, end := file.Offset(textEdit.Pos), file.Offset(textEdit.End) + if start > end || end > len(program.Sources[name]) { + return nil, fmt.Errorf("%s: fix contains an invalid offset", name) + } + edits := candidate[name] + if len(edits) > 0 && edits[len(edits)-1].start == start { + last := &edits[len(edits)-1] + last.end = end + last.text += string(textEdit.NewText) + } else { + edits = append(edits, edit{start, end, string(textEdit.NewText)}) + } + candidate[name] = edits + } + for name, edits := range candidate { + byFile[name] = append(byFile[name], edits...) + } + } + } + var changes []change + for name, edits := range byFile { + slices.SortFunc(edits, func(a, b edit) int { + if a.start != b.start { + return a.start - b.start + } + if a.end != b.end { + return a.end - b.end + } + return strings.Compare(a.text, b.text) + }) + edits = slices.Compact(edits) + before := program.Sources[name] + var after []byte + offset := 0 + for i, edit := range edits { + if edit.start < offset || i > 0 && edit.start == edits[i-1].start { + return nil, fmt.Errorf("%s: conflicting fixes; run individual analyzers; no files changed", name) + } + after = append(after, before[offset:edit.start]...) + after = append(after, edit.text...) + offset = edit.end + } + after = append(after, before[offset:]...) + after, err := formatSource(name, after, filePackages[name]) + if err != nil { + return nil, fmt.Errorf("%s: invalid fixed source; no files changed: %w", name, err) + } + if bytes.Equal(before, after) { + continue + } + info, err := os.Stat(name) + if err != nil { + return nil, err + } + changes = append(changes, change{name, before, after, info.Mode().Perm()}) + } + slices.SortFunc(changes, func(a, b change) int { return strings.Compare(a.name, b.name) }) + return changes, nil +} + +func formatSource(name string, source []byte, pkg *types.Package) ([]byte, error) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, name, source, parser.ParseComments) + if err != nil { + return nil, err + } + names := make(map[string]string) + if pkg != nil { + for _, imported := range pkg.Imports() { + names[imported.Path()] = imported.Name() + } + } + used := make(map[string]bool) + ast.Inspect(file, func(node ast.Node) bool { + if selector, ok := node.(*ast.SelectorExpr); ok { + if ident, ok := selector.X.(*ast.Ident); ok && ident.Obj == nil { + used[ident.Name] = true + } + } + return true + }) + for _, spec := range slices.Clone(file.Imports) { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil { + return nil, err + } + explicit := "" + name := names[path] + if spec.Name != nil { + explicit = spec.Name.Name + name = explicit + } + if name != "" && name != "_" && name != "." && !used[name] { + astutil.DeleteNamedImport(fset, file, explicit, path) + } + } + var output bytes.Buffer + if err := format.Node(&output, fset, file); err != nil { + return nil, err + } + return output.Bytes(), nil +} diff --git a/fix/fix_test.go b/fix/fix_test.go new file mode 100644 index 0000000000..0f26142cf6 --- /dev/null +++ b/fix/fix_test.go @@ -0,0 +1,426 @@ +package fix + +import ( + "bytes" + "flag" + "go/token" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/tinygo-org/tinygo/builder" + "github.com/tinygo-org/tinygo/compileopts" + "github.com/tinygo-org/tinygo/compiler" + "github.com/tinygo-org/tinygo/goenv" + "github.com/tinygo-org/tinygo/internal/diff" + "github.com/tinygo-org/tinygo/loader" + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/checker" + "golang.org/x/tools/go/analysis/passes/inline" + "golang.org/x/tools/go/analysis/passes/modernize" + "golang.org/x/tools/go/packages" +) + +func fixture(t *testing.T, files map[string]string, patterns ...string) (Load, string) { + return fixtureWithTests(t, files, true, patterns...) +} + +func fixtureWithTests(t *testing.T, files map[string]string, includeTests bool, patterns ...string) (Load, string) { + t.Helper() + t.Setenv("GOFLAGS", "") + t.Setenv("GOWORK", "off") + root, err := filepath.Abs("..") + if err != nil { + t.Fatal(err) + } + t.Setenv("TINYGOROOT", root) + dir := t.TempDir() + dir, err = filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + files["go.mod"] = "module example.com/probe\n\ngo 1.25.0\n" + for name, contents := range files { + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + t.Chdir(dir) + config, err := builder.NewConfig(&compileopts.Options{ + GOOS: goenv.Get("GOOS"), GOARCH: goenv.Get("GOARCH"), Opt: "z", + }) + if err != nil { + t.Fatal(err) + } + machine, err := compiler.NewTargetMachine(&compiler.Config{ + Triple: config.Triple(), CPU: config.CPU(), Features: config.Features(), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(machine.Dispose) + sizes := compiler.Sizes(machine) + return func(overlay map[string][]byte) (*loader.Analysis, error) { + return loader.LoadForAnalysis(config, patterns, sizes, overlay, includeTests) + }, dir +} + +func read(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + return data +} + +func TestFixPackagesAndTests(t *testing.T) { + files := map[string]string{ + "probe.go": "package probe\nimport _ \"example.com/probe/dep\"\nvar X interface{}\n", + "probe_test.go": "package probe\nvar TestX interface{}\n", + "external_test.go": "package probe_test\nimport \"example.com/probe\"\n" + + "var External interface{} = probe.X\n", + "dep/dep.go": "package dep\nvar Dependency interface{}\n", + } + load, dir := fixture(t, files) + before := read(t, filepath.Join(dir, "probe.go")) + var output, diagnostics bytes.Buffer + changed, err := Run(load, []*analysis.Analyzer{modernize.AnyAnalyzer}, true, &output, &diagnostics) + if err != nil { + t.Fatal(err) + } + if !changed || !strings.Contains(output.String(), "+var X any") || + !strings.Contains(output.String(), "+var TestX any") || + !strings.Contains(output.String(), "+var External any") { + t.Fatalf("missing fixes in diff:\n%s\n%s", &output, &diagnostics) + } + if !bytes.Equal(before, read(t, filepath.Join(dir, "probe.go"))) { + t.Fatal("diff wrote source") + } + changed, err = Run(load, []*analysis.Analyzer{modernize.AnyAnalyzer}, false, &output, &diagnostics) + if err != nil || !changed { + t.Fatalf("fix: changed=%v, err=%v", changed, err) + } + if got := string(read(t, filepath.Join(dir, "dep/dep.go"))); got != files["dep/dep.go"] { + t.Fatalf("dependency changed: %s", got) + } + changed, err = Run(load, []*analysis.Analyzer{modernize.AnyAnalyzer}, false, &output, &diagnostics) + if err != nil || changed { + t.Fatalf("second fix: changed=%v, err=%v", changed, err) + } +} + +func TestPatternsAndTypeIdentity(t *testing.T) { + load, _ := fixture(t, map[string]string{ + "a/a.go": "package a\ntype Value struct{}\n", + "b/b.go": "package b\nimport \"example.com/probe/a\"\nvar V a.Value\n", + "b/b_test.go": "package b\nvar Internal = V\n", + }, "./a", "./b") + program, err := load(nil) + if err != nil { + t.Fatal(err) + } + seen := make(map[string]bool) + for _, pkg := range program.Packages { + seen[pkg.PkgPath] = true + if pkg.PkgPath == "example.com/probe/b" { + a := pkg.Imports["example.com/probe/a"] + if a == nil || pkg.Types.Scope().Lookup("V").Type() != a.Types.Scope().Lookup("Value").Type() { + t.Fatal("imports do not share type identities") + } + } + if pkg.TypesSizes == nil || pkg.TypesInfo == nil || pkg.Fset == nil { + t.Fatal("incomplete analysis inputs") + } + } + if !seen["example.com/probe/a"] || !seen["example.com/probe/b"] { + t.Fatalf("missing roots: %v", seen) + } +} + +type packageFact struct{ Value bool } + +func (*packageFact) AFact() {} + +func TestDependencyFacts(t *testing.T) { + load, _ := fixture(t, map[string]string{ + "probe.go": "package probe\nimport _ \"example.com/probe/dep\"\n", + "dep/dep.go": "package dep\n", + }) + found := false + analyzer := &analysis.Analyzer{ + Name: "facts", Doc: "check dependency facts", FactTypes: []analysis.Fact{new(packageFact)}, + Run: func(pass *analysis.Pass) (any, error) { + for _, imported := range pass.Pkg.Imports() { + var fact packageFact + if imported.Path() == "example.com/probe/dep" && pass.ImportPackageFact(imported, &fact) { + found = fact.Value + } + } + pass.ExportPackageFact(&packageFact{true}) + return nil, nil + }, + } + _, err := Run(load, []*analysis.Analyzer{analyzer}, true, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil || !found { + t.Fatalf("dependency facts: found=%v, err=%v", found, err) + } +} + +func TestLanguageVersion(t *testing.T) { + load, _ := fixture(t, map[string]string{ + "probe.go": "package probe\nfunc Pointer() *int { v := 1; return &v }\n", + }) + changed, err := Run(load, []*analysis.Analyzer{modernize.NewExprAnalyzer}, true, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil || changed { + t.Fatalf("Go 1.25 source received new(expr): changed=%v, err=%v", changed, err) + } +} + +func TestNewImport(t *testing.T) { + load, dir := fixture(t, map[string]string{ + "probe.go": "package probe\nimport \"sort\"\nfunc Sort(xs []int) { sort.Slice(xs, func(i, j int) bool { return xs[i] < xs[j] }) }\n", + }) + changed, err := Run(load, []*analysis.Analyzer{modernize.SlicesSortAnalyzer}, false, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil || !changed { + t.Fatalf("new import: changed=%v, err=%v", changed, err) + } + if got := read(t, filepath.Join(dir, "probe.go")); !bytes.Contains(got, []byte("slices.Sort(xs)")) { + t.Fatalf("sort fix not written:\n%s", got) + } +} + +func TestInlineAcrossPackages(t *testing.T) { + load, dir := fixture(t, map[string]string{ + "probe.go": "package probe\nimport \"example.com/probe/dep\"\nvar X = dep.Old(2)\n", + "dep/dep.go": "package dep\n//go:fix inline\nfunc Old(x int) int { return x + 1 }\n", + }) + changed, err := Run(load, []*analysis.Analyzer{inline.Analyzer}, false, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil || !changed { + t.Fatalf("inline: changed=%v, err=%v", changed, err) + } + if got := read(t, filepath.Join(dir, "probe.go")); bytes.Contains(got, []byte("dep.Old")) { + t.Fatalf("call was not inlined:\n%s", got) + } +} + +func TestTinyGoSourceWrite(t *testing.T) { + load, _ := fixture(t, map[string]string{"probe.go": "package probe\n"}, "internal/itoa") + originalRoot := goenv.Get("TINYGOROOT") + root := t.TempDir() + root, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + if err := os.CopyFS(filepath.Join(root, "src"), os.DirFS(filepath.Join(originalRoot, "src"))); err != nil { + t.Fatal(err) + } + name := filepath.Join(root, "src", "internal", "itoa", "modernize_probe.go") + source := []byte("package itoa\nvar modernizeProbe interface{}\n") + if err := os.WriteFile(name, source, 0600); err != nil { + t.Fatal(err) + } + t.Setenv("TINYGOROOT", root) + program, err := load(nil) + if err != nil { + t.Fatal(err) + } + if !program.Writable[name] { + t.Fatalf("original source path is not editable: %s", name) + } + for _, pkg := range program.Packages { + for _, version := range pkg.TypesInfo.FileVersions { + want := "go1." + strconv.Itoa(compileopts.MinimumGoMinorVersion) + if version != want { + t.Fatalf("library language version = %s, want %s", version, want) + } + } + } + changed, err := Run(load, []*analysis.Analyzer{modernize.AnyAnalyzer}, false, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil || !changed { + t.Fatalf("library write: changed=%v, err=%v", changed, err) + } + if !bytes.Contains(read(t, name), []byte("var modernizeProbe any")) { + t.Fatal("fix did not reach the original TinyGo file") + } +} + +func TestCGoSourceSafety(t *testing.T) { + for _, cgoEdit := range []bool{false, true} { + t.Run(map[bool]string{false: "Go file", true: "CGo file"}[cgoEdit], func(t *testing.T) { + cgoSource := "package probe\n// typedef int value;\nimport \"C\"\nvar Value C.value\n" + goSource := "package probe\nvar X interface{}\n" + if cgoEdit { + cgoSource += "var Y interface{}\n" + } + load, dir := fixture(t, map[string]string{"cgo.go": cgoSource, "probe.go": goSource}) + var diagnostics bytes.Buffer + changed, err := Run(load, []*analysis.Analyzer{modernize.AnyAnalyzer}, false, &bytes.Buffer{}, &diagnostics) + if err != nil || !changed { + t.Fatalf("unchanged Go AST: changed=%v, err=%v", changed, err) + } + if !strings.Contains(diagnostics.String(), "skipping source rewritten by CGo") { + t.Fatalf("missing CGo notice: %s", &diagnostics) + } + if got := string(read(t, filepath.Join(dir, "cgo.go"))); got != cgoSource { + t.Fatal("rewritten CGo AST was written to source") + } + }) + } +} + +func TestAnalyzerSelection(t *testing.T) { + for _, test := range []struct { + args []string + want func(string) bool + }{ + {nil, func(string) bool { return true }}, + {[]string{"-any"}, func(s string) bool { return s == "any" }}, + {[]string{"-any=false"}, func(s string) bool { return s != "any" }}, + {[]string{"-any", "-rangeint"}, func(s string) bool { return s == "any" || s == "rangeint" }}, + } { + set := flag.NewFlagSet("test", flag.ContinueOnError) + flags := RegisterFlags(set) + if err := set.Parse(test.args); err != nil { + t.Fatal(err) + } + selected := make(map[string]bool) + for _, a := range flags.Selected() { + selected[a.Name] = true + } + for _, a := range Analyzers() { + if selected[a.Name] != test.want(a.Name) { + t.Fatalf("%v: unexpected selection for %s", test.args, a.Name) + } + } + } +} + +func TestEditSafety(t *testing.T) { + for _, test := range []struct { + name, source, want string + mutate bool + }{ + {"generated", "// Code generated by fixture. DO NOT EDIT.\npackage probe\nvar X interface{}\n", "cannot safely fix", false}, + {"stale", "package probe\nvar X interface{}\n", "changed during analysis", true}, + {"invalid", "package probe\nvar X interface{}\n", "invalid fixed source", false}, + {"conflict", "package probe\nvar X interface{}\n", "conflicting fixes", false}, + {"type error", "package probe\nvar X interface{}\n", "do not type-check", false}, + } { + t.Run(test.name, func(t *testing.T) { + load, dir := fixture(t, map[string]string{"probe.go": test.source}) + path := filepath.Join(dir, "probe.go") + analyzer := &analysis.Analyzer{ + Name: "safety", Doc: "test unsafe edits", + Run: func(pass *analysis.Pass) (any, error) { + if test.mutate { + if err := os.WriteFile(path, []byte(test.source+"\n"), 0600); err != nil { + return nil, err + } + } + file := pass.Files[0] + replacement := "package probe\nvar Y any\n" + if test.name == "invalid" { + replacement = "invalid {" + } else if test.name == "type error" { + replacement = "package probe\nvar X missing\n" + } + edits := []analysis.TextEdit{{Pos: file.FileStart, End: file.FileEnd, NewText: []byte(replacement)}} + pass.Report(analysis.Diagnostic{Pos: file.Pos(), SuggestedFixes: []analysis.SuggestedFix{{TextEdits: edits}}}) + if test.name == "conflict" { + pass.Report(analysis.Diagnostic{Pos: file.Pos(), SuggestedFixes: []analysis.SuggestedFix{{ + TextEdits: []analysis.TextEdit{{Pos: file.FileStart, End: file.FileEnd, NewText: []byte("package probe\nvar Z any\n")}}, + }}}) + } + return nil, nil + }, + } + _, err := Run(load, []*analysis.Analyzer{analyzer}, false, &bytes.Buffer{}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("got %v, want %s", err, test.want) + } + if !test.mutate && string(read(t, path)) != test.source { + t.Fatal("failed fix wrote source") + } + }) + } +} + +func TestAnalyzerError(t *testing.T) { + load, _ := fixture(t, map[string]string{"probe.go": "package probe\n"}) + analyzer := &analysis.Analyzer{ + Name: "failure", Doc: "fail", + Run: func(*analysis.Pass) (any, error) { return nil, os.ErrPermission }, + } + _, err := Run(load, []*analysis.Analyzer{analyzer}, false, &bytes.Buffer{}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("analyzer error was lost: %v", err) + } +} + +func TestUnified(t *testing.T) { + if _, err := exec.LookPath("patch"); err != nil { + t.Skip("patch is not available") + } + for _, test := range []struct{ before, after string }{ + {"", "new\n"}, {"old\n", ""}, {"old", "new"}, + {"a\nb\nc\nd\ne\nf\ng\nh\n", "a\nb\nC\nd\ne\nf\nG\nh\n"}, + } { + dir := t.TempDir() + name := filepath.Join(dir, "source.go") + if err := os.WriteFile(name, []byte(test.before), 0600); err != nil { + t.Fatal(err) + } + cmd := exec.Command("patch", "--quiet", name) + cmd.Stdin = strings.NewReader(string(diff.Diff(name, []byte(test.before), name, []byte(test.after)))) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("patch: %v\n%s", err, output) + } + if got := string(read(t, name)); got != test.after { + t.Fatalf("patch produced %q, want %q", got, test.after) + } + } +} + +func TestPrepareRejectsInvalidOffsets(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.go") + fset := token.NewFileSet() + file := fset.AddFile(path, -1, 100) + graph := &checker.Graph{Roots: []*checker.Action{{ + Analyzer: &analysis.Analyzer{Name: "offset"}, + Package: &packages.Package{ID: "probe", Fset: fset}, + Diagnostics: []analysis.Diagnostic{{SuggestedFixes: []analysis.SuggestedFix{{ + TextEdits: []analysis.TextEdit{{Pos: file.Pos(80), End: file.Pos(90)}}, + }}}}, + }}} + _, err := prepare(&loader.Analysis{ + Sources: map[string][]byte{path: []byte("package probe\n")}, + Writable: map[string]bool{path: true}, + }, graph, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "invalid offset") { + t.Fatalf("got %v, want invalid offset", err) + } + +} + +func TestWriteFailure(t *testing.T) { + dir := t.TempDir() + name := filepath.Join(dir, "readonly.go") + if err := os.WriteFile(name, []byte("before"), 0400); err != nil { + t.Fatal(err) + } + if err := write(change{name: name, after: []byte("after"), mode: 0400}); err == nil { + t.Fatal("read-only file was overwritten") + } + if got := string(read(t, name)); got != "before" { + t.Fatalf("failed write changed source to %q", got) + } +} diff --git a/fix_test.go b/fix_test.go new file mode 100644 index 0000000000..9258cd2676 --- /dev/null +++ b/fix_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/tinygo-org/tinygo/goenv" +) + +func fixCommand(t *testing.T, dir string, args ...string) (int, string) { + t.Helper() + cmd := exec.Command(os.Args[0], append([]string{"test-main", "fix"}, args...)...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err == nil { + return 0, string(output) + } + if exit, ok := err.(*exec.ExitError); ok { + return exit.ExitCode(), string(output) + } + t.Fatal(err) + return 0, "" +} + +func TestFixCommand(t *testing.T) { + root, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Setenv("TINYGOROOT", root) + t.Setenv("GOWORK", "off") + t.Setenv("GOFLAGS", "") + dir := t.TempDir() + source := "package probe\n\nvar X interface{}\n" + path := filepath.Join(dir, "probe.go") + for name, content := range map[string]string{ + "go.mod": "module example.com/probe\n\ngo 1.25.0\n", "probe.go": source, + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + status, output := fixCommand(t, root, "-C", dir, "-diff", "-any") + if status != 1 || !strings.Contains(output, "+var X any") { + t.Fatalf("diff status=%d:\n%s", status, output) + } + before, err := os.ReadFile(path) + if err != nil || string(before) != source { + t.Fatalf("diff changed source: %q, %v", before, err) + } + status, output = fixCommand(t, dir, "-any") + if status != 0 { + t.Fatalf("write status=%d:\n%s", status, output) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Contains(after, []byte("var X any")) { + t.Fatalf("fix did not persist: %q, %v", after, err) + } + t.Run("go fix parity", func(t *testing.T) { + _, minor, err := goenv.GetGorootVersion() + if err != nil { + t.Fatal(err) + } + if minor < 26 { + t.Skip("Go 1.25 does not have the analyzer-based fix command") + } + reference := t.TempDir() + for name, content := range map[string]string{ + "go.mod": "module example.com/reference\n\ngo 1.25.0\n", "probe.go": source, + } { + if err := os.WriteFile(filepath.Join(reference, name), []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + cmd := exec.Command(filepath.Join(goenv.Get("GOROOT"), "bin", "go"), "fix", "-any") + cmd.Dir = reference + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go fix: %v\n%s", err, output) + } + want, err := os.ReadFile(filepath.Join(reference, "probe.go")) + if err != nil || !bytes.Equal(after, want) { + t.Fatalf("tinygo fix differs from go fix:\ngot %s\nwant %s\n%v", after, want, err) + } + }) + status, output = fixCommand(t, dir, "-diff", "-any") + if status != 0 || output != "" { + t.Fatalf("second run status=%d:\n%s", status, output) + } +} + +func TestFixHelpAndFlags(t *testing.T) { + for _, test := range []struct { + args []string + status int + want string + }{ + {[]string{"help"}, 0, "rangeint"}, + {[]string{"help", "any"}, 0, "interface"}, + {[]string{"help", "missing"}, 2, "unknown analyzer"}, + {[]string{"-fixtool=other"}, 2, "flag provided but not defined"}, + {[]string{"-toolexec=other"}, 2, "flag provided but not defined"}, + {[]string{"-tests=false", "-help"}, 0, "include test files"}, + {[]string{"-diff", "./does-not-exist"}, 1, "does-not-exist"}, + {[]string{"-diff", "main.go"}, 1, "select a package"}, + } { + status, output := fixCommand(t, ".", test.args...) + if status != test.status || !strings.Contains(output, test.want) { + t.Fatalf("%v: status=%d, want=%d:\n%s", test.args, status, test.status, output) + } + } +} + +func TestFixExcludesUnbuildableTargetTests(t *testing.T) { + root, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Setenv("TINYGOROOT", root) + t.Setenv("GOWORK", "off") + t.Setenv("GOFLAGS", "") + dir := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/probe\n\ngo 1.25.0\n", + "probe.go": "package probe\nvar X interface{}\n", + "probe_test.go": "//go:build tinygo\n\npackage probe\nvar _ = missingTargetAPI\n", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + status, output := fixCommand(t, dir, "-diff", "-any") + if status != 1 || !strings.Contains(output, "missingTargetAPI") { + t.Fatalf("default test analysis status=%d, want target test type error:\n%s", status, output) + } + status, output = fixCommand(t, dir, "-tests=false", "-diff", "-any") + if status != 1 || !strings.Contains(output, "+var X any") || + strings.Contains(output, "missingTargetAPI") { + t.Fatalf("fix without tests status=%d:\n%s", status, output) + } +} + +func TestFixTinyGoSource(t *testing.T) { + for _, args := range [][]string{ + {"-diff", "-any", "./src/internal/itoa"}, + {"-diff", "internal/itoa"}, + {"-target=cortex-m-qemu", "-diff", "-any", "internal/itoa"}, + } { + status, output := fixCommand(t, ".", args...) + if status != 0 { + t.Fatalf("%v: status=%d:\n%s", args, status, output) + } + } + status, output := fixCommand(t, ".", "-diff", "runtime") + if status != 1 || !strings.Contains(output, "--- ") || !strings.Contains(output, "/src/runtime/") { + t.Fatalf("runtime default suite: status=%d:\n%s", status, output) + } +} + +func TestFixTargetSelection(t *testing.T) { + root, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Setenv("TINYGOROOT", root) + t.Setenv("GOWORK", "off") + t.Setenv("GOFLAGS", "") + dir := t.TempDir() + files := map[string]string{ + "go.mod": "module example.com/target\n\ngo 1.25.0\n", + "common.go": "package probe\nvar Common interface{}\n", + "selected.go": "//go:build selected\n\npackage probe\nvar Selected interface{}\n", + "other.go": "//go:build !selected\n\npackage probe\nvar Other interface{}\n", + "avr.go": "//go:build avr\n\npackage probe\nimport \"unsafe\"\n" + + "const IntSize = unsafe.Sizeof(int(0))\nconst PointerSize = unsafe.Sizeof(uintptr(0))\n" + + "var _ [4-IntSize]byte\nvar _ [IntSize-4]byte\nvar _ [2-PointerSize]byte\nvar _ [PointerSize-2]byte\n" + + "var AVR interface{}\n", + } + for name, source := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(source), 0600); err != nil { + t.Fatal(err) + } + } + for _, args := range [][]string{{"-tags=selected", "-any"}, {"-target=arduino", "-any"}} { + status, output := fixCommand(t, dir, args...) + if status != 0 { + t.Fatalf("%v: status=%d:\n%s", args, status, output) + } + selected, err := os.ReadFile(filepath.Join(dir, "selected.go")) + if err != nil || !bytes.Contains(selected, []byte("Selected any")) { + t.Fatalf("selected file was not fixed: %s\n%v", selected, err) + } + other, err := os.ReadFile(filepath.Join(dir, "other.go")) + if err != nil { + t.Fatal(err) + } + if args[0] == "-tags=selected" && string(other) != files["other.go"] { + t.Fatal("file excluded by build tags was changed") + } + } + avr, err := os.ReadFile(filepath.Join(dir, "avr.go")) + if err != nil || !bytes.Contains(avr, []byte("AVR any")) { + t.Fatalf("AVR source was not fixed: %s\n%v", avr, err) + } +} diff --git a/go.mod b/go.mod index a605bc7a57..db998105a6 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,7 @@ require ( github.com/urfave/cli/v3 v3.0.0-beta1 // indirect go.bytecodealliance.org v0.6.2 // indirect go.bytecodealliance.org/cm v0.2.2 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/text v0.38.0 // indirect ) diff --git a/internal/diff/diff.go b/internal/diff/diff.go new file mode 100644 index 0000000000..6a40b23fcb --- /dev/null +++ b/internal/diff/diff.go @@ -0,0 +1,261 @@ +// Copyright 2022 The Go 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 diff + +import ( + "bytes" + "fmt" + "sort" + "strings" +) + +// A pair is a pair of values tracked for both the x and y side of a diff. +// It is typically a pair of line indexes. +type pair struct{ x, y int } + +// Diff returns an anchored diff of the two texts old and new +// in the “unified diff” format. If old and new are identical, +// Diff returns a nil slice (no output). +// +// Unix diff implementations typically look for a diff with +// the smallest number of lines inserted and removed, +// which can in the worst case take time quadratic in the +// number of lines in the texts. As a result, many implementations +// either can be made to run for a long time or cut off the search +// after a predetermined amount of work. +// +// In contrast, this implementation looks for a diff with the +// smallest number of “unique” lines inserted and removed, +// where unique means a line that appears just once in both old and new. +// We call this an “anchored diff” because the unique lines anchor +// the chosen matching regions. An anchored diff is usually clearer +// than a standard diff, because the algorithm does not try to +// reuse unrelated blank lines or closing braces. +// The algorithm also guarantees to run in O(n log n) time +// instead of the standard O(n²) time. +// +// Some systems call this approach a “patience diff,” named for +// the “patience sorting” algorithm, itself named for a solitaire card game. +// We avoid that name for two reasons. First, the name has been used +// for a few different variants of the algorithm, so it is imprecise. +// Second, the name is frequently interpreted as meaning that you have +// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm, +// when in fact the algorithm is faster than the standard one. +func Diff(oldName string, old []byte, newName string, new []byte) []byte { + if bytes.Equal(old, new) { + return nil + } + x := lines(old) + y := lines(new) + + // Print diff header. + var out bytes.Buffer + fmt.Fprintf(&out, "diff %s %s\n", oldName, newName) + fmt.Fprintf(&out, "--- %s\n", oldName) + fmt.Fprintf(&out, "+++ %s\n", newName) + + // Loop over matches to consider, + // expanding each match to include surrounding lines, + // and then printing diff chunks. + // To avoid setup/teardown cases outside the loop, + // tgs returns a leading {0,0} and trailing {len(x), len(y)} pair + // in the sequence of matches. + var ( + done pair // printed up to x[:done.x] and y[:done.y] + chunk pair // start lines of current chunk + count pair // number of lines from each side in current chunk + ctext []string // lines for current chunk + ) + for _, m := range tgs(x, y) { + if m.x < done.x { + // Already handled scanning forward from earlier match. + continue + } + + // Expand matching lines as far as possible, + // establishing that x[start.x:end.x] == y[start.y:end.y]. + // Note that on the first (or last) iteration we may (or definitely do) + // have an empty match: start.x==end.x and start.y==end.y. + start := m + for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] { + start.x-- + start.y-- + } + end := m + for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] { + end.x++ + end.y++ + } + + // Emit the mismatched lines before start into this chunk. + // (No effect on first sentinel iteration, when start = {0,0}.) + for _, s := range x[done.x:start.x] { + ctext = append(ctext, "-"+s) + count.x++ + } + for _, s := range y[done.y:start.y] { + ctext = append(ctext, "+"+s) + count.y++ + } + + // If we're not at EOF and have too few common lines, + // the chunk includes all the common lines and continues. + const C = 3 // number of context lines + if (end.x < len(x) || end.y < len(y)) && + (end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) { + for _, s := range x[start.x:end.x] { + ctext = append(ctext, " "+s) + count.x++ + count.y++ + } + done = end + continue + } + + // End chunk with common lines for context. + if len(ctext) > 0 { + n := end.x - start.x + if n > C { + n = C + } + for _, s := range x[start.x : start.x+n] { + ctext = append(ctext, " "+s) + count.x++ + count.y++ + } + done = pair{start.x + n, start.y + n} + + // Format and emit chunk. + // Convert line numbers to 1-indexed. + // Special case: empty file shows up as 0,0 not 1,0. + if count.x > 0 { + chunk.x++ + } + if count.y > 0 { + chunk.y++ + } + fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y) + for _, s := range ctext { + out.WriteString(s) + } + count.x = 0 + count.y = 0 + ctext = ctext[:0] + } + + // If we reached EOF, we're done. + if end.x >= len(x) && end.y >= len(y) { + break + } + + // Otherwise start a new chunk. + chunk = pair{end.x - C, end.y - C} + for _, s := range x[chunk.x:end.x] { + ctext = append(ctext, " "+s) + count.x++ + count.y++ + } + done = end + } + + return out.Bytes() +} + +// lines returns the lines in the file x, including newlines. +// If the file does not end in a newline, one is supplied +// along with a warning about the missing newline. +func lines(x []byte) []string { + l := strings.SplitAfter(string(x), "\n") + if l[len(l)-1] == "" { + l = l[:len(l)-1] + } else { + // Treat last line as having a message about the missing newline attached, + // using the same text as BSD/GNU diff (including the leading backslash). + l[len(l)-1] += "\n\\ No newline at end of file\n" + } + return l +} + +// tgs returns the pairs of indexes of the longest common subsequence +// of unique lines in x and y, where a unique line is one that appears +// once in x and once in y. +// +// The longest common subsequence algorithm is as described in +// Thomas G. Szymanski, “A Special Case of the Maximal Common +// Subsequence Problem,” Princeton TR #170 (January 1975), +// available at https://research.swtch.com/tgs170.pdf. +func tgs(x, y []string) []pair { + // Count the number of times each string appears in a and b. + // We only care about 0, 1, many, counted as 0, -1, -2 + // for the x side and 0, -4, -8 for the y side. + // Using negative numbers now lets us distinguish positive line numbers later. + m := make(map[string]int) + for _, s := range x { + if c := m[s]; c > -2 { + m[s] = c - 1 + } + } + for _, s := range y { + if c := m[s]; c > -8 { + m[s] = c - 4 + } + } + + // Now unique strings can be identified by m[s] = -1+-4. + // + // Gather the indexes of those strings in x and y, building: + // xi[i] = increasing indexes of unique strings in x. + // yi[i] = increasing indexes of unique strings in y. + // inv[i] = index j such that x[xi[i]] = y[yi[j]]. + var xi, yi, inv []int + for i, s := range y { + if m[s] == -1+-4 { + m[s] = len(yi) + yi = append(yi, i) + } + } + for i, s := range x { + if j, ok := m[s]; ok && j >= 0 { + xi = append(xi, i) + inv = append(inv, j) + } + } + + // Apply Algorithm A from Szymanski's paper. + // In those terms, A = J = inv and B = [0, n). + // We add sentinel pairs {0,0}, and {len(x),len(y)} + // to the returned sequence, to help the processing loop. + J := inv + n := len(xi) + T := make([]int, n) + L := make([]int, n) + for i := range T { + T[i] = n + 1 + } + for i := 0; i < n; i++ { + k := sort.Search(n, func(k int) bool { + return T[k] >= J[i] + }) + T[k] = J[i] + L[i] = k + 1 + } + k := 0 + for _, v := range L { + if k < v { + k = v + } + } + seq := make([]pair, 2+k) + seq[1+k] = pair{len(x), len(y)} // sentinel at end + lastj := n + for i := n - 1; i >= 0; i-- { + if L[i] == k && J[i] < lastj { + seq[k] = pair{xi[i], yi[J[i]]} + k-- + } + } + seq[0] = pair{0, 0} // sentinel at start + return seq +} diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go new file mode 100644 index 0000000000..2c43a105a9 --- /dev/null +++ b/internal/diff/diff_test.go @@ -0,0 +1,30 @@ +package diff + +import "testing" + +func TestSeparateHunks(t *testing.T) { + old := []byte("one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\n") + new := []byte("one\nTWO\nthree\nfour\nfive\nsix\nseven\neight\nnine\nTEN\neleven\ntwelve\n") + got := string(Diff("source.go", old, "source.go", new)) + want := "diff source.go source.go\n" + + "--- source.go\n" + + "+++ source.go\n" + + "@@ -1,5 +1,5 @@\n" + + " one\n" + + "-two\n" + + "+TWO\n" + + " three\n" + + " four\n" + + " five\n" + + "@@ -7,6 +7,6 @@\n" + + " seven\n" + + " eight\n" + + " nine\n" + + "-ten\n" + + "+TEN\n" + + " eleven\n" + + " twelve\n" + if got != want { + t.Fatalf("diff output:\n%s\nwant:\n%s", got, want) + } +} diff --git a/loader/analysis.go b/loader/analysis.go new file mode 100644 index 0000000000..8b2a8768c7 --- /dev/null +++ b/loader/analysis.go @@ -0,0 +1,346 @@ +package loader + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/build" + "go/types" + "go/version" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/tinygo-org/tinygo/compileopts" + "github.com/tinygo-org/tinygo/goenv" + "golang.org/x/tools/go/packages" +) + +// Analysis holds a type-checked package graph and its editable source files. +type Analysis struct { + Packages []*packages.Package + Sources map[string][]byte + Writable map[string]bool + Skipped []string +} + +// LoadForAnalysis retains distinct package identities when includeTests is true. +// Overlay keys are absolute original source paths, not cached GOROOT paths. +func LoadForAnalysis(config *compileopts.Config, patterns []string, sizes types.Sizes, overlay map[string][]byte, includeTests bool) (*Analysis, error) { + if sizes == nil { + return nil, fmt.Errorf("analysis requires target type sizes") + } + if len(patterns) == 0 { + patterns = []string{"."} + } + for _, pattern := range patterns { + if strings.HasSuffix(pattern, ".go") { + return nil, fmt.Errorf("named Go files are not supported; select a package instead of %s", pattern) + } + } + patterns, err := analysisPatterns(config, patterns) + if err != nil { + return nil, err + } + cfg := *config + cfg.TestConfig.CompileTestBinary = includeTests + program, err := load(&cfg, patterns, types.Config{Sizes: sizes}, true, overlay) + if err != nil { + return nil, err + } + if err := program.Parse(); err != nil { + return nil, err + } + result := &Analysis{ + Sources: program.sources, + Writable: make(map[string]bool), + } + graph := make(map[string]*packages.Package) + byTypes := make(map[*types.Package]*packages.Package) + minimumGoVersion := fmt.Sprintf("go1.%d", compileopts.MinimumGoMinorVersion) + tinySrc, err := analysisPath(filepath.Join(goenv.Get("TINYGOROOT"), "src")) + if err != nil { + return nil, fmt.Errorf("resolve TinyGo source root %q: %w", filepath.Join(goenv.Get("TINYGOROOT"), "src"), err) + } + for _, pkg := range program.sorted { + converted := &packages.Package{ + ID: pkg.ImportPath, Name: pkg.Name, PkgPath: pkg.Pkg.Path(), + Dir: pkg.OriginalDir(), ForTest: pkg.ForTest, + Fset: program.fset, + Types: pkg.Pkg, TypesInfo: &pkg.info, TypesSizes: sizes, + Imports: make(map[string]*packages.Package), + } + if pkg.Module.Path != "" { + converted.Module = &packages.Module{ + Path: pkg.Module.Path, Version: pkg.Module.Version, + GoVersion: pkg.Module.GoVersion, Dir: pkg.Module.Dir, + GoMod: pkg.Module.GoMod, Main: pkg.Module.Main, + } + } + for _, file := range pkg.Files { + name := program.fset.File(file.FileStart).Name() + if _, ok := program.sources[name]; ok { + converted.GoFiles = append(converted.GoFiles, name) + if !program.rewritten[name] { + converted.Syntax = append(converted.Syntax, file) + converted.CompiledGoFiles = append(converted.CompiledGoFiles, name) + } + } + if pkg.Standard || within(tinySrc, name) { + // Unconstrained library files must use the minimum supported Go version. + pkg.info.FileVersions[file] = version.Lang(versionMax(minimumGoVersion, file.GoVersion)) + } + } + graph[pkg.ImportPath] = converted + byTypes[pkg.Pkg] = converted + if pkg.DepOnly || strings.HasSuffix(pkg.ImportPath, ".test") { + continue + } + result.Packages = append(result.Packages, converted) + var sourceRoot string + if pkg.Module.Main { + sourceRoot = pkg.Module.Dir + } else if pkg.Module.Path == "" && !pkg.Standard && pkg.Root != "" { + sourceRoot = filepath.Join(pkg.Root, "src") + } + if sourceRoot != "" { + originalRoot := sourceRoot + sourceRoot, err = analysisPath(sourceRoot) + if err != nil { + return nil, fmt.Errorf("resolve analysis package root %q: %w", originalRoot, err) + } + } + for _, file := range pkg.Files { + name := program.fset.File(file.FileStart).Name() + if _, exists := program.sources[name]; !exists { + continue + } + if sourceRoot != "" && within(sourceRoot, name) || within(tinySrc, name) { + // CGo rewrites ASTs before type checking. + writable := !program.rewritten[name] && !ast.IsGenerated(file) + if previous, exists := result.Writable[name]; exists { + writable = writable && previous + } + result.Writable[name] = writable + } + } + } + for _, pkg := range program.sorted { + converted := graph[pkg.ImportPath] + for _, file := range pkg.Files { + for _, spec := range file.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil { + return nil, err + } + if path == "C" { + continue + } + imported, err := pkg.Import(path) + if err != nil { + return nil, err + } + dependency := byTypes[imported] + if dependency == nil { + return nil, fmt.Errorf("%s: missing analysis dependency %s", pkg.ImportPath, path) + } + converted.Imports[path] = dependency + } + } + } + if len(result.Packages) == 0 { + return nil, fmt.Errorf("no packages matched %s", strings.Join(patterns, " ")) + } + for name := range result.Writable { + if program.rewritten[name] { + result.Skipped = append(result.Skipped, name) + } + } + sort.Strings(result.Skipped) + return result, nil +} + +func analysisPatterns(config *compileopts.Config, patterns []string) ([]string, error) { + root, err := analysisPath(filepath.Join(goenv.Get("TINYGOROOT"), "src")) + if err != nil { + return nil, fmt.Errorf("resolve TinyGo source root %q: %w", filepath.Join(goenv.Get("TINYGOROOT"), "src"), err) + } + var result []string + for _, pattern := range patterns { + if !filepath.IsAbs(pattern) && !strings.HasPrefix(pattern, ".") { + result = append(result, pattern) + continue + } + patternPath := filepath.FromSlash(pattern) + recursive := strings.HasSuffix(patternPath, string(filepath.Separator)+"...") + base := strings.TrimSuffix(patternPath, string(filepath.Separator)+"...") + if strings.Contains(base, "...") { + result = append(result, pattern) + continue + } + if config.Options.Directory != "" && !filepath.IsAbs(base) { + base = filepath.Join(config.Options.Directory, base) + } + base, err = filepath.Abs(base) + if err != nil { + return nil, err + } + originalBase := base + base, err = analysisPath(base) + if err != nil { + return nil, fmt.Errorf("resolve analysis pattern %q: %w", originalBase, err) + } + if !within(root, base) { + result = append(result, pattern) + continue + } + rel, err := filepath.Rel(root, base) + if err != nil { + return nil, err + } + if recursive { + dirs, err := analysisPackageDirs(base, config) + if err != nil { + return nil, err + } + matched := false + for _, dir := range dirs { + rel, err := filepath.Rel(root, dir) + if err != nil { + return nil, err + } + if rel != "." { + result = append(result, filepath.ToSlash(rel)) + matched = true + } + } + if !matched { + return nil, fmt.Errorf("no packages matched %s", pattern) + } + continue + } + if rel == "." { + return nil, fmt.Errorf("no Go package in TinyGo src root; select a package or use ./src/...") + } + result = append(result, filepath.ToSlash(rel)) + } + return result, nil +} + +func analysisPackageDirs(root string, config *compileopts.Config) ([]string, error) { + context := build.Default + context.GOOS = config.GOOS() + context.GOARCH = config.GOARCH() + context.CgoEnabled = true + context.BuildTags = append(append([]string(nil), context.BuildTags...), config.BuildTags()...) + found := make(map[string]bool) + err := filepath.WalkDir(root, func(name string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if name != root { + base := entry.Name() + if strings.HasPrefix(base, ".") || strings.HasPrefix(base, "_") || + base == "testdata" || base == "vendor" { + return filepath.SkipDir + } + } + return nil + } + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + return nil + } + match, err := context.MatchFile(filepath.Dir(name), entry.Name()) + if err != nil { + return err + } + if match { + found[filepath.Dir(name)] = true + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("walk TinyGo source tree %q: %w", root, err) + } + dirs := make([]string, 0, len(found)) + for dir := range found { + dirs = append(dirs, dir) + } + sort.Strings(dirs) + return dirs, nil +} + +func analysisImportPath(path string) string { + if index := strings.Index(path, " ["); index >= 0 { + return path[:index] + } + return path +} + +func versionMax(a, b string) string { + if version.Compare(a, b) < 0 { + return b + } + return a +} + +func within(dir, name string) bool { + rel, err := filepath.Rel(dir, name) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func analysisOverlay(goroot string, overlay map[string][]byte) (func(), []string, error) { + if len(overlay) == 0 { + return func() {}, nil, nil + } + dir, err := os.MkdirTemp("", "tinygo-analysis-") + if err != nil { + return nil, nil, err + } + cleanup := func() { os.RemoveAll(dir) } + replacements := make(map[string]string) + for name, data := range overlay { + file, err := os.CreateTemp(dir, "*.go") + if err != nil { + cleanup() + return nil, nil, err + } + if _, err := file.Write(data); err != nil { + file.Close() + cleanup() + return nil, nil, err + } + if err := file.Close(); err != nil { + cleanup() + return nil, nil, err + } + replacements[name] = file.Name() + for _, root := range []string{goenv.Get("TINYGOROOT"), goenv.Get("GOROOT")} { + sourceRoot, err := analysisPath(filepath.Join(root, "src")) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("resolve analysis overlay source root %q: %w", filepath.Join(root, "src"), err) + } + if within(sourceRoot, name) { + rel, err := filepath.Rel(sourceRoot, name) + if err != nil { + cleanup() + return nil, nil, err + } + replacements[filepath.Join(goroot, "src", rel)] = file.Name() + } + } + } + data, err := json.Marshal(struct{ Replace map[string]string }{replacements}) + if err == nil { + err = os.WriteFile(filepath.Join(dir, "overlay.json"), data, 0600) + } + if err != nil { + cleanup() + return nil, nil, err + } + return cleanup, []string{"-overlay=" + filepath.Join(dir, "overlay.json")}, nil +} diff --git a/loader/analysispath_test.go b/loader/analysispath_test.go new file mode 100644 index 0000000000..0ed2dad6c2 --- /dev/null +++ b/loader/analysispath_test.go @@ -0,0 +1,123 @@ +package loader + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/tinygo-org/tinygo/compileopts" + "github.com/tinygo-org/tinygo/goenv" +) + +func TestAnalysisPath(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.Mkdir(target, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "probe.go"), []byte("package probe\n"), 0600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link") + if err := symlink(target, link); err != nil { + t.Fatal(err) + } + for _, suffix := range []string{"", "probe.go"} { + want, err := analysisPath(filepath.Join(target, suffix)) + if err != nil { + t.Fatal(err) + } + got, err := analysisPath(filepath.Join(link, suffix)) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("resolved path = %q, want %q", got, want) + } + } + if _, err := analysisPath(filepath.Join(dir, "missing")); !os.IsNotExist(err) { + t.Fatalf("missing path: %v", err) + } +} + +func TestAnalysisGoRootPath(t *testing.T) { + root := filepath.Join(goenv.Get("GOROOT"), "src") + resolved, err := analysisPath(root) + if err != nil { + t.Fatal(err) + } + original, err := os.Stat(root) + if err != nil { + t.Fatal(err) + } + actual, err := os.Stat(resolved) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(original, actual) { + t.Fatalf("%q and %q refer to different directories", root, resolved) + } +} + +func TestAnalysisPatterns(t *testing.T) { + dir := t.TempDir() + t.Setenv("TINYGOROOT", dir) + files := map[string]string{ + "src/crypto/default.go": "package crypto\n", + "src/crypto/tagged.go": "//go:build custom\n\npackage crypto\n", + "src/device/arm/arm.go": "//go:build tinygo_root_test_marker\n\npackage arm\n", + "src/internal/itoa/itoa.go": "package itoa\n", + "src/machine/machine.go": "package machine\n", + "src/machine/usb/usb.go": "package usb\n", + "src/machine/vendor/hidden/hidden.go": "package hidden\n", + "src/machine/testdata/hidden/hidden.go": "package hidden\n", + "src/machine/_private/private.go": "package private\n", + "src/.hidden/hidden.go": "package hidden\n", + "src/runtime/internal/sys/zversion.go": "//go:build tinygo_root_test_marker\n\npackage sys\n", + } + for name, contents := range files { + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + config := &compileopts.Config{ + Options: &compileopts.Options{Directory: dir}, + Target: &compileopts.TargetSpec{Triple: "x86_64-unknown-linux", GOOS: "linux", GOARCH: "amd64"}, + GoMinorVersion: 25, + } + for _, test := range []struct { + patterns []string + want []string + }{ + { + patterns: []string{"./src/..."}, + want: []string{"crypto", "internal/itoa", "machine", "machine/usb"}, + }, + { + patterns: []string{"./src/machine/..."}, + want: []string{"machine", "machine/usb"}, + }, + { + patterns: []string{"./src/machine"}, + want: []string{"machine"}, + }, + } { + got, err := analysisPatterns(config, test.patterns) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got, test.want) { + t.Errorf("analysisPatterns(%q) = %q, want %q", test.patterns, got, test.want) + } + } + if _, err := analysisPatterns(config, []string{"./src"}); err == nil || + !strings.Contains(err.Error(), "use ./src/...") { + t.Fatalf("analysisPatterns(./src) error = %v, want a ./src/... hint", err) + } +} diff --git a/loader/analysispath_unix.go b/loader/analysispath_unix.go new file mode 100644 index 0000000000..7b7fb7cfdd --- /dev/null +++ b/loader/analysispath_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package loader + +import "path/filepath" + +func analysisPath(name string) (string, error) { + return filepath.EvalSymlinks(name) +} diff --git a/loader/analysispath_windows.go b/loader/analysispath_windows.go new file mode 100644 index 0000000000..aa3b9abc4b --- /dev/null +++ b/loader/analysispath_windows.go @@ -0,0 +1,32 @@ +package loader + +import ( + "os" + "strings" + + "golang.org/x/sys/windows" +) + +func analysisPath(name string) (string, error) { + file, err := os.Open(name) + if err != nil { + return "", err + } + defer file.Close() + buffer := make([]uint16, 260) + for { + n, err := windows.GetFinalPathNameByHandle(windows.Handle(file.Fd()), &buffer[0], uint32(len(buffer)), 0) + if err != nil { + return "", &os.PathError{Op: "GetFinalPathNameByHandle", Path: name, Err: err} + } + if n >= uint32(len(buffer)) { + buffer = make([]uint16, n+1) + continue + } + path := windows.UTF16ToString(buffer[:n]) + if strings.HasPrefix(path, `\\?\UNC\`) { + return `\\` + strings.TrimPrefix(path, `\\?\UNC\`), nil + } + return strings.TrimPrefix(path, `\\?\`), nil + } +} diff --git a/loader/loader.go b/loader/loader.go index e2a124cc84..9a86133c26 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -9,6 +9,7 @@ import ( "go/ast" "go/constant" "go/parser" + "go/printer" "go/scanner" "go/token" "go/types" @@ -36,6 +37,10 @@ type Program struct { typeChecker types.Config goroot string // synthetic GOROOT workingDir string + analysis bool + overlay map[string][]byte + sources map[string][]byte + rewritten map[string]bool Packages map[string]*Package sorted []*Package @@ -56,6 +61,7 @@ type PackageJSON struct { ForTest string Root string Standard bool + DepOnly bool Module struct { Path string Main bool @@ -113,6 +119,10 @@ type EmbedFile struct { // package). Call .Parse() afterwards to parse all Go files (including CGo // processing, if necessary). func Load(config *compileopts.Config, inputPkg string, typeChecker types.Config) (*Program, error) { + return load(config, []string{inputPkg}, typeChecker, false, nil) +} + +func load(config *compileopts.Config, inputs []string, typeChecker types.Config, analysis bool, overlay map[string][]byte) (*Program, error) { goroot, err := GetCachedGoroot(config) if err != nil { return nil, err @@ -136,6 +146,10 @@ func Load(config *compileopts.Config, inputPkg string, typeChecker types.Config) workingDir: wd, Packages: make(map[string]*Package), fset: token.NewFileSet(), + analysis: analysis, + overlay: overlay, + sources: make(map[string][]byte), + rewritten: make(map[string]bool), } // List the dependencies of this package, in raw JSON format. @@ -143,7 +157,13 @@ func Load(config *compileopts.Config, inputPkg string, typeChecker types.Config) if config.TestConfig.CompileTestBinary { extraArgs = append(extraArgs, "-test") } - cmd, err := List(config, extraArgs, []string{inputPkg}) + cleanup, overlayFlag, err := analysisOverlay(goroot, overlay) + if err != nil { + return nil, err + } + defer cleanup() + extraArgs = append(extraArgs, overlayFlag...) + cmd, err := List(config, extraArgs, inputs) if err != nil { return nil, err } @@ -156,7 +176,7 @@ func Load(config *compileopts.Config, inputPkg string, typeChecker types.Config) cmd.Stderr = os.Stderr err = cmd.Run() if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr, ok := err.(*exec.ExitError); ok && !analysis { os.Exit(exitErr.ExitCode()) } return nil, fmt.Errorf("failed to run `go list`: %s", err) @@ -225,7 +245,7 @@ func Load(config *compileopts.Config, inputPkg string, typeChecker types.Config) } return nil, err } - if config.TestConfig.CompileTestBinary { + if config.TestConfig.CompileTestBinary && !analysis { // When creating a test binary, `go list` will list two or three // packages used for testing the package. The first is the original // package as if it were built normally, the second is the same @@ -271,7 +291,7 @@ func Load(config *compileopts.Config, inputPkg string, typeChecker types.Config) } } - if config.TestConfig.CompileTestBinary && !strings.HasSuffix(p.sorted[len(p.sorted)-1].ImportPath, ".test") { + if !analysis && config.TestConfig.CompileTestBinary && !strings.HasSuffix(p.sorted[len(p.sorted)-1].ImportPath, ".test") { // Trying to compile a test binary but there are no test files in this // package. return p, NoTestFilesError{p.sorted[len(p.sorted)-1].ImportPath} @@ -582,10 +602,26 @@ func (p *Package) recordedCFlagPath(filename string) string { // parseFile is a wrapper around parser.ParseFile. func (p *Package) parseFile(path string, mode parser.Mode) (*ast.File, error) { originalPath := p.program.getOriginalPath(path) + if p.program.analysis { + resolved, err := analysisPath(path) + if err != nil { + return nil, fmt.Errorf("resolve analysis source %q: %w", path, err) + } + originalPath = resolved + } data, err := os.ReadFile(path) if err != nil { return nil, err } + if replacement, ok := p.program.overlay[originalPath]; ok { + data = replacement + } + if p.program.analysis { + if previous, exists := p.program.sources[originalPath]; exists && !bytes.Equal(previous, data) { + return nil, fmt.Errorf("%s changed during package loading", originalPath) + } + p.program.sources[originalPath] = data + } sum := sha512.Sum512_224(data) p.FileHashes[originalPath] = sum[:] return parser.ParseFile(p.program.fset, originalPath, data, mode) @@ -653,7 +689,9 @@ func (p *Package) Check() error { // Do typechecking of the package. packageName := p.ImportPath - if p == p.program.MainPkg() { + if p.program.analysis { + packageName = analysisImportPath(packageName) + } else if p == p.program.MainPkg() { if p.Name != "main" { return Errors{p, []error{ scanner.Error{ @@ -719,6 +757,16 @@ func (p *Package) parseFiles() ([]*ast.File, error) { // and there is a syntax error in a CGo file, len(files) may be 0. Don't try // to call cgo.Process in that case as it will only cause issues. if len(p.CgoFiles) != 0 && len(files) != 0 { + originals := make(map[*ast.File]string) + if p.program.analysis { + for _, file := range files { + var text bytes.Buffer + if err := printer.Fprint(&text, p.program.fset, file); err != nil { + return nil, err + } + originals[file] = text.String() + } + } var initialCFlags []string initialCFlags = append(initialCFlags, p.program.config.CFlags(true)...) initialCFlags = append(initialCFlags, "-I"+p.Dir) @@ -757,6 +805,15 @@ func (p *Package) parseFiles() ([]*ast.File, error) { p.program.config.GOARCH(), p.program.config.BuildTags(), ) + for file, original := range originals { + var text bytes.Buffer + if err := printer.Fprint(&text, p.program.fset, file); err != nil { + return nil, err + } + if original != text.String() { + p.program.rewritten[p.program.fset.File(file.FileStart).Name()] = true + } + } p.CFlags = slices.Concat(initialCFlags, cflags) p.CXXFlags = slices.Concat(initialCFlags, cxxflags) p.CGoHeaders = headerCode @@ -1040,7 +1097,7 @@ func (p *Package) Import(to string) (*types.Package, error) { if to == "unsafe" { return types.Unsafe, nil } - if newTo, ok := p.ImportMap[to]; ok && !strings.HasSuffix(newTo, ".test]") { + if newTo, ok := p.ImportMap[to]; ok && (p.program.analysis || !strings.HasSuffix(newTo, ".test]")) { to = newTo } if imported, ok := p.program.Packages[to]; ok { diff --git a/main.go b/main.go index ae7a215e3b..4b917e26fb 100644 --- a/main.go +++ b/main.go @@ -1574,6 +1574,7 @@ commands: ports: list available serial ports env: list environment variables used during build list: run go list using the TinyGo root + fix: apply Go fix analyzers using the TinyGo loader clean: empty cache directory (%s) targets: list targets info: show info for specified target @@ -1584,6 +1585,7 @@ commands: var ( commandHelp = map[string]string{ "build": usageBuild, + "fix": usageFix, "run": usageRun, "flash": usageFlash, "monitor": usageMonitor, @@ -1866,6 +1868,8 @@ func main() { // library. handleChdirFlag() switch command { + case "fix": + os.Exit(runFix(os.Args[2:], os.Stdout, os.Stderr)) case "clang", "ld.lld", "wasm-ld": err := builder.RunTool(command, os.Args[2:]...) if err != nil { diff --git a/make/build.mk b/make/build.mk index e46d2b37f2..dea447d25a 100644 --- a/make/build.mk +++ b/make/build.mk @@ -5,7 +5,7 @@ clean: ## Remove build directory @rm -rf build -FMT_PATHS = ./*.go builder cgo/*.go compiler interp loader src transform +FMT_PATHS = ./*.go builder cgo/*.go compiler fix interp loader src transform fmt: ## Reformat source @gofmt -l -w $(FMT_PATHS) fmt-check: ## Warn if any source needs reformatting diff --git a/make/config.mk b/make/config.mk index 020024e4ae..2e3ce38072 100644 --- a/make/config.mk +++ b/make/config.mk @@ -36,7 +36,7 @@ GO ?= go # Flags to pass to go test. GOTESTFLAGS ?= -GOTESTPKGS ?= ./builder ./cgo ./compileopts ./compiler ./interp ./loader ./transform . +GOTESTPKGS ?= ./builder ./cgo ./compileopts ./compiler ./fix ./interp ./loader ./transform . # tinygo binary for tests TINYGO ?= $(call detect,tinygo,tinygo $(CURDIR)/build/tinygo)