a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
299 lines
8.4 KiB
Go
299 lines
8.4 KiB
Go
package languages
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
)
|
|
|
|
// Makefile is target-and-recipe structured: a target line `NAME:` is
|
|
// followed by tab-indented recipe lines. We model targets and
|
|
// `define NAME ... endef` blocks as function nodes, variable
|
|
// assignments as variables, and `include` / `-include` / `sinclude`
|
|
// directives as imports.
|
|
var (
|
|
makeTargetRe = regexp.MustCompile(`(?m)^([A-Za-z_][\w./-]*(?:\.[A-Za-z_][\w./-]*)?)\s*:(?:[^=]|$)`)
|
|
makeDefineRe = regexp.MustCompile(`(?m)^define\s+([A-Za-z_]\w*)`)
|
|
makeIncludeRe = regexp.MustCompile(`(?m)^(?:-include|sinclude|include)\s+(.+)$`)
|
|
makeVarRe = regexp.MustCompile(`(?m)^([A-Za-z_][\w]*)\s*(?::=|\?=|\+=|=)\s*`)
|
|
)
|
|
|
|
// MakefileExtractor extracts Makefile source using regex.
|
|
type MakefileExtractor struct{}
|
|
|
|
func NewMakefileExtractor() *MakefileExtractor { return &MakefileExtractor{} }
|
|
|
|
func (e *MakefileExtractor) Language() string { return "makefile" }
|
|
func (e *MakefileExtractor) Extensions() []string {
|
|
return []string{".mk", ".make", "Makefile", "GNUmakefile", "makefile"}
|
|
}
|
|
|
|
func (e *MakefileExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
|
|
lines := strings.Split(string(src), "\n")
|
|
result := &parser.ExtractionResult{}
|
|
|
|
fileNode := &graph.Node{
|
|
ID: filePath, Kind: graph.KindFile, Name: filePath,
|
|
FilePath: filePath, StartLine: 1, EndLine: len(lines),
|
|
Language: "makefile",
|
|
}
|
|
result.Nodes = append(result.Nodes, fileNode)
|
|
|
|
seen := make(map[string]bool)
|
|
add := func(name string, kind graph.NodeKind, start, end int) {
|
|
if name == "" {
|
|
return
|
|
}
|
|
id := filePath + "::" + name
|
|
if seen[id] {
|
|
return
|
|
}
|
|
seen[id] = true
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: kind, Name: name,
|
|
FilePath: filePath, StartLine: start, EndLine: end,
|
|
Language: "makefile",
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileNode.ID, To: id, Kind: graph.EdgeDefines,
|
|
FilePath: filePath, Line: start,
|
|
})
|
|
}
|
|
|
|
// Collect target lines to compute end = line before next top-level
|
|
// definition (targets have no explicit terminator; indented tab
|
|
// lines are the recipe).
|
|
var tops []makeTopHit
|
|
for _, m := range makeTargetRe.FindAllSubmatchIndex(src, -1) {
|
|
name := string(src[m[2]:m[3]])
|
|
if isMakeDirective(name) {
|
|
continue
|
|
}
|
|
line := lineAt(src, m[0])
|
|
tops = append(tops, makeTopHit{name: name, line: line, kind: graph.KindFunction})
|
|
}
|
|
for _, m := range makeVarRe.FindAllSubmatchIndex(src, -1) {
|
|
name := string(src[m[2]:m[3]])
|
|
if isMakeDirective(name) {
|
|
continue
|
|
}
|
|
line := lineAt(src, m[0])
|
|
tops = append(tops, makeTopHit{name: name, line: line, kind: graph.KindVariable})
|
|
}
|
|
// Sort-by-line so end-of-range computation is monotonic.
|
|
for i := 0; i < len(tops); i++ {
|
|
for j := i + 1; j < len(tops); j++ {
|
|
if tops[j].line < tops[i].line {
|
|
tops[i], tops[j] = tops[j], tops[i]
|
|
}
|
|
}
|
|
}
|
|
for i, t := range tops {
|
|
endLine := len(lines)
|
|
if i+1 < len(tops) {
|
|
endLine = tops[i+1].line - 1
|
|
if endLine < t.line {
|
|
endLine = t.line
|
|
}
|
|
}
|
|
add(t.name, t.kind, t.line, endLine)
|
|
}
|
|
|
|
for _, m := range makeDefineRe.FindAllSubmatchIndex(src, -1) {
|
|
name := string(src[m[2]:m[3]])
|
|
line := lineAt(src, m[0])
|
|
add(name, graph.KindFunction, line, findKeywordBlockEnd(lines, line, "endef"))
|
|
}
|
|
|
|
for _, m := range makeIncludeRe.FindAllSubmatchIndex(src, -1) {
|
|
arg := strings.TrimSpace(string(src[m[2]:m[3]]))
|
|
line := lineAt(src, m[0])
|
|
// `include a.mk b.mk` may list several files.
|
|
for _, f := range strings.Fields(arg) {
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileNode.ID, To: "unresolved::import::" + f,
|
|
Kind: graph.EdgeImports, FilePath: filePath, Line: line,
|
|
})
|
|
}
|
|
}
|
|
|
|
emitMakeRecipeCallEdges(filePath, lines, tops, result)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// emitMakeRecipeCallEdges walks each target's recipe lines and emits
|
|
// EdgeCalls when a recipe invokes another target in the same file.
|
|
// Recognised forms:
|
|
//
|
|
// $(MAKE) <target> → emit edge to <target>
|
|
// ${MAKE} <target> → same
|
|
// make <target> → same
|
|
// <bare-cmd> → emit edge to <bare-cmd> when it matches
|
|
// another target name
|
|
//
|
|
// Recipe-line modifiers (`@`, `-`, `+`) are stripped. Subshell
|
|
// interpolations like `$(shell …)` are skipped — they're command
|
|
// substitutions, not direct call edges. External commands (`grep`,
|
|
// `git`, etc.) produce no edge unless they happen to match a target
|
|
// name; that's a small false-positive risk we accept for the much
|
|
// larger build-chain coverage win.
|
|
// makeTopHit is the package-scope form of the local topHit struct,
|
|
// shared between Extract and emitMakeRecipeCallEdges so the call-edge
|
|
// pass can reuse the same target/variable inventory.
|
|
type makeTopHit struct {
|
|
name string
|
|
line int
|
|
kind graph.NodeKind
|
|
}
|
|
|
|
func emitMakeRecipeCallEdges(filePath string, lines []string, tops []makeTopHit, result *parser.ExtractionResult) {
|
|
// Build target-name lookup so call resolution is O(1).
|
|
targets := map[string]int{} // name → start line
|
|
for _, t := range tops {
|
|
if t.kind == graph.KindFunction {
|
|
targets[t.name] = t.line
|
|
}
|
|
}
|
|
if len(targets) == 0 {
|
|
return
|
|
}
|
|
// Cache of recipe lines per target.
|
|
for i, t := range tops {
|
|
if t.kind != graph.KindFunction {
|
|
continue
|
|
}
|
|
end := len(lines)
|
|
if i+1 < len(tops) {
|
|
end = tops[i+1].line - 1
|
|
if end < t.line {
|
|
end = t.line
|
|
}
|
|
}
|
|
fromID := filePath + "::" + t.name
|
|
seenCallEdges := map[string]bool{}
|
|
// Recipe lines are indented (tab or space) — not the target
|
|
// header line itself.
|
|
for ln := t.line + 1; ln <= end && ln <= len(lines); ln++ {
|
|
if ln-1 < 0 || ln-1 >= len(lines) {
|
|
continue
|
|
}
|
|
raw := lines[ln-1]
|
|
if !startsWithIndent(raw) {
|
|
continue
|
|
}
|
|
cmd := stripRecipePrefix(raw)
|
|
callee := makeRecipeCallTarget(cmd, targets)
|
|
if callee == "" || callee == t.name {
|
|
continue
|
|
}
|
|
key := callee + "@" + lineToKey(ln)
|
|
if seenCallEdges[key] {
|
|
continue
|
|
}
|
|
seenCallEdges[key] = true
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fromID,
|
|
To: filePath + "::" + callee,
|
|
Kind: graph.EdgeCalls,
|
|
FilePath: filePath,
|
|
Line: ln,
|
|
Origin: graph.OriginASTInferred,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// startsWithIndent reports whether a recipe line begins with a tab or
|
|
// space indent. Plain Makefiles use tabs; some projects use leading
|
|
// spaces with `.RECIPEPREFIX`. Either is acceptable.
|
|
func startsWithIndent(s string) bool {
|
|
if len(s) == 0 {
|
|
return false
|
|
}
|
|
return s[0] == '\t' || s[0] == ' '
|
|
}
|
|
|
|
// stripRecipePrefix removes leading indent and the optional recipe
|
|
// modifiers (`@` silent, `-` keep-going, `+` always-execute).
|
|
func stripRecipePrefix(s string) string {
|
|
s = strings.TrimLeft(s, " \t")
|
|
for {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
switch s[0] {
|
|
case '@', '-', '+':
|
|
s = s[1:]
|
|
default:
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
|
|
// makeRecipeCallTarget returns the same-file target name a recipe
|
|
// command invokes, or "" when the command isn't a target call.
|
|
func makeRecipeCallTarget(cmd string, targets map[string]int) string {
|
|
cmd = strings.TrimSpace(cmd)
|
|
if cmd == "" {
|
|
return ""
|
|
}
|
|
// `$(MAKE) <target>` / `${MAKE} <target>` / `make <target>`.
|
|
for _, prefix := range []string{"$(MAKE)", "${MAKE}", "make"} {
|
|
if strings.HasPrefix(cmd, prefix) {
|
|
rest := strings.TrimSpace(cmd[len(prefix):])
|
|
// First whitespace-delimited token after the prefix.
|
|
tok := firstWord(rest)
|
|
if tok != "" {
|
|
if _, ok := targets[tok]; ok {
|
|
return tok
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
// Bare command — emit edge only when it matches an existing
|
|
// target. This avoids `grep`, `git`, etc. producing unresolved
|
|
// edges. Strip any leading `$(...)` interpolation.
|
|
if strings.HasPrefix(cmd, "$(") || strings.HasPrefix(cmd, "${") {
|
|
return ""
|
|
}
|
|
tok := firstWord(cmd)
|
|
if tok == "" {
|
|
return ""
|
|
}
|
|
if _, ok := targets[tok]; ok {
|
|
return tok
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstWord(s string) string {
|
|
for i, r := range s {
|
|
if r == ' ' || r == '\t' {
|
|
return s[:i]
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func lineToKey(ln int) string {
|
|
return strings.Repeat(" ", ln%8) + "."
|
|
}
|
|
|
|
// isMakeDirective filters out reserved-word collisions that the
|
|
// variable regex would otherwise catch.
|
|
func isMakeDirective(s string) bool {
|
|
switch s {
|
|
case "ifeq", "ifneq", "ifdef", "ifndef", "else", "endif",
|
|
"define", "endef", "include", "sinclude", "export",
|
|
"unexport", "override", "vpath", "VPATH":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
var _ parser.Extractor = (*MakefileExtractor)(nil)
|