blob: 5ff1a738c3db6ef5c7a5d1dc8d4b4af74ec07ff2 [file]
// Copyright 2015 Marc-Antoine Ruel. All rights reserved.
// Use of this source code is governed under the Apache License, Version 2.0
// that can be found in the LICENSE file.
package scm
import (
"go/scanner"
"go/token"
"io/ioutil"
"log"
"os"
"path"
"path/filepath"
"sort"
"strings"
"sync"
)
// Change represents a change to test against.
//
// This interface is specialized for Go projects.
type Change interface {
// Repo references back to the repository.
Repo() ReadOnlyRepo
// Package returns the package name to reference Repo().Root(). Returns an
// empty string if the repository is located outside of $GOPATH.
Package() string
// Changed is the directly affected files and packages.
Changed() Set
// Indirect returns the Set of everything affected indirectly, e.g. all
// modified files plus all packages importing a package that was modified by
// this Change. It is useful for example to run all tests that could be
// indirectly impacted by a change. Indirect().GoFiles() ==
// Changed().GoFiles(). Only Packages() and TestPackages() can be longer, up
// to values returned by All()'s.
Indirect() Set
// All returns all the files in the repository.
All() Set
// Content returns the content of a file.
Content(name string) []byte
// IsIgnored returns true if this path is ignored. This is mostly relevant
// when using tools that work at the package level instead of at the file
// level and generated files (like proto-gen-go generated files) should be
// ignored.
IsIgnored(p string) bool
}
// Set is a subset of files/directories/packages relative to the change and the
// overall repository.
type Set interface {
// GoFiles returns all the source files, including tests.
GoFiles() []string
// Packages returns all the packages included in this set, using the relative
// notation, e.g. with prefix "./" relative to the checkout root. So this
// package "scm" would be represented as "./scm".
Packages() []string
// TestPackages returns all the packages included in this set that contain
// tests, using the relative notation, e.g. with prefix "./".
//
// In summary, it is the same result as Packages() but without the ones with
// no test.
TestPackages() []string
}
// Private details.
const pathSeparator = string(os.PathSeparator)
type change struct {
repo ReadOnlyRepo
packageName string
ignorePatterns IgnorePatterns
direct set
indirect set
all set
lock sync.Mutex
content map[string][]byte
}
func newChange(r ReadOnlyRepo, files, allFiles, ignorePatterns IgnorePatterns) *change {
//log.Printf("Change{%s, %s}", files, allFiles)
root := r.Root()
// An error occurs when the repository is not inside GOPATH. Ignore this
// error here.
pkgName, _ := relToGOPATH(root, r.GOPATH())
c := &change{
repo: r,
packageName: pkgName,
ignorePatterns: ignorePatterns,
content: map[string][]byte{},
}
// Map of <relative directory> : <relative package>
testDirs := map[string]string{}
sourceDirs := map[string]string{}
for _, f := range files {
if !strings.HasSuffix(f, ".go") {
continue
}
c.direct.files = append(c.direct.files, f)
dir := dirName(f)
if _, ok := sourceDirs[dir]; !ok {
relPkgName := dirToPkg(dir)
sourceDirs[dir] = relPkgName
c.direct.packages = append(c.direct.packages, relPkgName)
}
if strings.HasSuffix(f, "_test.go") {
if _, ok := testDirs[dir]; !ok {
relPkgName := dirToPkg(dir)
testDirs[dir] = relPkgName
c.direct.testPackages = append(c.direct.testPackages, relPkgName)
}
}
}
// Map of <relative directory> : <all files in this directory>
allDirs := map[string][]string{}
// Set of <relative directory>
allTestDirs := map[string]bool{}
allSourceDirs := map[string]bool{}
// Map of <absolute package name> : <relative directory>
allPkgs := map[string]string{}
for _, f := range allFiles {
if !strings.HasSuffix(f, ".go") {
continue
}
c.all.files = append(c.all.files, f)
dir := dirName(f)
allDirs[dir] = append(allDirs[dir], filepath.Base(f))
if _, ok := allSourceDirs[dir]; !ok {
relPkgName := dirToPkg(dir)
allSourceDirs[dir] = true
c.all.packages = append(c.all.packages, relPkgName)
allPkgs[path.Join(pkgName, strings.Replace(dir, pathSeparator, "/", -1))] = dir
}
if strings.HasSuffix(f, "_test.go") {
if _, ok := allTestDirs[dir]; !ok {
relPkgName := dirToPkg(dir)
allTestDirs[dir] = true
c.all.testPackages = append(c.all.testPackages, relPkgName)
if _, ok = testDirs[dir]; !ok {
if _, ok = sourceDirs[dir]; ok {
// Add test packages where only non-test source files were modified.
testDirs[dir] = relPkgName
c.direct.testPackages = append(c.direct.testPackages, relPkgName)
}
}
}
}
}
// Still need to sort these since "." will not be at the right place.
var wg sync.WaitGroup
wg.Add(4)
go func() {
defer wg.Done()
sort.Strings(c.direct.packages)
}()
go func() {
defer wg.Done()
sort.Strings(c.direct.testPackages)
}()
go func() {
defer wg.Done()
sort.Strings(c.all.packages)
}()
go func() {
defer wg.Done()
sort.Strings(c.all.testPackages)
}()
wg.Wait()
c.indirect.files = c.direct.files
if len(c.direct.packages) == len(c.all.packages) && len(c.direct.testPackages) == len(c.all.testPackages) {
// Everything is affected. Skip processing files.
c.indirect.packages = c.direct.packages
c.indirect.testPackages = c.direct.testPackages
} else {
c.indirect.packages = make([]string, len(c.direct.packages))
copy(c.indirect.packages, c.direct.packages)
c.indirect.testPackages = make([]string, len(c.direct.testPackages))
copy(c.indirect.testPackages, c.direct.testPackages)
// First create a maps of what imports what. Then resolve the DAG. Go spec
// guarantees it's a DAG. Treat tests independently since they are not
// subject to the DAG restriction; tests can't be imported so they do not
// affect other packages indirectly.
// Map <imported relative dir> : <set of relative dirs importing this package>
reverseImports := map[string]map[string]bool{}
reverseTestImports := map[string]map[string]bool{}
// Parallelize but rate limited. The goal is to work around the os.Open()
// file latency, especially on Windows.
parallel := make(chan bool, 16)
for i := 0; i < cap(parallel); i++ {
parallel <- true
}
var wg sync.WaitGroup
for baseDir, files := range allDirs {
if _, ok := sourceDirs[baseDir]; ok {
// Already in indirect.
continue
}
for _, f := range files {
wg.Add(1)
go func(baseDir, f string) {
<-parallel
defer func() {
wg.Done()
parallel <- true
}()
content := c.Content(filepath.Join(baseDir, f))
if content == nil {
return
}
_, localImports := getImports(content)
for _, imp := range localImports {
if importedDir, ok := allPkgs[imp]; ok {
isTest := strings.HasSuffix(f, "_test.go")
c.lock.Lock()
if !isTest {
if reverseImports[importedDir] == nil {
reverseImports[importedDir] = map[string]bool{}
}
reverseImports[importedDir][baseDir] = true
} else {
if reverseTestImports[importedDir] == nil {
reverseTestImports[importedDir] = map[string]bool{}
}
reverseTestImports[importedDir][baseDir] = true
}
c.lock.Unlock()
}
}
}(baseDir, f)
}
}
wg.Wait()
// First resolve imports. Do it iteratively, so it's exponential runtime.
// Reimplement with better algo once the runtime is >5ms.
found := true
for found {
found = false
for dir := range sourceDirs {
for importerDir := range reverseImports[dir] {
if _, ok := sourceDirs[importerDir]; !ok {
relPkgName := dirToPkg(importerDir)
sourceDirs[importerDir] = relPkgName
c.indirect.packages = append(c.indirect.packages, relPkgName)
found = true
// Does it contain tests too?
if _, ok := allTestDirs[importerDir]; ok {
if _, ok = testDirs[importerDir]; !ok {
testDirs[importerDir] = relPkgName
c.indirect.testPackages = append(c.indirect.testPackages, relPkgName)
}
}
}
}
}
}
// Tests export nothing, so no need to do a multi-pass.
for dir := range sourceDirs {
for importerDir := range reverseTestImports[dir] {
if _, ok := testDirs[importerDir]; !ok {
relPkgName := dirToPkg(importerDir)
testDirs[importerDir] = relPkgName
c.indirect.testPackages = append(c.indirect.testPackages, relPkgName)
}
}
}
wg.Add(2)
go func() {
defer wg.Done()
sort.Strings(c.indirect.packages)
}()
go func() {
defer wg.Done()
sort.Strings(c.indirect.testPackages)
}()
wg.Wait()
}
return c
}
func (c *change) Repo() ReadOnlyRepo {
return c.repo
}
func (c *change) Package() string {
return c.packageName
}
func (c *change) Changed() Set {
return &c.direct
}
func (c *change) Indirect() Set {
return &c.indirect
}
func (c *change) All() Set {
return &c.all
}
func (c *change) Content(p string) []byte {
c.lock.Lock()
content, ok := c.content[p]
c.lock.Unlock()
if !ok {
var err error
content, err = ioutil.ReadFile(filepath.Join(c.repo.Root(), p))
if err != nil {
log.Printf("failed to read %s: %s", p, err)
}
c.lock.Lock()
c.content[p] = content
c.lock.Unlock()
}
return content
}
func (c *change) IsIgnored(p string) bool {
return c.ignorePatterns.Match(p)
}
type set struct {
files []string
packages []string
testPackages []string
}
func (s *set) GoFiles() []string {
return s.files
}
func (s *set) Packages() []string {
return s.packages
}
func (s *set) TestPackages() []string {
return s.testPackages
}
func dirToPkg(d string) string {
if d == "." {
return d
}
return "./" + strings.Replace(d, pathSeparator, "/", -1)
}
func dirName(p string) string {
if d := filepath.Dir(p); d != "" {
return d
}
return "."
}
// getPackageName returns the name of the package as defined as a
// "package foo" statement.
func getPackageName(content []byte) string {
var s scanner.Scanner
fset := token.NewFileSet()
file := fset.AddFile("", fset.Base(), len(content))
s.Init(file, content, nil, 0)
for {
_, tok, _ := s.Scan()
if tok == token.EOF {
return ""
}
if tok == token.PACKAGE {
_, tok, lit := s.Scan()
if tok == token.IDENT {
return lit
}
}
}
}
// getImports returns the package name and all imports of a file.
//
// It is similar to go/parser.ParseFile() with mode ImportsOnly. This function
// is an extremely simplified version which doesn't construct an ast.File tree,
// mainly for performance.
//
// Switch back to ParseFile() if bugs are found.
func getImports(content []byte) (string, []string) {
// As per https://golang.org/ref/spec, ignoring comments, package must happen
// first, then import statements (potentially multiple) and only then the
// actual code exists. So processing cuts short after the first non-import
// statement.
var s scanner.Scanner
fset := token.NewFileSet()
file := fset.AddFile("", fset.Base(), len(content))
s.Init(file, content, nil, 0)
pkgName := ""
var imports []string
outer:
for {
_, tok, _ := s.Scan()
switch tok {
case token.ILLEGAL, token.EOF:
// Likely a truncated file.
break outer
case token.PACKAGE:
_, tok, lit := s.Scan()
if tok == token.IDENT {
pkgName = lit
} else {
panic("Temporary")
}
case token.IMPORT:
// Scan all the following lines.
for {
_, tok, lit := s.Scan()
switch tok {
case token.STRING:
imports = append(imports, lit[1:len(lit)-1])
case token.LPAREN, token.IMPORT, token.RPAREN, token.SEMICOLON:
// There can be multiple imports statement, they can have
// parenthesis, semicolon is implicitly added after each line
case token.PERIOD:
// '.' can be used to import everything inline.
case token.IDENT:
// A named package. It can be "_" or anything else.
case token.EOF:
// A file with only import statement but no code.
break outer
default:
// Any other statement breaks the loop.
break outer
}
}
case token.COMMENT:
case token.SEMICOLON:
default:
// This happens if a source file does not import anything. For example
// the file only defines constants or pure algorithm.
break
}
}
return pkgName, imports
}