Skip to content
Merged
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
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil
externalDeps = make(map[string][]string)
}
} else {
outcome, err = scanForDepsOutcomeWithHint(root, filters)
outcome, err = scanForDepsOutcomeWithHint(absRoot, filters)
if err != nil {
if errors.Is(err, scanner.ErrAstGrepNotFound) {
printAstGrepInstallHint(os.Stderr, err)
Expand Down
16 changes: 12 additions & 4 deletions scanner/astgrep.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F
}
}

if m.RuleID == "rust-mod-imports" || m.RuleID == "rust-path-module-imports" || m.RuleID == "rust-path-imports" || m.RuleID == "rust-use-imports" || m.RuleID == "rust-askama-template-imports" || m.RuleID == "rust-include-imports" || m.RuleID == "rust-embedded-file-imports" || m.RuleID == "rust-cargo-rerun-imports" {
if m.RuleID == "rust-mod-imports" || m.RuleID == "rust-path-module-imports" || m.RuleID == "rust-path-imports" || m.RuleID == "rust-use-imports" || m.RuleID == "rust-use-imports-nested" || m.RuleID == "rust-askama-template-imports" || m.RuleID == "rust-include-imports" || m.RuleID == "rust-embedded-file-imports" || m.RuleID == "rust-cargo-rerun-imports" {
var path string
var explicitTarget string
kind := "rust-path"
Expand All @@ -441,11 +441,19 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F
}
case "rust-path-imports":
path = m.Text
case "rust-use-imports":
case "rust-use-imports", "rust-use-imports-nested":
if pathVar, ok := m.MetaVariables.Single["PATH"]; ok {
path = pathVar.Text
}
if len(expandRustUseReferencePaths(path)) > 0 || strings.ContainsAny(path, "{}") {
if m.RuleID == "rust-use-imports-nested" && rustUseIsSelfOrSuperRooted(path) {
// `use self::…`/`use super::…` inside an inline module
// (`mod tests { … }`) is relative to that module, not the
// file, and resolving it file-relative produced false
// edges. A missed edge beats a wrong one, so drop it;
// `use crate::…` means the same thing everywhere and
// still resolves below.
path = ""
} else if len(expandRustUseReferencePaths(path)) > 0 || strings.ContainsAny(path, "{}") {
// Unexpandable brace trees stay rust-use; raw braces must not
// create crate-root edges.
kind = "rust-use"
Expand Down Expand Up @@ -477,7 +485,7 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F
}
}
if path != "" {
if m.RuleID != "rust-path-imports" && m.RuleID != "rust-askama-template-imports" && m.RuleID != "rust-embedded-file-imports" && m.RuleID != "rust-cargo-rerun-imports" {
if m.RuleID != "rust-path-imports" && m.RuleID != "rust-use-imports" && m.RuleID != "rust-use-imports-nested" && m.RuleID != "rust-askama-template-imports" && m.RuleID != "rust-embedded-file-imports" && m.RuleID != "rust-cargo-rerun-imports" {
fileMap[relPath].Imports = append(fileMap[relPath].Imports, path)
}
fileMap[relPath].References = append(fileMap[relPath].References, ImportReference{
Expand Down
10 changes: 10 additions & 0 deletions scanner/cargofallback.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ func scanForGraphOutcome(ctx context.Context, root string, scan dependencyOutcom
}

func scanForGraphOutcomeWithFilters(ctx context.Context, root string, filters Filters, scan dependencyOutcomeScanner, loader cargoMetadataLoader, allowCargoOnly bool) (ScanOutcome, bool, error) {
// The Cargo fallback resolves each package's absolute manifest_path
// against root; a relative root (e.g. "." from the CLI) makes
// filepath.Rel fail and silently drops every recovered package, so
// absolutize here even though callers are expected to pass absRoot.
absRoot, err := filepath.Abs(root)
if err != nil {
return ScanOutcome{}, false, err
}
root = absRoot

outcome, err := scan(root)
degraded := false
if err == nil {
Expand Down
105 changes: 105 additions & 0 deletions scanner/cargofallback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,52 @@ func TestScanForGraphOutcomeUsesGoFallbackWithoutCargo(t *testing.T) {
}
}

func TestScanForGraphOutcomeGoFallbackExcludesHiddenAndNestedRepoFiles(t *testing.T) {
// The Go fallback must scan the same file universe as the ast-grep
// primary: hidden directories and nested git repos are importer-eligible
// under plain ScanFiles but ast-grep (and findNestedGitRepos) exclude
// them, so leaving them in produces phantom importers.
root := t.TempDir()
writeRustCargoFixture(t, root, map[string]string{
"go.mod": "module example.com/demo\n\ngo 1.22\n",
"lib/lib.go": "package lib\n\nfunc Foo() {}\n",
"main.go": "package main\n\nimport \"example.com/demo/lib\"\n\nfunc main() { lib.Foo() }\n",
".tools/gen.go": "package tools\n\nimport \"example.com/demo/lib\"\n\nfunc Gen() { lib.Foo() }\n",
"sub/cmd/x.go": "package cmd\n\nimport \"example.com/demo/lib\"\n\nfunc X() { lib.Foo() }\n",
})
// sub/ is its own nested git repo, mirroring findNestedGitRepos' target.
if err := os.MkdirAll(filepath.Join(root, "sub", ".git"), 0o755); err != nil {
t.Fatal(err)
}

outcome, usedFallback, err := scanForGraphOutcome(
context.Background(),
root,
func(string) (ScanOutcome, error) {
return ScanOutcome{}, newIncompleteScanError("ast-grep", ScanSourceUnavailable, "ast-grep unavailable", ErrAstGrepNotFound)
},
func(context.Context, string) ([]byte, error) {
return nil, errors.New("unexpected Cargo fallback")
},
false,
)
if err != nil {
t.Fatal(err)
}
if !usedFallback {
t.Fatal("Go-only recovery did not report fallback use")
}

var paths []string
for _, analysis := range outcome.Analyses {
paths = append(paths, filepath.ToSlash(analysis.Path))
}
want := []string{"lib/lib.go", "main.go"}
if !reflect.DeepEqual(paths, want) {
t.Fatalf("analysis paths = %v, want %v (.tools/gen.go and sub/cmd/x.go must not leak into the Go fallback)", paths, want)
}
}

func TestScanForGraphOutcomeCombinesGoAndCargoFallbacks(t *testing.T) {
root, metadata := cargoFallbackFixture(t, map[string]any{
"name": "core", "path": "core", "kind": nil,
Expand Down Expand Up @@ -565,6 +611,65 @@ func TestCargoFallbackPropagatesCanceledDiscovery(t *testing.T) {
}
}

func TestScanForGraphOutcomeCargoFallbackResolvesRelativeRoot(t *testing.T) {
// A relative root (e.g. "." from `codemap --deps .`) must recover the
// same Cargo fallback edges as an absolute one: rustPackageFromCargoMetadata
// resolves each package's absolute manifest_path against root via
// projectRelativePath, which errors on filepath.Rel(".", "/abs/...").
root, metadata := cargoFallbackFixture(t, map[string]any{
"name": "core", "path": "core", "kind": nil,
})
writeRustCargoFixture(t, root, map[string]string{
"main.go": "package main\n\nimport \"fmt\"\n\nfunc main() { fmt.Println(\"x\") }\n",
})
t.Chdir(root)

outcome, usedFallback, err := scanForGraphOutcomeWithFilters(
context.Background(),
".",
Filters{},
func(string) (ScanOutcome, error) {
return ScanOutcome{}, newIncompleteScanError("ast-grep", ScanSourceUnavailable, "ast-grep unavailable", ErrAstGrepNotFound)
},
func(context.Context, string) ([]byte, error) {
return metadata, nil
},
true,
)
if err != nil {
t.Fatalf("scanForGraphOutcomeWithFilters with relative root: %v", err)
}
if !usedFallback {
t.Fatal("expected fallback to be used")
}

var cargoStatus ScanSourceStatus
found := false
for _, source := range outcome.Sources {
if source.Name == "cargo-metadata" {
cargoStatus = source.Status
found = true
}
}
if !found {
t.Fatalf("no cargo-metadata source in outcome: %#v", outcome.Sources)
}
if cargoStatus != ScanSourceFallback {
t.Fatalf("cargo-metadata status = %q, want %q", cargoStatus, ScanSourceFallback)
}

wantEdge := fileEdge{from: "app/src/lib.rs", to: "core/src/lib.rs"}
edgeFound := false
for _, edge := range outcome.precomputedEdges {
if edge == wantEdge {
edgeFound = true
}
}
if !edgeFound {
t.Fatalf("precomputed edges = %#v, want cross-crate edge %#v", outcome.precomputedEdges, wantEdge)
}
}

func TestCargoFallbackAcceptsDevDependencyFromLibraryTarget(t *testing.T) {
// #98 merged all-non-build-target dev-dependency resolution; the fallback
// must treat dev dependencies on source targets as proven edges.
Expand Down
28 changes: 28 additions & 0 deletions scanner/gofallback.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ func buildGoFallbackOutcome(ctx context.Context, root string, files []FileInfo)
skipped := 0
fset := token.NewFileSet()

// Unlike ScanFiles, the ast-grep primary excludes hidden directories and
// nested git repos (its own ignore behavior, plus findNestedGitRepos'
// --globs excludes); mirror that here so the fallback doesn't contribute
// phantom importers from files the primary would never have seen.
nestedRepos := findNestedGitRepos(root)
for _, file := range files {
if goFallbackFileExcluded(file.Path, nestedRepos) {
continue
}
if !strings.EqualFold(filepath.Ext(file.Path), ".go") {
continue
}
Expand Down Expand Up @@ -82,3 +90,23 @@ func buildGoFallbackOutcome(ctx context.Context, root string, files []FileInfo)
}},
}, nil
}

// goFallbackFileExcluded reports whether path lies in a hidden directory or
// under one of the given nested-repo subtrees, matching what the ast-grep
// primary would have skipped.
func goFallbackFileExcluded(path string, nestedRepos []string) bool {
dir := filepath.Dir(path)
if dir != "." {
for _, part := range strings.Split(dir, string(filepath.Separator)) {
if strings.HasPrefix(part, ".") {
return true
}
}
}
for _, repo := range nestedRepos {
if path == repo || strings.HasPrefix(path, repo+string(filepath.Separator)) {
return true
}
}
return false
}
31 changes: 31 additions & 0 deletions scanner/rustaskama_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,37 @@ func TestRustAskamaTemplateResolvesWithinCargoPackage(t *testing.T) {
}
}

func TestRustAskamaTemplateResolvesWithExtensionSibling(t *testing.T) {
// idx.byExact also indexes files under their extension-stripped key, so
// "app/templates/template.html.orig" appears under the same
// "app/templates/template.html" key as the real target.
root := t.TempDir()
writeRustCargoFixture(t, root, map[string]string{
"Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\n",
"src/lib.rs": "pub fn app() {}\n",
"templates/template.html": "real template\n",
"templates/template.html.orig": "backup\n",
})
metadata := cargoMetadataJSON(t, root, []map[string]any{
cargoPackage(root, ".", "app", "app", nil),
})
analyses := []FileAnalysis{
{Path: "src/lib.rs", Language: "rust", References: []ImportReference{
{Path: `"template.html"`, Kind: "rust-askama-template", ExplicitTarget: `"template.html"`},
}},
{Path: "templates/template.html", Language: "html"},
{Path: "templates/template.html.orig", Language: ""},
}
graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses,
func(context.Context, string) ([]byte, error) { return metadata, nil })
if err != nil {
t.Fatal(err)
}
if got, want := sortedImports(graph, "src/lib.rs"), []string{"templates/template.html"}; !reflect.DeepEqual(got, want) {
t.Fatalf("Askama imports = %#v, want %#v", got, want)
}
}

func TestRustAskamaTemplateRequiresAuthoritativeUnambiguousTarget(t *testing.T) {
idx := &fileIndex{byExact: map[string][]string{
filepath.FromSlash("app/templates/page.html"): {"app/templates/page.html", "app/templates/page.html"},
Expand Down
14 changes: 12 additions & 2 deletions scanner/rustbuildscript.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,18 @@ func resolveRustBuildScriptInput(fromFile, input string, idx *fileIndex, workspa
return ""
}
candidate := filepath.Clean(filepath.Join(pkg.root, path))
files := idx.byExact[candidate]
if len(files) != 1 || files[0] != candidate || candidate == fromFile {
if candidate == fromFile {
return ""
}
// byExact also indexes files under their extension-stripped key, so
// accept only when the target itself is indexed exactly once.
exact := 0
for _, file := range idx.byExact[candidate] {
if file == candidate {
exact++
}
}
if exact != 1 {
return ""
}
return candidate
Expand Down
33 changes: 33 additions & 0 deletions scanner/rustbuildscript_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,39 @@ func TestRustBuildScriptResolvesStaticCargoInputs(t *testing.T) {
}
}

func TestRustBuildScriptResolvesTargetWithExtensionSibling(t *testing.T) {
// idx.byExact also indexes files under their extension-stripped key, so
// "app/data.json.gz" appears under the same "app/data.json" key as the
// real target. The real directive must still resolve.
root := t.TempDir()
writeRustCargoFixture(t, root, map[string]string{
"Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\nbuild = \"build.rs\"\n",
"build.rs": "fn main() {}\n",
"data.json": "{}\n",
"data.json.gz": "not really gzip\n",
})

metadata := cargoMetadataJSON(t, root, []map[string]any{
cargoPackageWithTargets(root, ".", "app", []map[string]any{
cargoTargetJSON(root, "build.rs", "build-script-build", rustTargetCustomBuild),
}, nil),
})
graph, err := buildFileGraphFromAnalysesWithCargoMetadata(
context.Background(),
root,
[]FileAnalysis{{Path: "build.rs", Language: "rust", References: []ImportReference{
{Path: "data.json", Kind: "rust-build-input"},
}}},
func(context.Context, string) ([]byte, error) { return metadata, nil },
)
if err != nil {
t.Fatal(err)
}
if got, want := graph.Imports["build.rs"], []string{"data.json"}; !reflect.DeepEqual(got, want) {
t.Fatalf("build script inputs = %#v, want %#v", got, want)
}
}

func TestRustBuildScriptInputsRequireCustomBuildTarget(t *testing.T) {
root := t.TempDir()
writeRustCargoFixture(t, root, map[string]string{
Expand Down
13 changes: 10 additions & 3 deletions scanner/rustgraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -667,11 +667,18 @@ func resolveRustAskamaTemplate(root, fromFile, literal string, idx *fileIndex, w
if !pathWithin(target, templateRoot) {
return ""
}
files := idx.byExact[target]
if len(files) != 1 || files[0] != target {
// byExact also indexes files under their extension-stripped key, so
// accept only when the target itself is indexed exactly once.
exact := 0
for _, file := range idx.byExact[target] {
if file == target {
exact++
}
}
if exact != 1 {
return ""
}
return files[0]
return target
}

func resolveRustReferences(root string, analysis FileAnalysis, idx *fileIndex, workspace *rustWorkspaceIndex) []string {
Expand Down
8 changes: 8 additions & 0 deletions scanner/rustuse.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ func expandRustUseReferencePaths(path string) []string {
return expandRustUseTreePaths(path)
}

// rustUseIsSelfOrSuperRooted reports whether a use-tree path's root is
// `self` or `super`, which resolve relative to the enclosing module rather
// than the file.
func rustUseIsSelfOrSuperRooted(path string) bool {
root := rustUseRoot(strings.TrimSpace(path))
return root == "self" || root == "super"
}

func rustUseRoot(path string) string {
if end := strings.IndexAny(path, ":{ \r\n"); end >= 0 {
return path[:end]
Expand Down
Loading
Loading