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
12 changes: 10 additions & 2 deletions scanner/astgrep.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,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" {
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" {
var path string
var explicitTarget string
kind := "rust-path"
Expand All @@ -348,9 +348,17 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F
if pathVar, ok := m.MetaVariables.Single["PATH"]; ok {
path = pathVar.Text
}
case "rust-askama-template-imports":
kind = "rust-askama-template"
if targetVar, ok := m.MetaVariables.Single["TARGET"]; ok && !rustAttributeAssigns(m.Text, "config") {
if _, literal := parseRustStringLiteral(targetVar.Text); literal {
path = targetVar.Text
explicitTarget = targetVar.Text
}
}
}
if path != "" {
if m.RuleID != "rust-path-imports" {
if m.RuleID != "rust-path-imports" && m.RuleID != "rust-askama-template-imports" {
fileMap[relPath].Imports = append(fileMap[relPath].Imports, path)
}
fileMap[relPath].References = append(fileMap[relPath].References, ImportReference{
Expand Down
152 changes: 152 additions & 0 deletions scanner/rustaskama_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package scanner

import (
"context"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"testing"
)

func TestAstGrepRustAskamaTemplateExtraction(t *testing.T) {
scanner, err := NewAstGrepScanner()
if err != nil {
t.Fatal(err)
}
t.Cleanup(scanner.Close)
if !scanner.Available() {
t.Skip("ast-grep not available")
}

root := t.TempDir()
source := `#[template(path = "page.html")]
struct Page;

#[template(path = r#"raw.html"#, escape = "none")]
enum Raw {}

#[other(path = "ignored.html")]
struct Other;

const TEMPLATE: &str = "dynamic.html";
#[template(path = TEMPLATE)]
struct Dynamic;

#[template(path = "configured.html", config = "askama-custom.toml")]
struct Configured;
`
if err := os.WriteFile(filepath.Join(root, "lib.rs"), []byte(source), 0o644); err != nil {
t.Fatal(err)
}
astScanner, err := NewAstGrepScanner()
if err != nil {
t.Fatal(err)
}
outcome, err := astScanner.ScanDirectory(context.Background(), root)
if err != nil {
t.Fatal(err)
}
var got []ImportReference
for _, analysis := range outcome.Analyses {
for _, ref := range analysis.References {
if ref.Kind == "rust-askama-template" {
ref.Line = 0
got = append(got, ref)
}
}
}
sort.Slice(got, func(i, j int) bool { return got[i].ExplicitTarget < got[j].ExplicitTarget })
want := []ImportReference{
{Path: `"page.html"`, Kind: "rust-askama-template", ExplicitTarget: `"page.html"`},
{Path: `r#"raw.html"#`, Kind: "rust-askama-template", ExplicitTarget: `r#"raw.html"#`},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Askama template references = %#v, want %#v", got, want)
}
}

func TestRustAskamaTemplateResolvesWithinCargoPackage(t *testing.T) {
root := t.TempDir()
absolute := filepath.Join(root, "app", "templates", "absolute.html")
writeRustCargoFixture(t, root, map[string]string{
"Cargo.toml": "[workspace]\nmembers = [\"app\", \"configured\"]\n",
"app/Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\n",
"app/src/lib.rs": "pub fn app() {}\n",
"app/scratch/unowned.rs": "pub fn scratch() {}\n",
"app/templates/page.html": "package page\n",
"app/templates/raw.html": "package raw\n",
"app/templates/excluded.html": "excluded\n",
"app/templates/absolute.html": "absolute\n",
"templates/page.html": "workspace lookalike\n",
"outside.html": "outside\n",
"configured/Cargo.toml": "[package]\nname = \"configured\"\nversion = \"0.1.0\"\n",
"configured/askama.toml": "[general]\ndirs = [\"views\"]\n",
"configured/src/lib.rs": "pub fn configured() {}\n",
"configured/templates/page.html": "wrong configured-directory target\n",
".codemap/config.json": `{"exclude":["app/templates/excluded.html"]}`,
})
metadata := cargoMetadataJSON(t, root, []map[string]any{
cargoPackage(root, "app", "app", "app", nil),
cargoPackage(root, "configured", "configured", "configured", nil),
})
analyses := []FileAnalysis{
{Path: "app/src/lib.rs", Language: "rust", References: []ImportReference{
{Path: `"page.html"`, Kind: "rust-askama-template", ExplicitTarget: `"page.html"`},
{Path: `r#"raw.html"#`, Kind: "rust-askama-template", ExplicitTarget: `r#"raw.html"#`},
{Path: `"excluded.html"`, Kind: "rust-askama-template", ExplicitTarget: `"excluded.html"`},
{Path: `"missing.html"`, Kind: "rust-askama-template", ExplicitTarget: `"missing.html"`},
{Path: `"page"`, Kind: "rust-askama-template", ExplicitTarget: `"page"`},
{Path: `"../outside.html"`, Kind: "rust-askama-template", ExplicitTarget: `"../outside.html"`},
{Path: fmt.Sprintf("%q", absolute), Kind: "rust-askama-template", ExplicitTarget: fmt.Sprintf("%q", absolute)},
{Path: `"unterminated`, Kind: "rust-askama-template", ExplicitTarget: `"unterminated`},
}},
{Path: "app/scratch/unowned.rs", Language: "rust", References: []ImportReference{
{Path: `"page.html"`, Kind: "rust-askama-template", ExplicitTarget: `"page.html"`},
}},
{Path: "configured/src/lib.rs", Language: "rust", References: []ImportReference{
{Path: `"page.html"`, Kind: "rust-askama-template", ExplicitTarget: `"page.html"`},
}},
{Path: "configured/templates/page.html", Language: "html"},
{Path: "app/templates/page.html", Language: "html"},
{Path: "app/templates/raw.html", Language: "html"},
{Path: "app/templates/excluded.html", Language: "html"},
{Path: "app/templates/absolute.html", Language: "html"},
{Path: "templates/page.html", Language: "html"},
{Path: "outside.html", Language: "html"},
}
graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses,
func(context.Context, string) ([]byte, error) { return metadata, nil })
if err != nil {
t.Fatal(err)
}
want := []string{"app/templates/page.html", "app/templates/raw.html"}
if got := sortedImports(graph, "app/src/lib.rs"); !reflect.DeepEqual(got, want) {
t.Fatalf("Askama imports = %#v, want %#v", got, want)
}
if got := graph.Importers[filepath.FromSlash("templates/page.html")]; len(got) != 0 {
t.Fatalf("workspace-root lookalike importers = %#v, want none", got)
}
if got := graph.Imports[filepath.FromSlash("app/scratch/unowned.rs")]; len(got) != 0 {
t.Fatalf("unowned Rust source imports = %#v, want none", got)
}
if got := graph.Imports[filepath.FromSlash("configured/src/lib.rs")]; len(got) != 0 {
t.Fatalf("configured template-directory imports = %#v, want none", got)
}
}

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"},
}}
workspace := &rustWorkspaceIndex{packages: []rustPackage{{root: "app", authoritative: true}}}
if got := resolveRustAskamaTemplate(t.TempDir(), "app/src/lib.rs", `"page.html"`, idx, workspace); got != "" {
t.Fatalf("ambiguous target = %q, want unresolved", got)
}
idx.byExact[filepath.FromSlash("app/templates/page.html")] = []string{"app/templates/page.html"}
workspace.packages[0].authoritative = false
if got := resolveRustAskamaTemplate(t.TempDir(), "app/src/lib.rs", `"page.html"`, idx, workspace); got != "" {
t.Fatalf("fallback-owned target = %q, want unresolved", got)
}
}
45 changes: 42 additions & 3 deletions scanner/rustgraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,37 @@ func parseRustCookedString(literal string) (string, bool) {
return value.String(), true
}

