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
241 lines
8.5 KiB
Go
241 lines
8.5 KiB
Go
package languages
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
)
|
|
|
|
// Framework-provided-identifier suppression and template-handler mining for the
|
|
// SFC frameworks (Vue/Nuxt, Svelte/SvelteKit, Astro). The script blocks are
|
|
// delegated to the TS/JS extractor, which emits a bare-call edge for every
|
|
// compiler macro / auto-import the framework provides (defineProps, useFetch,
|
|
// $state, …). Those have no user definition, so they would linger as unresolved
|
|
// edges (noise) or, worse, mis-bind to an unrelated user symbol of the same
|
|
// name. Dropping them keeps the component's call graph precise — and a real,
|
|
// user-defined same-named symbol still resolves and is left intact.
|
|
|
|
func frameworkIdentSet(names ...string) map[string]bool {
|
|
m := make(map[string]bool, len(names))
|
|
for _, n := range names {
|
|
m[n] = true
|
|
}
|
|
return m
|
|
}
|
|
|
|
var frameworkSuppressionSets = map[string]map[string]bool{
|
|
"vue": frameworkIdentSet(
|
|
// Vue compiler macros.
|
|
"defineProps", "defineEmits", "defineExpose", "defineOptions",
|
|
"defineModel", "defineSlots", "withDefaults", "useAttrs", "useSlots",
|
|
// Nuxt auto-imports.
|
|
"useFetch", "useAsyncData", "useLazyFetch", "useState", "useRoute",
|
|
"useRouter", "useRuntimeConfig", "useHead", "useSeoMeta", "useNuxtApp",
|
|
"useCookie", "useRequestHeaders", "navigateTo", "definePageMeta",
|
|
"defineNuxtConfig", "defineEventHandler",
|
|
),
|
|
"svelte": frameworkIdentSet(
|
|
// Svelte 5 runes.
|
|
"$state", "$derived", "$effect", "$props", "$bindable", "$inspect", "$host",
|
|
// Svelte 3/4 lifecycle / store helpers.
|
|
"onMount", "onDestroy", "beforeUpdate", "afterUpdate", "tick",
|
|
),
|
|
"astro": frameworkIdentSet("Astro", "Fragment"),
|
|
}
|
|
|
|
// frameworkImportPrefixes are the virtual module roots a framework injects;
|
|
// imports from them are framework-provided, not user files.
|
|
var frameworkImportPrefixes = map[string][]string{
|
|
"svelte": {"$app/", "$env/", "$lib/", "svelte", "@sveltejs/"},
|
|
"vue": {"#imports", "#app", "nuxt/"},
|
|
"astro": {"astro:", "#imports"},
|
|
}
|
|
|
|
// suppressFrameworkIdents drops the unresolved macro/auto-import edges a
|
|
// delegated script produced for lang. Only unresolved targets are touched, so a
|
|
// user symbol that genuinely matches the name keeps its resolved edge.
|
|
func suppressFrameworkIdents(result *parser.ExtractionResult, lang string) {
|
|
set := frameworkSuppressionSets[lang]
|
|
prefixes := frameworkImportPrefixes[lang]
|
|
if len(set) == 0 && len(prefixes) == 0 {
|
|
return
|
|
}
|
|
kept := result.Edges[:0]
|
|
for _, e := range result.Edges {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
switch e.Kind {
|
|
case graph.EdgeCalls, graph.EdgeReferences, graph.EdgeInstantiates, graph.EdgeReads:
|
|
if graph.IsUnresolvedTarget(e.To) && set[graph.UnresolvedName(e.To)] {
|
|
continue // framework macro — suppress
|
|
}
|
|
case graph.EdgeImports:
|
|
if graph.IsUnresolvedTarget(e.To) {
|
|
name := graph.UnresolvedName(e.To)
|
|
if hasAnyPrefix(name, prefixes) {
|
|
continue // framework virtual module — suppress
|
|
}
|
|
}
|
|
}
|
|
kept = append(kept, e)
|
|
}
|
|
result.Edges = kept
|
|
}
|
|
|
|
func hasAnyPrefix(s string, prefixes []string) bool {
|
|
for _, p := range prefixes {
|
|
if strings.HasPrefix(s, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var (
|
|
// vueHandlerRe matches a Vue template event binding `@click="onClick"` or
|
|
// `v-on:click="onClick"`. Group 2 is the handler identifier.
|
|
vueHandlerRe = regexp.MustCompile(`(?:@|v-on:)[\w-]+\s*=\s*"(\w+)`)
|
|
// svelteHandlerRe matches a Svelte template binding `on:click={onClick}` or
|
|
// the Svelte 5 `onclick={onClick}` attribute form. Group 1 is the handler.
|
|
svelteHandlerRe = regexp.MustCompile(`on:?[\w-]+\s*=\s*\{\s*(\w+)`)
|
|
// composableDestructureRe matches a script-setup destructure of a Vue/Nuxt
|
|
// composable's return — `const { close, open: o } = useModal()`. Group 1 is
|
|
// the destructure body, group 2 the `use*` composable. Only `use`-prefixed
|
|
// callees qualify (the composable convention), keeping the binding precise.
|
|
composableDestructureRe = regexp.MustCompile(`(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(use[A-Z]\w*)\s*\(`)
|
|
)
|
|
|
|
// composableBinding records a template handler that is a composable-destructured
|
|
// local: the original member it aliases and the composable it came from.
|
|
type composableBinding struct {
|
|
original string
|
|
composable string
|
|
}
|
|
|
|
// composableHandlerBindings maps each local name destructured from a `use*`
|
|
// composable to its original member + composable, so a template handler that
|
|
// references the (possibly renamed) local resolves to the composable member
|
|
// instead of an unresolved bare name. `const { close: c } = useModal()` maps
|
|
// c -> {original: "close", composable: "useModal"}.
|
|
func composableHandlerBindings(src []byte) map[string]composableBinding {
|
|
out := map[string]composableBinding{}
|
|
for _, m := range composableDestructureRe.FindAllSubmatch(src, -1) {
|
|
composable := string(m[2])
|
|
for _, part := range strings.Split(string(m[1]), ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" || strings.HasPrefix(part, "...") {
|
|
continue
|
|
}
|
|
// Drop a default value (`open = false`).
|
|
if i := strings.IndexByte(part, '='); i >= 0 {
|
|
part = strings.TrimSpace(part[:i])
|
|
}
|
|
orig, local := part, part
|
|
if i := strings.IndexByte(part, ':'); i >= 0 { // rename `orig: local`
|
|
orig = strings.TrimSpace(part[:i])
|
|
local = strings.TrimSpace(part[i+1:])
|
|
}
|
|
if !isSimpleJSIdent(local) || !isSimpleJSIdent(orig) {
|
|
continue
|
|
}
|
|
if _, ok := out[local]; !ok {
|
|
out[local] = composableBinding{original: orig, composable: composable}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isSimpleJSIdent reports whether s is a bare JS identifier (letters, digits,
|
|
// `_`, `$`; not starting with a digit).
|
|
func isSimpleJSIdent(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
switch {
|
|
case c == '_' || c == '$':
|
|
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z':
|
|
case c >= '0' && c <= '9':
|
|
if i == 0 {
|
|
return false
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// mineTemplateHandlers emits a callback reference edge from the component to
|
|
// each event handler bound in its markup (@click / v-on / on:click), resolved
|
|
// to the in-script function so "what handles this event" and find_usages reach
|
|
// the handler — the template→code link a script-only scanner misses.
|
|
func mineTemplateHandlers(src []byte, filePath, componentID, lang string, result *parser.ExtractionResult) {
|
|
var re *regexp.Regexp
|
|
switch lang {
|
|
case "svelte":
|
|
re = svelteHandlerRe
|
|
case "astro":
|
|
return // Astro markup uses framework client directives, not @click.
|
|
default:
|
|
re = vueHandlerRe
|
|
}
|
|
|
|
funcByName := map[string]string{}
|
|
for _, n := range result.Nodes {
|
|
if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod {
|
|
if _, ok := funcByName[n.Name]; !ok {
|
|
funcByName[n.Name] = n.ID
|
|
}
|
|
}
|
|
}
|
|
|
|
// Composable-destructured handlers (`const { close: c } = useModal()` +
|
|
// `@click="c"`) bind to the composable member, not a top-level function.
|
|
bindings := composableHandlerBindings(src)
|
|
|
|
tmpl := blankTemplateRegions(src, lang)
|
|
seen := map[string]bool{}
|
|
for _, m := range re.FindAllSubmatchIndex(tmpl, -1) {
|
|
handler := string(tmpl[m[2]:m[3]])
|
|
if handler == "" || seen[handler] {
|
|
continue
|
|
}
|
|
seen[handler] = true
|
|
to := "unresolved::" + handler
|
|
origin := graph.OriginTextMatched
|
|
meta := map[string]any{"ref_context": graph.RefContextCallback, "via": "template_handler"}
|
|
if id, ok := funcByName[handler]; ok {
|
|
to = id
|
|
origin = graph.OriginASTResolved
|
|
} else if b, ok := bindings[handler]; ok {
|
|
// Reference the composable's original member so the binding is
|
|
// traceable (`@click="c"` where c aliases useModal().close).
|
|
to = "unresolved::" + b.original
|
|
meta["via"] = "composable_handler"
|
|
meta["composable"] = b.composable
|
|
}
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: componentID, To: to, Kind: graph.EdgeReferences,
|
|
FilePath: filePath, Line: 1 + strings.Count(string(tmpl[:m[0]]), "\n"),
|
|
Origin: origin,
|
|
Meta: meta,
|
|
})
|
|
}
|
|
}
|
|
|
|
// applyFrameworkTemplatePasses runs the suppression + handler-mining passes
|
|
// shared by the Vue/Svelte/Astro extractors after their scripts are delegated.
|
|
func applyFrameworkTemplatePasses(src []byte, filePath, componentID, lang string, result *parser.ExtractionResult) {
|
|
// Mine markup `{expr}` calls first, so the suppression pass can drop any
|
|
// rune/macro calls (`$state(...)`) it surfaced before they linger as edges.
|
|
mineTemplateExpressionCalls(src, filePath, componentID, lang, result)
|
|
suppressFrameworkIdents(result, lang)
|
|
mineTemplateHandlers(src, filePath, componentID, lang, result)
|
|
}
|