blob: 8f9cae5a3b790f36b567aa13b172123e6a96e29c [file] [log] [blame]
// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package splitpkg
// This file produces the "Split package" HTML report.
//
// The server persistently holds, for each PackageID, the current set
// of components and the mapping from declared names to components. On
// each page reload or JS reload() call, the server type-checks the
// package, computes its symbol reference graph, projects it onto
// components, then returns the component reference graph, and if it
// is cyclic, which edges form cycles. Thus changes to the package
// source are reflected in the client UI at the next page reload or JS
// reload() event.
//
// See also:
// - ../codeaction.go - offers the CodeAction command
// - ../../server/command.go - handles the command by opening a web page
// - ../../server/server.go - handles the HTTP request and calls this function
// - ../../server/assets/splitpkg.js - client-side logic
// - ../../test/integration/web/splitpkg_test.go - integration test of server
//
// TODO(adonovan): future work
//
// Refine symbol reference graph:
// - deal with enums (values must stay together; implicit dependency on iota expression)
// - deal with coupled vars "var x, y = f()"
// - deal with declared methods (coupled to receiver named type)
// - deal with fields/interface methods (loosely coupled to struct/interface type)
// In both cases the field/method name must be either exported or in the same component.
//
// UI:
// - make shift click extend selection of a range of checkboxes.
// - display two-level grouping of decls and specs: var ( x int; y int )
// - indicate when package has type errors (data may be incomplete).
//
// Code transformation:
// - add "Split" button that is green when acyclic. It should:
// 1) move each component into a new package, or separate file of
// the same package. (The UI will need to hold this user
// intent in the list of components.)
// 2) ensure that each declaration referenced from another package
// is public, renaming as needed.
// 3) update package decls, imports, package docs, file docs,
// doc comments, etc.
// Should we call this feature "Reorganize package" or "Decompose package"
// until the "Split" button actually exists?
import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/json"
"fmt"
"go/ast"
"go/token"
"go/types"
"html/template"
"log"
"strconv"
"strings"
"golang.org/x/tools/gopls/internal/cache"
"golang.org/x/tools/gopls/internal/cache/metadata"
"golang.org/x/tools/gopls/internal/filecache"
"golang.org/x/tools/gopls/internal/protocol"
"golang.org/x/tools/gopls/internal/util/moremaps"
"golang.org/x/tools/gopls/internal/util/safetoken"
"golang.org/x/tools/internal/typesinternal"
)
//go:embed splitpkg.html.tmpl
var htmlTmpl string
// HTML returns the HTML for the main "Split package" page, for the
// /splitpkg endpoint. The real magic happens in JavaScript; see
// ../../server/assets/splitpkg.js.
func HTML(pkgpath metadata.PackagePath) []byte {
t, err := template.New("splitpkg.html").Parse(htmlTmpl)
if err != nil {
log.Fatal(err)
}
data := struct {
Title string
}{
Title: fmt.Sprintf("Split package %s", pkgpath),
}
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
log.Fatal(err)
}
return buf.Bytes()
}
const cacheKind = "splitpkg" // filecache kind
func cacheKey(pkgID metadata.PackageID) [32]byte {
return sha256.Sum256([]byte(pkgID))
}
// UpdateComponentsJSON parses the JSON description of components and
// their assigned declarations and updates the component state for the
// specified package.
func UpdateComponentsJSON(pkgID metadata.PackageID, data []byte) error {
return filecache.Set(cacheKind, cacheKey(pkgID), data)
}
// Web is an abstraction of gopls' web server.
type Web interface {
// SrcURL forms URLs that cause the editor to open a file at a specific position.
SrcURL(filename string, line, col8 int) protocol.URI
}
// JSON returns the JSON encoding of the data needed by
// the /splitpkg-json endpoint for the specified package. It includes:
// - the set of names declared by the package, grouped by file;
// - the set of components and their assigned declarations from
// the most recent call to [UpdateComponentsJSON]; and
// - the component graph derived from them, along with the
// sets of reference that give rise to each edge.
func JSON(pkg *cache.Package, web Web) ([]byte, error) {
// Retrieve package's most recent state from the file cache.
var comp ComponentsJSON
data, err := filecache.Get(cacheKind, cacheKey(pkg.Metadata().ID))
if err != nil {
if err != filecache.ErrNotFound {
return nil, err
}
// cache miss: use zero value
} else if err := json.Unmarshal(data, &comp); err != nil {
return nil, err
}
// Prepare to construct symbol reference graph.
var (
info = pkg.TypesInfo()
symbols = make(map[types.Object]*symbol)
)
// setName records the UI name for an object.
// (The UI name disambiguates "init", "_", etc.)
setName := func(obj types.Object, name string) {
symbols[obj] = &symbol{
name: name,
component: comp.Assignments[name], // missing => "default"
}
}
// Pass 1: name everything, since naming is order-dependent.
var initCounter, blankCounter int
for _, pgf := range pkg.CompiledGoFiles() {
for _, decl := range pgf.File.Decls {
switch decl := decl.(type) {
case *ast.FuncDecl:
if fn, ok := info.Defs[decl.Name].(*types.Func); ok {
// For now we treat methods as first class decls,
// but since they are coupled to the named type
// they should be omitted in the UI for brevity.
name := fn.Name()
if recv := fn.Signature().Recv(); recv != nil {
fn = fn.Origin()
_, named := typesinternal.ReceiverNamed(recv)
name = named.Obj().Name() + "." + name
} else if name == "init" {
// Disambiguate top-level init functions.
name += suffix(&initCounter)
}
if name == "_" { // (function or method)
name += suffix(&blankCounter)
}
setName(fn, name)
}
case *ast.GenDecl:
switch decl.Tok {
case token.CONST, token.VAR:
for _, spec := range decl.Specs {
spec := spec.(*ast.ValueSpec)
for _, id := range spec.Names {
if obj := info.Defs[id]; obj != nil {
name := obj.Name()
if name == "_" {
name += suffix(&blankCounter)
}
setName(obj, name)
}
}
}
case token.TYPE:
for _, spec := range decl.Specs {
spec := spec.(*ast.TypeSpec)
if obj := info.Defs[spec.Name]; obj != nil {
name := obj.Name()
if name == "_" {
name += suffix(&blankCounter)
}
setName(obj, name)
}
}
}
}
}
}
// Pass 2: compute symbol reference graph, project onto
// component dependency graph, and build JSON response.
var (
files []*fileJSON
refs []*refJSON
)
for _, pgf := range pkg.CompiledGoFiles() {
identURL := func(id *ast.Ident) string {
posn := safetoken.Position(pgf.Tok, id.Pos())
return web.SrcURL(posn.Filename, posn.Line, posn.Column)
}
newCollector := func(from *symbol) *refCollector {
return &refCollector{
from: from,
identURL: identURL,
pkg: pkg.Types(),
info: info,
symbols: symbols,
}
}
var decls []*declJSON
for _, decl := range pgf.File.Decls {
var (
kind string
specs []*specJSON
)
switch decl := decl.(type) {
case *ast.FuncDecl:
kind = "func"
if fn, ok := info.Defs[decl.Name].(*types.Func); ok {
symbol := symbols[fn]
rc := newCollector(symbol).collect(decl)
refs = append(refs, rc.refs...)
specs = append(specs, &specJSON{
Name: symbol.name,
URL: identURL(decl.Name),
})
}
case *ast.GenDecl:
kind = decl.Tok.String()
switch decl.Tok {
case token.CONST, token.VAR:
for _, spec := range decl.Specs {
spec := spec.(*ast.ValueSpec)
for i, id := range spec.Names {
if obj := info.Defs[id]; obj != nil {
symbol := symbols[obj]
rc := newCollector(symbol)
// If there's a type,
// all RHSs depend on it.
if spec.Type != nil {
rc.collect(spec.Type)
}
switch len(spec.Values) {
case len(spec.Names):
// var x, y = a, b
rc.collect(spec.Values[i])
case 1:
// var x, y = f()
rc.collect(spec.Values[0])
case 0:
// var x T
}
refs = append(refs, rc.refs...)
specs = append(specs, &specJSON{
Name: symbol.name,
URL: identURL(id),
})
}
}
}
case token.TYPE:
for _, spec := range decl.Specs {
spec := spec.(*ast.TypeSpec)
if obj := info.Defs[spec.Name]; obj != nil {
symbol := symbols[obj]
rc := newCollector(symbol).collect(spec.Type)
refs = append(refs, rc.refs...)
specs = append(specs, &specJSON{
Name: symbol.name,
URL: identURL(spec.Name),
})
}
}
}
}
if len(specs) > 0 {
decls = append(decls, &declJSON{Kind: kind, Specs: specs})
}
}
files = append(files, &fileJSON{
Base: pgf.URI.Base(),
URL: web.SrcURL(pgf.URI.Path(), 1, 1),
Decls: decls,
})
}
// Compute the graph of dependencies between components, by
// projecting the symbol dependency graph through component
// assignments.
var (
g = make(graph)
edgeRefs = make(map[[2]int][]*refJSON) // refs that induce each intercomponent edge
)
for _, ref := range refs {
from, to := ref.from, ref.to
if from.component != to.component {
// inter-component reference
m, ok := g[from.component]
if !ok {
m = make(map[int]bool)
g[from.component] = m
}
m[to.component] = true
key := [2]int{from.component, to.component}
edgeRefs[key] = append(edgeRefs[key], ref)
}
}
// Detect cycles in the component graph
// and record cyclic (⚠) components.
cycles := [][]int{} // non-nil for JSON
scmap := make(map[int]int) // maps component index to 1 + SCC index (0 => acyclic)
for i, scc := range sccs(g) {
for c := range scc {
scmap[c] = i + 1
}
cycles = append(cycles, moremaps.KeySlice(scc))
}
// Record intercomponent edges and their references.
edges := []*edgeJSON{} // non-nil for JSON
for edge, refs := range edgeRefs {
from, to := edge[0], edge[1]
edges = append(edges, &edgeJSON{
From: from,
To: to,
Refs: refs,
Cyclic: scmap[from] > 0 && scmap[from] == scmap[to],
})
}
return json.Marshal(ResultJSON{
Files: files,
Components: comp,
Edges: edges,
Cycles: cycles,
})
}
// A refCollector gathers intra-package references to top-level
// symbols from within one syntax tree, in lexical order.
type refCollector struct {
from *symbol
identURL func(*ast.Ident) string
pkg *types.Package
info *types.Info
index map[types.Object]*refJSON
symbols map[types.Object]*symbol
refs []*refJSON // output
}
// A symbol describes a declared name and its assigned component.
type symbol struct {
name string // unique name in the UI and JSON/HTTP protocol
component int // index of assigned component
}
// collect adds the free references of n to the collection.
func (rc *refCollector) collect(n ast.Node) *refCollector {
var f func(n ast.Node) bool
f = func(n ast.Node) bool {
switch n := n.(type) {
case *ast.SelectorExpr:
if sel, ok := rc.info.Selections[n]; ok {
rc.addRef(n.Sel, sel.Obj())
ast.Inspect(n.X, f)
return false // don't visit n.Sel
}
case *ast.Ident:
if obj := rc.info.Uses[n]; obj != nil {
rc.addRef(n, obj)
}
}
return true
}
ast.Inspect(n, f)
return rc
}
// addRef records a reference from id to obj.
func (rc *refCollector) addRef(id *ast.Ident, obj types.Object) {
if obj.Pkg() != rc.pkg {
return // cross-package reference
}
// Un-instantiate methods.
if fn, ok := obj.(*types.Func); ok && fn.Signature().Recv() != nil {
obj = fn.Origin() // G[int].method -> G[T].method
}
// We only care about refs to package-level symbols.
// And methods, for now.
decl := rc.symbols[obj]
if decl == nil {
return // not a package-level symbol or top-level method
}
if ref, ok := rc.index[obj]; !ok {
ref = &refJSON{
From: rc.from.name,
To: decl.name,
URL: rc.identURL(id),
from: rc.from,
to: decl,
}
if rc.index == nil {
rc.index = make(map[types.Object]*refJSON)
}
rc.index[obj] = ref
rc.refs = append(rc.refs, ref)
}
}
// suffix returns a subscripted decimal suffix,
// preincrementing the specified counter.
func suffix(counter *int) string {
*counter++
n := *counter
return subscripter.Replace(strconv.Itoa(n))
}
var subscripter = strings.NewReplacer(
"0", "₀",
"1", "₁",
"2", "₂",
"3", "₃",
"4", "₄",
"5", "₅",
"6", "₆",
"7", "₇",
"8", "₈",
"9", "₉",
)
// -- JSON types --
// ResultJSON describes the result of a /splitpkg-json query.
// It is public for testing.
type ResultJSON struct {
Components ComponentsJSON // component names and their assigned declarations
Files []*fileJSON // files of the packages and their declarations and references
Edges []*edgeJSON // inter-component edges and their references
Cycles [][]int // sets of strongly-connected components
}
// request body of a /splitpkg-components update;
// also part of /splitpkg-json response.
type ComponentsJSON struct {
Names []string `json:",omitempty"` // if empty, implied Names[0]=="default".
Assignments map[string]int `json:",omitempty"` // maps specJSON.Name to component index; missing => 0
}
// edgeJSON describes an inter-component dependency.
type edgeJSON struct {
From, To int // component IDs
Refs []*refJSON // references that give rise to this edge
Cyclic bool // edge is part of nontrivial strongly connected component
}
// fileJSON records groups decl/spec information about a single file.
type fileJSON struct {
Base string // file base name
URL string // showDocument link for file
Decls []*declJSON `json:",omitempty"`
}
// declJSON groups specs (e.g. "var ( x int; y int )").
type declJSON struct {
Kind string // const, var, type, func
Specs []*specJSON `json:",omitempty"`
}
// specJSON describes a single declared name.
// (A coupled declaration "var x, y = f()" results in two specJSONs.)
type specJSON struct {
Name string // x or T.x
URL string // showDocument link for declaring identifier
}
// refJSON records the first reference from a given declaration to a symbol.
// (Repeat uses of the same identifier are omitted.)
type refJSON struct {
From, To string // x or T.x of referenced spec
URL string // showDocument link for referring identifier
from, to *symbol // transient
}