func resolveRustAskamaTemplate(root, fromFile, literal string, idx *fileIndex, workspace *rustWorkspaceIndex) string {
value, ok := parseRustStringLiteral(literal)
if !ok || value == "" {
return ""
}
path := filepath.FromSlash(value)
if filepath.IsAbs(path) {
return ""
}
pkg, ok := workspace.packageForFile(fromFile)
if !ok || !pkg.authoritative {
return ""
}
if _, ok := workspace.targetForFile(fromFile, idx); !ok {
return ""
}
if _, err := os.Stat(filepath.Join(root, pkg.root, "askama.toml")); !os.IsNotExist(err) {
return ""
}
templateRoot := filepath.Clean(filepath.Join(pkg.root, "templates"))
target := filepath.Clean(filepath.Join(templateRoot, path))
if !pathWithin(target, templateRoot) {
return ""
}
files := idx.byExact[target]
if len(files) != 1 || files[0] != target {
return ""
}
return files[0]
}

func resolveRustReferences(root string, analysis FileAnalysis, idx *fileIndex, workspace *rustWorkspaceIndex) []string {
var resolved []string
for _, ref := range analysis.References {
Expand All @@ -616,6 +647,10 @@ func resolveRustReferences(root string, analysis FileAnalysis, idx *fileIndex, w
if target := resolveRustPath(ref.Path, analysis.Path, idx, workspace); target != "" && target != analysis.Path {
resolved = append(resolved, target)
}
case "rust-askama-template":
if target := resolveRustAskamaTemplate(root, analysis.Path, ref.ExplicitTarget, idx, workspace); target != "" && target != analysis.Path {
resolved = append(resolved, target)
}
}
}
return dedupe(resolved)
Expand Down Expand Up @@ -1015,14 +1050,18 @@ func skipRustTriviaBackward(lines []string, line int) (int, bool) {
}

func rustAttributeAssignsPath(attribute string) bool {
return rustAttributeAssigns(attribute, "path")
}

func rustAttributeAssigns(attribute, name string) bool {
for offset := 0; offset < len(attribute); {
relative := strings.Index(attribute[offset:], "path")
relative := strings.Index(attribute[offset:], name)
if relative < 0 {
return false
}
i := offset + relative
beforeOK := i == 0 || !isRustIdentifierByte(attribute[i-1])
j := i + len("path")
j := i + len(name)
afterOK := j == len(attribute) || !isRustIdentifierByte(attribute[j])
if beforeOK && afterOK {
for j < len(attribute) && (attribute[j] == ' ' || attribute[j] == '\t' || attribute[j] == '\n' || attribute[j] == '\r') {
Expand All @@ -1032,7 +1071,7 @@ func rustAttributeAssignsPath(attribute string) bool {
return true
}
}
offset = i + len("path")
offset = i + len(name)
}
return false
}
Expand Down
15 changes: 15 additions & 0 deletions scanner/sg-rules/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ language: rust
rule:
pattern: $PATH::$$$REST
---
id: rust-askama-template-imports
language: rust
rule:
any:
- pattern:
context: |
#[template(path = $TARGET)]
struct __CONTEXT__;
selector: attribute_item
- pattern:
context: |
#[template(path = $TARGET, $$$REST)]
struct __CONTEXT__;
selector: attribute_item
---
id: rust-functions
language: rust
rule:
Expand Down
Loading