Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@ $(go):
@chmod +x $(go)
endif

.PHONY: $(sagefile)
$(sagefile): $(go)
ifneq ($(wildcard $(sagefile)),)
sage_source_files := $(shell $(sagefile) --deps .sage)
ifeq ($(sage_source_files),)
$(error sagefile --deps returned empty; run 'make sage' to force rebuild)
endif
endif
$(sagefile): $(go) $(sage_source_files)
@cd .sage && $(go) mod tidy && $(go) run .

.PHONY: sage
Expand Down
10 changes: 10 additions & 0 deletions sg/initfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ func generateInitFile(g *codegen.File, pkg *doc.Package, mks []Makefile) error {
g.P("}")
g.P("target, args := ", g.Import("os"), ".Args[1], ", g.Import("os"), ".Args[2:]")
g.P("_ = args")
g.P(`if target == "--deps" {`)
g.P("if len(args) == 0 {")
g.P(g.Import("fmt"), `.Fprintln(`, g.Import("os"), `.Stderr, "usage: sagefile --deps <prefix>")`)
g.P(g.Import("os"), ".Exit(1)")
g.P("}")
g.P("for _, dep := range ", g.Import("go.einride.tech/sage/sg"), ".SagefileDeps(args[0]) {")
g.P(g.Import("fmt"), ".Println(dep)")
g.P("}")
g.P(g.Import("os"), ".Exit(0)")
g.P("}")
g.P("var err error")
g.P("switch target {")
forEachTargetFunction(pkg, func(function *doc.Func, _ *doc.Type) {
Expand Down
13 changes: 11 additions & 2 deletions sg/makefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,17 @@ func generateMakefile(_ context.Context, g *codegen.File, pkg *doc.Package, mk M
g.P("\t@chmod +x $(go)")
g.P("endif")
g.P()
g.P(".PHONY: $(sagefile)")
g.P("$(sagefile): $(go)")
// When the sagefile binary already exists, ask it for its own source deps
// so Make can skip the rebuild when nothing changed. On first build the
// binary doesn't exist yet, so sage_source_files stays empty and Make
// builds unconditionally because the target file is missing.
g.P("ifneq ($(wildcard $(sagefile)),)")
g.P("sage_source_files := $(shell $(sagefile) --deps ", includePath, ")")
g.P("ifeq ($(sage_source_files),)")
g.P("$(error sagefile --deps returned empty; run 'make sage' to force rebuild)")
g.P("endif")
g.P("endif")
g.P("$(sagefile): $(go) $(sage_source_files)")
g.P("\t@cd ", includePath, " && $(go) mod tidy && $(go) run .")
g.P()
g.P(".PHONY: sage")
Expand Down
96 changes: 96 additions & 0 deletions sg/sagefile_deps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package sg

import (
"fmt"
"go/build"
"io/fs"
"os"
"path/filepath"
"strings"
)

// SagefileDeps returns the source files the sagefile binary depends on.
// It uses go/build.ImportDir to resolve which .go files actually participate
// in the build for the current platform, rather than naively globbing all
// .go files (which would include test files and build-tag-excluded files).
//
// prefix is the relative path from the Makefile to the .sage directory
// (e.g. ".sage" for the root Makefile, "../../.sage" for a namespace
// Makefile in a subdirectory). Each returned path is prefixed with it so
// that Make can resolve the files from its working directory.
func SagefileDeps(prefix string) []string {
if strings.Contains(prefix, " ") {
fmt.Fprintf(os.Stderr, "sagefile --deps: prefix %q contains spaces, which Make cannot handle\n", prefix)
return nil
}
sageDir := FromSageDir()
pkg, err := build.ImportDir(sageDir, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "sagefile --deps: %v\n", err)
return nil
}
var deps []string
for _, f := range pkg.GoFiles {
deps = append(deps, filepath.Join(prefix, f))
}
deps = append(deps, filepath.Join(prefix, "go.mod"))
deps = append(deps, filepath.Join(prefix, "go.sum"))
// If go.mod has local replace directives (e.g. "replace foo => ../"),
// changes to the replaced module's source should also trigger a rebuild.
// This matters when developing sage itself, where .sage/go.mod points
// back to the repo root via replace.
for _, relTarget := range findLocalReplaces(filepath.Join(sageDir, "go.mod")) {
absTarget := filepath.Join(sageDir, relTarget)
_ = filepath.WalkDir(absTarget, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil

Check failure on line 46 in sg/sagefile_deps.go

View workflow job for this annotation

GitHub Actions / build

error is not nil (line 44) but it returns nil (nilerr)
}
if d.IsDir() {
name := d.Name()
if name == "vendor" || name == "testdata" || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {

Check failure on line 50 in sg/sagefile_deps.go

View workflow job for this annotation

GitHub Actions / build

File is not properly formatted (golines)
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") {
rel, err := filepath.Rel(absTarget, path)
if err != nil {
return nil

Check failure on line 58 in sg/sagefile_deps.go

View workflow job for this annotation

GitHub Actions / build

error is not nil (line 56) but it returns nil (nilerr)
}
deps = append(deps, filepath.Join(prefix, relTarget, rel))
}
return nil
})
}
return deps
}

// findLocalReplaces parses a go.mod file and returns the target paths of
// replace directives that point to local directories (relative paths).
func findLocalReplaces(goModPath string) []string {
data, err := os.ReadFile(goModPath)
if err != nil {
return nil
}
var dirs []string
for _, line := range strings.Split(string(data), "\n") {
// In go.mod, replace entries either start with "replace" (single-line)
// or are tab-indented inside a replace() block.
if !strings.HasPrefix(line, "replace") && !strings.HasPrefix(line, "\t") {
continue
}
idx := strings.Index(line, "=>")
if idx < 0 {
continue
}
target := strings.TrimSpace(line[idx+2:])
// Strip trailing version if present.
if i := strings.IndexByte(target, ' '); i >= 0 {
target = target[:i]
}
if strings.HasPrefix(target, "./") || strings.HasPrefix(target, "../") {
dirs = append(dirs, target)
}
}
return dirs
}
69 changes: 69 additions & 0 deletions sg/sagefile_deps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package sg

import (
"os"
"path/filepath"
"testing"
)

func TestFindLocalReplaces(t *testing.T) {
tests := []struct {
name string
content string
want []string
}{
{
name: "single-line replace",
content: "module test\n\nreplace go.einride.tech/sage => ../\n",
want: []string{"../"},
},
{
name: "block replace",
content: "module test\n\nreplace (\n\tgo.einride.tech/sage => ../\n)\n",
want: []string{"../"},
},
{
name: "multiple replaces",
content: "module test\n\nreplace (\n\tgo.einride.tech/sage => ../\n\tgo.einride.tech/other => ./local\n)\n",
want: []string{"../", "./local"},
},
{
name: "remote replace skipped",
content: "module test\n\nreplace go.einride.tech/sage => github.com/other/sage v1.0.0\n",
want: nil,
},
{
name: "comment with arrow skipped",
content: "module test\n\n// old => ./local\n",
want: nil,
},
{
name: "no replaces",
content: "module test\n\nrequire go.einride.tech/sage v0.400.0\n",
want: nil,
},
{
name: "replace with version suffix",
content: "replace go.einride.tech/sage => ../sage v0.0.0\n",
want: []string{"../sage"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
gomod := filepath.Join(dir, "go.mod")
if err := os.WriteFile(gomod, []byte(tt.content), 0o600); err != nil {
t.Fatal(err)
}
got := findLocalReplaces(gomod)
if len(got) != len(tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}
Loading