From 43733487ff6576d81ce84d1c9b7a1233f0f3bc05 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 14 Jul 2026 06:44:29 +0800 Subject: [PATCH] compiler: add standalone locality directive analysis --- internal/directive/directive.go | 77 +++++ internal/directive/directive_test.go | 47 +++ internal/locality/locality.go | 90 +++++ internal/locality/locality_test.go | 479 +++++++++++++++++++++++++++ internal/locality/prepare.go | 221 ++++++++++++ internal/locality/scan.go | 193 +++++++++++ 6 files changed, 1107 insertions(+) create mode 100644 internal/directive/directive.go create mode 100644 internal/directive/directive_test.go create mode 100644 internal/locality/locality.go create mode 100644 internal/locality/locality_test.go create mode 100644 internal/locality/prepare.go create mode 100644 internal/locality/scan.go diff --git a/internal/directive/directive.go b/internal/directive/directive.go new file mode 100644 index 0000000000..2678eb69ff --- /dev/null +++ b/internal/directive/directive.go @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package directive parses Go and LLGo source directives without assigning +// feature-specific semantics to them. +package directive + +import ( + "go/ast" + "go/token" + "strings" +) + +// Directive is one normalized Go or LLGo source directive. +type Directive struct { + Name string + Args string + Raw string + Pos token.Pos +} + +// Parse normalizes comment when it uses a supported directive spelling. +func Parse(comment *ast.Comment) (Directive, bool) { + if comment == nil { + return Directive{}, false + } + raw := comment.Text + var namespace, body string + switch { + case strings.HasPrefix(raw, "//go:"): + namespace, body = "go:", raw[len("//go:"):] + case strings.HasPrefix(raw, "//llgo:"): + namespace, body = "llgo:", raw[len("//llgo:"):] + case strings.HasPrefix(raw, "// llgo:"): + namespace, body = "llgo:", raw[len("// llgo:"):] + case strings.HasPrefix(raw, "//export "): + return Directive{Name: "export", Args: strings.TrimSpace(raw[len("//export "):]), Raw: raw, Pos: comment.Pos()}, true + default: + return Directive{}, false + } + body = strings.TrimSpace(body) + if body == "" { + return Directive{}, false + } + name, args := body, "" + if idx := strings.IndexAny(body, " \t"); idx >= 0 { + name, args = body[:idx], strings.TrimSpace(body[idx+1:]) + } + return Directive{Name: namespace + name, Args: args, Raw: raw, Pos: comment.Pos()}, true +} + +// ParseGroup returns all normalized directives in doc in source order. +func ParseGroup(doc *ast.CommentGroup) []Directive { + if doc == nil { + return nil + } + ret := make([]Directive, 0, len(doc.List)) + for _, comment := range doc.List { + if parsed, ok := Parse(comment); ok { + ret = append(ret, parsed) + } + } + return ret +} diff --git a/internal/directive/directive_test.go b/internal/directive/directive_test.go new file mode 100644 index 0000000000..c9bb47fd07 --- /dev/null +++ b/internal/directive/directive_test.go @@ -0,0 +1,47 @@ +package directive + +import ( + "go/ast" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + text string + name string + args string + ok bool + }{ + {text: "// ordinary"}, + {text: "//go:"}, + {text: "//go:noinline", name: "go:noinline", ok: true}, + {text: "//llgo:tls", name: "llgo:tls", ok: true}, + {text: "// llgo:type C", name: "llgo:type", args: "C", ok: true}, + {text: "//llgo:link\tF C.f", name: "llgo:link", args: "F C.f", ok: true}, + {text: "//export F", name: "export", args: "F", ok: true}, + } + if _, ok := Parse(nil); ok { + t.Fatal("nil comment parsed as a directive") + } + if ParseGroup(nil) != nil { + t.Fatal("nil comment group returned directives") + } + for _, test := range tests { + got, ok := Parse(&ast.Comment{Text: test.text}) + if ok != test.ok || got.Name != test.name || got.Args != test.args || got.Raw != map[bool]string{true: test.text}[test.ok] { + t.Fatalf("Parse(%q) = %+v, %v", test.text, got, ok) + } + } +} + +func TestParseGroupPreservesSourceOrder(t *testing.T) { + doc := &ast.CommentGroup{List: []*ast.Comment{ + {Text: "// ordinary"}, + {Text: "//go:noinline"}, + {Text: "//llgo:tls"}, + }} + got := ParseGroup(doc) + if len(got) != 2 || got[0].Name != "go:noinline" || got[1].Name != "llgo:tls" { + t.Fatalf("ParseGroup = %+v", got) + } +} diff --git a/internal/locality/locality.go b/internal/locality/locality.go new file mode 100644 index 0000000000..1e9a7bfca3 --- /dev/null +++ b/internal/locality/locality.go @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package locality defines the source-level TLS and GLS declaration model. +// It intentionally has no dependency on LLGo's SSA or LLVM lowering layers. +package locality + +import "fmt" + +const ( + ThreadDirective = "//llgo:tls" + GoroutineDirective = "//llgo:gls" + InitPrefix = "__llgo_local_init_" +) + +// Kind identifies the execution context that owns a package variable. +type Kind uint8 + +const ( + None Kind = iota + Thread + Goroutine +) + +// Info is the locality-specific part of a declaration's compiler metadata. +type Info struct { + Locality Kind + HasInitializer bool + InitFunc string + InitOrder int +} + +func (kind Kind) String() string { + switch kind { + case None: + return "" + case Thread: + return "tls" + case Goroutine: + return "gls" + default: + return fmt.Sprintf("invalid:%d", kind) + } +} + +// Parse converts the cache representation of a locality into a Kind. +func Parse(name string) (Kind, bool) { + switch name { + case "": + return None, true + case "tls": + return Thread, true + case "gls": + return Goroutine, true + default: + return None, false + } +} + +// Directive returns the source directive for kind. +func Directive(kind Kind) string { + if kind == Goroutine { + return GoroutineDirective + } + return ThreadDirective +} + +// Merge combines declaration- and spec-level locality directives. +func Merge(a, b Kind) (Kind, bool) { + if a != None && b != None && a != b { + return None, false + } + if b != None { + return b, true + } + return a, true +} diff --git a/internal/locality/locality_test.go b/internal/locality/locality_test.go new file mode 100644 index 0000000000..4a8ac7d44b --- /dev/null +++ b/internal/locality/locality_test.go @@ -0,0 +1,479 @@ +package locality + +import ( + "go/ast" + "go/parser" + "go/token" + "go/types" + "strings" + "testing" +) + +func TestKindEncodingAndNames(t *testing.T) { + tests := []struct { + kind Kind + name string + }{ + {None, ""}, + {Thread, "tls"}, + {Goroutine, "gls"}, + } + for _, test := range tests { + if got := test.kind.String(); got != test.name { + t.Fatalf("Kind(%d).String() = %q, want %q", test.kind, got, test.name) + } + if got, ok := Parse(test.name); !ok || got != test.kind { + t.Fatalf("Parse(%q) = %v, %v", test.name, got, ok) + } + } + if got := Kind(99).String(); got != "invalid:99" { + t.Fatalf("invalid locality name = %q", got) + } + if _, ok := Parse("invalid:99"); ok { + t.Fatal("Parse accepted invalid locality") + } + if Directive(Thread) != ThreadDirective || Directive(Goroutine) != GoroutineDirective { + t.Fatal("unexpected locality directive names") + } +} + +func TestMerge(t *testing.T) { + tests := []struct { + a, b Kind + want Kind + ok bool + }{ + {want: None, ok: true}, + {a: Thread, want: Thread, ok: true}, + {b: Goroutine, want: Goroutine, ok: true}, + {a: Thread, b: Thread, want: Thread, ok: true}, + {a: Thread, b: Goroutine, want: None}, + } + for _, test := range tests { + got, ok := Merge(test.a, test.b) + if got != test.want || ok != test.ok { + t.Fatalf("Merge(%v, %v) = %v, %v", test.a, test.b, got, ok) + } + } +} + +func TestScanPackageVar(t *testing.T) { + fset, file := parseFile(t, `package p + +//llgo:tls +var ( + first int + //llgo:gls + second int +) +`) + decl := file.Decls[0].(*ast.GenDecl) + if _, err := ScanPackageVar(fset, decl); err == nil || !strings.Contains(err.Error(), "cannot apply to the same variable declaration") { + t.Fatalf("ScanPackageVar conflict error = %v", err) + } + + fset, file = parseFile(t, `package p + +var ( + //llgo:tls + first, second = 1, 2 +) +`) + vars, err := ScanPackageVar(fset, file.Decls[0].(*ast.GenDecl)) + if err != nil { + t.Fatal(err) + } + if len(vars) != 2 || vars[0].Info.Locality != Thread || !vars[0].Info.HasInitializer || vars[1].Name != "second" { + t.Fatalf("ScanPackageVar = %+v", vars) + } +} + +func TestScanPackageVarBranches(t *testing.T) { + comment := func(text string) *ast.CommentGroup { + doc := &ast.CommentGroup{} + for _, line := range strings.Split(text, "\n") { + doc.List = append(doc.List, &ast.Comment{Text: line}) + } + return doc + } + tests := []struct { + name string + decl *ast.GenDecl + want string + }{ + { + name: "declaration error", + decl: &ast.GenDecl{Tok: token.VAR, Doc: comment("//llgo:tls extra")}, + want: "does not accept arguments", + }, + { + name: "spec error", + decl: &ast.GenDecl{Tok: token.VAR, Specs: []ast.Spec{&ast.ValueSpec{Doc: comment("//llgo:gls extra")}}}, + want: "does not accept arguments", + }, + { + name: "embed conflict", + decl: &ast.GenDecl{Tok: token.VAR, Doc: comment("//llgo:tls\n//go:embed value.txt"), Specs: []ast.Spec{&ast.ValueSpec{Names: []*ast.Ident{ast.NewIdent("Value")}}}}, + want: "//go:embed", + }, + { + name: "blank name", + decl: &ast.GenDecl{Tok: token.VAR, Doc: comment("//llgo:tls"), Specs: []ast.Spec{&ast.ValueSpec{Names: []*ast.Ident{ast.NewIdent("_")}}}}, + want: "blank identifier", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := ScanPackageVar(nil, test.decl); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ScanPackageVar error = %v, want %q", err, test.want) + } + }) + } + + ordinary := &ast.GenDecl{Tok: token.VAR, Specs: []ast.Spec{ + &ast.ImportSpec{}, + &ast.ValueSpec{Names: []*ast.Ident{ast.NewIdent("Value")}}, + }} + if vars, err := ScanPackageVar(nil, ordinary); err != nil || len(vars) != 0 { + t.Fatalf("ordinary ScanPackageVar = %+v, %v", vars, err) + } +} + +func TestDirectivePlacementDiagnostics(t *testing.T) { + tests := []struct { + name string + src string + want string + }{ + { + name: "grouped import spec", + src: `package p +import ( + //llgo:tls + "unsafe" +) +var _ = unsafe.Sizeof(0) +`, + want: "package-level var", + }, + { + name: "local var", + src: `package p +func f() { + //llgo:gls + var value int + _ = value +} +`, + want: "package-level var", + }, + { + name: "nested function local", + src: `package p +func f() { + _ = func() { + //llgo:tls extra + var value int + _ = value + } +} +`, + want: "does not accept arguments", + }, + { + name: "nested function in local initializer", + src: `package p +func f() { + var nested = func() { + //llgo:gls + var value int + _ = value + } + _ = nested +} +`, + want: "package-level var", + }, + { + name: "function", + src: `package p +//llgo:tls +func f() {} +`, + want: "package-level var", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fset, file := parseFile(t, test.src) + err := validateFile(fset, file) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validation error = %v, want %q", err, test.want) + } + }) + } +} + +func TestDirectiveDiagnostics(t *testing.T) { + tests := []struct { + comment string + want string + }{ + {"//llgo:threadlocal", "use //llgo:tls"}, + {"//llgo:goroutinelocal", "use //llgo:gls"}, + {"//llgo:tls extra", "does not accept arguments"}, + {"//llgo:tls\n//llgo:gls", "cannot apply to the same variable declaration"}, + } + for _, test := range tests { + doc := &ast.CommentGroup{} + for _, line := range strings.Split(test.comment, "\n") { + doc.List = append(doc.List, &ast.Comment{Text: line}) + } + if _, _, err := FromDoc(nil, doc); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("FromDoc(%q) error = %v", test.comment, err) + } + } + if err := ValidateDoc(nil, nil); err != nil { + t.Fatal(err) + } + if kind, _, err := FromDoc(nil, &ast.CommentGroup{List: []*ast.Comment{{Text: "//go:noinline"}}}); err != nil || kind != None { + t.Fatalf("ordinary directive = %v, %v", kind, err) + } + if err := ValidateFuncBody(nil, nil); err != nil { + t.Fatal(err) + } + typeDecl := &ast.GenDecl{Tok: token.TYPE, Specs: []ast.Spec{ + &ast.TypeSpec{Name: ast.NewIdent("T"), Doc: &ast.CommentGroup{List: []*ast.Comment{{Text: "//llgo:tls"}}}}, + }} + if err := ValidateNonPackageVar(nil, typeDecl); err == nil || !strings.Contains(err.Error(), "package-level var") { + t.Fatalf("type spec validation error = %v", err) + } + if hasDirective(&ast.CommentGroup{List: []*ast.Comment{{Text: "//go:noinline"}}}, "go:embed") { + t.Fatal("hasDirective matched an unrelated directive") + } +} + +func TestInitializerLocalityDiagnostics(t *testing.T) { + pkg := types.NewPackage("example.com/p", "p") + thread := types.NewVar(token.NoPos, pkg, "thread", types.Typ[types.Int]) + goroutine := types.NewVar(token.NoPos, pkg, "goroutine", types.Typ[types.Int]) + ordinary := types.NewVar(token.NoPos, pkg, "ordinary", types.Typ[types.Int]) + vars := map[string]Info{ + "thread": {Locality: Thread, HasInitializer: true}, + "goroutine": {Locality: Goroutine, HasInitializer: true}, + } + rhs := ast.NewIdent("rhs") + tests := []struct { + lhs []*types.Var + want string + }{ + {[]*types.Var{thread, ordinary}, "mix local and ordinary"}, + {[]*types.Var{ordinary, thread}, "mix local and ordinary"}, + {[]*types.Var{thread, goroutine}, "mix thread-local and goroutine-local"}, + } + for _, test := range tests { + if _, _, err := initializerLocality(nil, &types.Initializer{Lhs: test.lhs, Rhs: rhs}, vars); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("initializerLocality error = %v, want %q", err, test.want) + } + } + if kind, found, err := initializerLocality(nil, &types.Initializer{Lhs: []*types.Var{ordinary}, Rhs: rhs}, vars); err != nil || found || kind != None { + t.Fatalf("ordinary initializer = %v, %v, %v", kind, found, err) + } +} + +func TestPrepareIsIdempotentAcrossPrograms(t *testing.T) { + fset, file := parseFile(t, `package p +func makeValue() *int { value := 42; return &value } +//llgo:tls +var Value = makeValue() +`) + files := []*ast.File{file} + info := newTypeInfo() + pkg, err := (&types.Config{}).Check("example.com/p", fset, files, info) + if err != nil { + t.Fatal(err) + } + vars, err := ScanPackageVar(fset, file.Decls[1].(*ast.GenDecl)) + if err != nil { + t.Fatal(err) + } + raw := make(map[string]Info) + for _, variable := range vars { + raw[variable.Name] = variable.Info + } + + prepared, err := Prepare(fset, pkg.Path(), pkg, info, files, raw) + if err != nil { + t.Fatal(err) + } + declCount := len(file.Decls) + scopeCount := len(pkg.Scope().Names()) + initName := prepared["Value"].InitFunc + if initName != "example.com/p.__llgo_local_init_0" || prepared["Value"].InitOrder != 1 { + t.Fatalf("prepared metadata = %+v", prepared["Value"]) + } + + again, err := Prepare(fset, pkg.Path(), pkg, info, files, prepared) + if err != nil { + t.Fatal(err) + } + reused, err := Prepare(fset, pkg.Path(), pkg, info, files, raw) + if err != nil { + t.Fatal(err) + } + if len(file.Decls) != declCount || len(pkg.Scope().Names()) != scopeCount { + t.Fatalf("repeated Prepare changed syntax/scope: decls=%d/%d scope=%d/%d", len(file.Decls), declCount, len(pkg.Scope().Names()), scopeCount) + } + if again["Value"] != prepared["Value"] || reused["Value"] != prepared["Value"] { + t.Fatalf("repeated metadata = %+v, reused = %+v, want %+v", again["Value"], reused["Value"], prepared["Value"]) + } +} + +func TestPrepareZeroValuePointerAndValidation(t *testing.T) { + pkg := types.NewPackage("example.com/p", "p") + value := types.NewVar(token.NoPos, pkg, "Value", types.NewPointer(types.Typ[types.Int])) + pkg.Scope().Insert(value) + vars := map[string]Info{"Value": {Locality: Goroutine}} + prepared, err := Prepare(nil, pkg.Path(), pkg, &types.Info{}, nil, vars) + if err != nil { + t.Fatal(err) + } + if got := prepared["Value"]; got.InitFunc != "" || got.InitOrder != 0 { + t.Fatalf("zero-value initializer = %+v", got) + } + if err := ValidatePrepared(pkg.Path(), map[string]Info{"Value": {Locality: Thread, HasInitializer: true}}); err == nil { + t.Fatal("ValidatePrepared accepted missing initializer metadata") + } + if err := ValidatePrepared(pkg.Path(), map[string]Info{"Value": {Locality: Thread, InitFunc: "p.init", InitOrder: 1}}); err == nil { + t.Fatal("ValidatePrepared accepted initializer metadata on a zero-value declaration") + } + if err := ValidatePrepared(pkg.Path(), map[string]Info{ + "Ordinary": {}, + "Value": {Locality: Thread, HasInitializer: true, InitFunc: "p.init", InitOrder: 1}, + }); err != nil { + t.Fatal(err) + } +} + +func TestPrepareEarlyReturnsAndMissingFiles(t *testing.T) { + if got, err := Prepare(nil, "", nil, nil, nil, nil); err != nil || len(got) != 0 { + t.Fatalf("nil Prepare = %+v, %v", got, err) + } + pkg := types.NewPackage("example.com/p", "p") + value := types.NewVar(token.NoPos, pkg, "Value", types.Typ[types.Int]) + pkg.Scope().Insert(value) + info := &types.Info{InitOrder: []*types.Initializer{{Lhs: []*types.Var{value}, Rhs: ast.NewIdent("rhs")}}} + vars := map[string]Info{"Value": {Locality: Thread, HasInitializer: true}} + if _, err := Prepare(nil, pkg.Path(), pkg, info, nil, vars); err == nil || !strings.Contains(err.Error(), "without syntax files") { + t.Fatalf("Prepare without files error = %v", err) + } + if got, err := Prepare(nil, pkg.Path(), pkg, &types.Info{}, nil, nil); err != nil || len(got) != 0 { + t.Fatalf("Prepare without localities = %+v, %v", got, err) + } +} + +func TestPrepareInitializerBranches(t *testing.T) { + pkg := types.NewPackage("example.com/p", "p") + local := types.NewVar(token.NoPos, pkg, "Local", types.Typ[types.Int]) + ordinary := types.NewVar(token.NoPos, pkg, "Ordinary", types.Typ[types.Int]) + pkg.Scope().Insert(local) + pkg.Scope().Insert(ordinary) + rhs := ast.NewIdent("rhs") + info := &types.Info{InitOrder: []*types.Initializer{ + {Lhs: []*types.Var{ordinary}, Rhs: ast.NewIdent("ordinary")}, + {Lhs: []*types.Var{local, ordinary}, Rhs: rhs}, + }} + vars := map[string]Info{"Local": {Locality: Thread, HasInitializer: true}} + if _, err := Prepare(nil, pkg.Path(), pkg, info, []*ast.File{{}}, vars); err == nil || !strings.Contains(err.Error(), "mix local and ordinary") { + t.Fatalf("mixed Prepare error = %v", err) + } + + info.InitOrder = []*types.Initializer{{Lhs: []*types.Var{local}, Rhs: rhs}} + prepared, err := Prepare(nil, pkg.Path(), pkg, info, []*ast.File{{}}, vars) + if err != nil { + t.Fatal(err) + } + if prepared["Local"].InitFunc == "" || info.Uses == nil || info.Defs == nil { + t.Fatalf("Prepare did not initialize type maps: %+v", prepared["Local"]) + } + + conflicting := &types.Initializer{Lhs: []*types.Var{local, ordinary}, Rhs: rhs} + if got := preparedInitName(conflicting, map[string]Info{ + "Local": {InitFunc: "p.first", InitOrder: 1}, + "Ordinary": {InitFunc: "p.second", InitOrder: 1}, + }, 1); got != "" { + t.Fatalf("preparedInitName accepted conflicting helpers: %q", got) + } + if got := qualify("", "Value"); got != "Value" { + t.Fatalf("qualify empty package = %q", got) + } +} + +func TestFindLocalInitializerRejectsLookalikes(t *testing.T) { + pkg := types.NewPackage("example.com/p", "p") + value := types.NewVar(token.NoPos, pkg, "Value", types.Typ[types.Int]) + rhs := ast.NewIdent("rhs") + initializer := &types.Initializer{Lhs: []*types.Var{value}, Rhs: rhs} + files := []*ast.File{{Decls: []ast.Decl{ + &ast.FuncDecl{ + Name: ast.NewIdent(InitPrefix + "0"), + Body: &ast.BlockStmt{List: []ast.Stmt{&ast.ExprStmt{X: rhs}}}, + }, + &ast.FuncDecl{ + Name: ast.NewIdent(InitPrefix + "1"), + Body: &ast.BlockStmt{List: []ast.Stmt{&ast.AssignStmt{ + Lhs: []ast.Expr{&ast.BasicLit{Kind: token.INT, Value: "1"}}, + Tok: token.ASSIGN, + Rhs: []ast.Expr{rhs}, + }}}, + }, + }}} + if got := findLocalInitializer(pkg, &types.Info{}, files, initializer); got != "" { + t.Fatalf("findLocalInitializer accepted lookalike %q", got) + } +} + +func parseFile(t *testing.T, src string) (*token.FileSet, *ast.File) { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "source.go", src, parser.ParseComments) + if err != nil { + t.Fatal(err) + } + return fset, file +} + +func validateFile(fset *token.FileSet, file *ast.File) error { + for _, node := range file.Decls { + switch decl := node.(type) { + case *ast.FuncDecl: + if err := ValidateDoc(fset, decl.Doc); err != nil { + return err + } + if err := ValidateFuncBody(fset, decl.Body); err != nil { + return err + } + case *ast.GenDecl: + if decl.Tok == token.VAR { + if _, err := ScanPackageVar(fset, decl); err != nil { + return err + } + } else if err := ValidateNonPackageVar(fset, decl); err != nil { + return err + } + } + } + return nil +} + +func newTypeInfo() *types.Info { + return &types.Info{ + Types: make(map[ast.Expr]types.TypeAndValue), + Defs: make(map[*ast.Ident]types.Object), + Uses: make(map[*ast.Ident]types.Object), + Implicits: make(map[ast.Node]types.Object), + Selections: make(map[*ast.SelectorExpr]*types.Selection), + Scopes: make(map[ast.Node]*types.Scope), + Instances: make(map[*ast.Ident]types.Instance), + } +} diff --git a/internal/locality/prepare.go b/internal/locality/prepare.go new file mode 100644 index 0000000000..429dd78834 --- /dev/null +++ b/internal/locality/prepare.go @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package locality + +import ( + "fmt" + "go/ast" + "go/token" + "go/types" + "sort" +) + +// Prepare rewrites local package initializers into replayable synthetic +// functions and returns updated metadata. It is idempotent for repeated calls, +// including calls made by another compiler Program reusing the same syntax and +// types.Package objects. +func Prepare(fset *token.FileSet, pkgPath string, pkg *types.Package, typeInfo *types.Info, files []*ast.File, vars map[string]Info) (map[string]Info, error) { + ret := cloneInfo(vars) + if pkg == nil || typeInfo == nil || !hasLocality(ret) { + return ret, nil + } + + nextName := 0 + for order, initializer := range typeInfo.InitOrder { + _, found, err := initializerLocality(fset, initializer, ret) + if err != nil { + return nil, err + } + if !found { + continue + } + initOrder := order + 1 + if initName := preparedInitName(initializer, ret, initOrder); initName != "" { + setInitializerNames(initializer, ret, initName, initOrder) + continue + } + if len(files) == 0 { + return nil, fmt.Errorf("cannot prepare local initializer for package %q without syntax files", pkgPath) + } + name := findLocalInitializer(pkg, typeInfo, files, initializer) + if name == "" { + for { + name = fmt.Sprintf("%s%d", InitPrefix, nextName) + nextName++ + if pkg.Scope().Lookup(name) == nil { + break + } + } + fnObj, decl := makeLocalInitializer(pkg, typeInfo, name, initializer) + pkg.Scope().Insert(fnObj) + files[len(files)-1].Decls = append(files[len(files)-1].Decls, decl) + } + setInitializerNames(initializer, ret, qualify(pkgPath, name), initOrder) + } + + return ret, nil +} + +// ValidatePrepared verifies that every explicit local initializer was prepared +// before Go SSA construction. +func ValidatePrepared(pkgPath string, vars map[string]Info) error { + names := make([]string, 0, len(vars)) + for name := range vars { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + info := vars[name] + if info.Locality == None { + continue + } + prepared := info.InitFunc != "" && info.InitOrder != 0 + if info.HasInitializer != prepared { + return fmt.Errorf("local variable %s has inconsistent initializer metadata before SSA compilation", qualify(pkgPath, name)) + } + } + return nil +} + +func initializerLocality(fset *token.FileSet, initializer *types.Initializer, vars map[string]Info) (Kind, bool, error) { + var kind Kind + localCount := 0 + for _, variable := range initializer.Lhs { + info, ok := vars[variable.Name()] + if !ok || info.Locality == None { + if localCount != 0 { + return None, false, errorAt(fset, initializer.Rhs.Pos(), "one initializer cannot mix local and ordinary package variables") + } + continue + } + if localCount == 0 { + kind = info.Locality + } else if kind != info.Locality { + return None, false, errorAt(fset, initializer.Rhs.Pos(), "one initializer cannot mix thread-local and goroutine-local variables") + } + localCount++ + } + if localCount != 0 && len(initializer.Lhs) != localCount { + return None, false, errorAt(fset, initializer.Rhs.Pos(), "one initializer cannot mix local and ordinary package variables") + } + return kind, localCount != 0, nil +} + +func preparedInitName(initializer *types.Initializer, vars map[string]Info, order int) string { + var name string + for _, variable := range initializer.Lhs { + info := vars[variable.Name()] + if info.InitFunc == "" || info.InitOrder != order { + return "" + } + if name == "" { + name = info.InitFunc + } else if name != info.InitFunc { + return "" + } + } + return name +} + +func findLocalInitializer(pkg *types.Package, info *types.Info, files []*ast.File, initializer *types.Initializer) string { + for _, file := range files { + for _, node := range file.Decls { + decl, ok := node.(*ast.FuncDecl) + if !ok || len(decl.Name.Name) < len(InitPrefix) || decl.Name.Name[:len(InitPrefix)] != InitPrefix || decl.Body == nil || len(decl.Body.List) != 1 { + continue + } + assign, ok := decl.Body.List[0].(*ast.AssignStmt) + if !ok || assign.Tok != token.ASSIGN || len(assign.Rhs) != 1 || assign.Rhs[0] != initializer.Rhs || len(assign.Lhs) != len(initializer.Lhs) { + continue + } + matches := true + for i, lhs := range assign.Lhs { + ident, ok := lhs.(*ast.Ident) + if !ok || info.Uses[ident] != initializer.Lhs[i] { + matches = false + break + } + } + if matches { + if object := pkg.Scope().Lookup(decl.Name.Name); object == info.Defs[decl.Name] { + return decl.Name.Name + } + } + } + } + return "" +} + +func makeLocalInitializer(pkg *types.Package, info *types.Info, name string, initializer *types.Initializer) (*types.Func, *ast.FuncDecl) { + if info.Uses == nil { + info.Uses = make(map[*ast.Ident]types.Object) + } + if info.Defs == nil { + info.Defs = make(map[*ast.Ident]types.Object) + } + lhs := make([]ast.Expr, len(initializer.Lhs)) + for i, variable := range initializer.Lhs { + ident := ast.NewIdent(variable.Name()) + info.Uses[ident] = variable + lhs[i] = ident + } + nameIdent := ast.NewIdent(name) + sig := types.NewSignatureType(nil, nil, nil, nil, nil, false) + fnObj := types.NewFunc(token.NoPos, pkg, name, sig) + info.Defs[nameIdent] = fnObj + decl := &ast.FuncDecl{ + Name: nameIdent, + Type: &ast.FuncType{Params: &ast.FieldList{}}, + Body: &ast.BlockStmt{List: []ast.Stmt{ + &ast.AssignStmt{Lhs: lhs, Tok: token.ASSIGN, Rhs: []ast.Expr{initializer.Rhs}}, + }}, + } + return fnObj, decl +} + +func setInitializerNames(initializer *types.Initializer, vars map[string]Info, initName string, order int) { + for _, variable := range initializer.Lhs { + info := vars[variable.Name()] + info.InitFunc = initName + info.InitOrder = order + vars[variable.Name()] = info + } +} + +func cloneInfo(vars map[string]Info) map[string]Info { + ret := make(map[string]Info, len(vars)) + for name, info := range vars { + ret[name] = info + } + return ret +} + +func hasLocality(vars map[string]Info) bool { + for _, info := range vars { + if info.Locality != None { + return true + } + } + return false +} + +func qualify(pkgPath, name string) string { + if pkgPath == "" { + return name + } + return pkgPath + "." + name +} diff --git a/internal/locality/scan.go b/internal/locality/scan.go new file mode 100644 index 0000000000..b458a81795 --- /dev/null +++ b/internal/locality/scan.go @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package locality + +import ( + "fmt" + "go/ast" + "go/token" + + "github.com/goplus/llgo/internal/directive" +) + +const ( + legacyThread = "llgo:threadlocal" + legacyGoroutine = "llgo:goroutinelocal" +) + +// Variable records locality metadata collected for one package variable. +type Variable struct { + Name string + Info Info +} + +// ScanPackageVar validates and collects locality directives on a package-level +// var declaration. +func ScanPackageVar(fset *token.FileSet, decl *ast.GenDecl) ([]Variable, error) { + declKind, declPos, err := FromDoc(fset, decl.Doc) + if err != nil { + return nil, err + } + var ret []Variable + for _, node := range decl.Specs { + spec, ok := node.(*ast.ValueSpec) + if !ok { + continue + } + specKind, specPos, err := FromDoc(fset, spec.Doc) + if err != nil { + return nil, err + } + kind, _, err := mergeAt(fset, declKind, declPos, specKind, specPos) + if err != nil { + return nil, err + } + if kind == None { + continue + } + if hasDirective(decl.Doc, "go:embed") || hasDirective(spec.Doc, "go:embed") { + return nil, errorAt(fset, spec.Pos(), "%s and //go:embed cannot apply to the same variable declaration", Directive(kind)) + } + for _, ident := range spec.Names { + if ident.Name == "_" { + return nil, errorAt(fset, ident.Pos(), "locality directive cannot apply to the blank identifier") + } + ret = append(ret, Variable{ + Name: ident.Name, + Info: Info{Locality: kind, HasInitializer: len(spec.Values) != 0}, + }) + } + } + return ret, nil +} + +// ValidateNonPackageVar rejects locality directives on declarations other +// than package-level vars, including grouped import/type/const specs. +func ValidateNonPackageVar(fset *token.FileSet, decl *ast.GenDecl) error { + if err := ValidateDoc(fset, decl.Doc); err != nil { + return err + } + for _, node := range decl.Specs { + var doc *ast.CommentGroup + switch spec := node.(type) { + case *ast.ImportSpec: + doc = spec.Doc + case *ast.TypeSpec: + doc = spec.Doc + case *ast.ValueSpec: + doc = spec.Doc + } + if err := ValidateDoc(fset, doc); err != nil { + return err + } + } + return nil +} + +// ValidateFuncBody rejects locality directives on declarations nested inside +// a function or function literal. +func ValidateFuncBody(fset *token.FileSet, body *ast.BlockStmt) error { + if body == nil { + return nil + } + var firstErr error + ast.Inspect(body, func(node ast.Node) bool { + if firstErr != nil { + return false + } + stmt, ok := node.(*ast.DeclStmt) + if !ok { + return true + } + decl, ok := stmt.Decl.(*ast.GenDecl) + if ok { + firstErr = ValidateNonPackageVar(fset, decl) + } + return firstErr == nil + }) + return firstErr +} + +// FromDoc returns the locality directive attached to doc. +func FromDoc(fset *token.FileSet, doc *ast.CommentGroup) (Kind, token.Pos, error) { + var kind Kind + var pos token.Pos + for _, directive := range directive.ParseGroup(doc) { + var next Kind + switch directive.Name { + case "llgo:tls": + next = Thread + case "llgo:gls": + next = Goroutine + case legacyThread: + return None, token.NoPos, errorAt(fset, directive.Pos, "//%s is not supported; use %s", legacyThread, ThreadDirective) + case legacyGoroutine: + return None, token.NoPos, errorAt(fset, directive.Pos, "//%s is not supported; use %s", legacyGoroutine, GoroutineDirective) + default: + continue + } + if directive.Args != "" { + return None, token.NoPos, errorAt(fset, directive.Pos, "//%s does not accept arguments", directive.Name) + } + var err error + kind, pos, err = mergeAt(fset, kind, pos, next, directive.Pos) + if err != nil { + return None, token.NoPos, err + } + } + return kind, pos, nil +} + +// ValidateDoc rejects a locality directive outside a package-level var. +func ValidateDoc(fset *token.FileSet, doc *ast.CommentGroup) error { + kind, pos, err := FromDoc(fset, doc) + if err != nil { + return err + } + if kind != None { + return errorAt(fset, pos, "%s applies only to package-level var declarations", Directive(kind)) + } + return nil +} + +func mergeAt(fset *token.FileSet, a Kind, apos token.Pos, b Kind, bpos token.Pos) (Kind, token.Pos, error) { + merged, ok := Merge(a, b) + if !ok { + return None, token.NoPos, errorAt(fset, bpos, "%s and %s cannot apply to the same variable declaration", ThreadDirective, GoroutineDirective) + } + if b != None { + return merged, bpos, nil + } + return merged, apos, nil +} + +func hasDirective(doc *ast.CommentGroup, name string) bool { + for _, directive := range directive.ParseGroup(doc) { + if directive.Name == name { + return true + } + } + return false +} + +func errorAt(fset *token.FileSet, pos token.Pos, format string, args ...any) error { + msg := fmt.Sprintf(format, args...) + if fset == nil || pos == token.NoPos { + return fmt.Errorf("%s", msg) + } + return fmt.Errorf("%s: %s", fset.Position(pos), msg) +}