199 lines
5.5 KiB
Go
199 lines
5.5 KiB
Go
|
|
// Package service 实现代码扫描、Git 分析和后台任务等业务能力。
|
|||
|
|
package service
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"errors"
|
|||
|
|
"fmt"
|
|||
|
|
"github.com/hhatto/gocloc"
|
|||
|
|
gitignore "github.com/sabhiram/go-gitignore"
|
|||
|
|
"io/fs"
|
|||
|
|
"os"
|
|||
|
|
"path/filepath"
|
|||
|
|
"sort"
|
|||
|
|
"strings"
|
|||
|
|
"view/model"
|
|||
|
|
"view/platform"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Scanner 负责遍历项目、应用排除规则并调用 gocloc 统计代码。它不依赖 Wails,可独立测试。
|
|||
|
|
type Scanner struct{}
|
|||
|
|
|
|||
|
|
// MatchExcludedPattern 对目录的每一级名称应用通配符,并对带斜杠的规则匹配完整相对路径。
|
|||
|
|
func MatchExcludedPattern(pattern, rel string) bool {
|
|||
|
|
pattern = filepath.ToSlash(strings.TrimSpace(pattern))
|
|||
|
|
rel = filepath.ToSlash(rel)
|
|||
|
|
if strings.Contains(pattern, "/") {
|
|||
|
|
ok, _ := filepath.Match(filepath.FromSlash(pattern), filepath.FromSlash(rel))
|
|||
|
|
return ok
|
|||
|
|
}
|
|||
|
|
for _, part := range strings.Split(rel, "/") {
|
|||
|
|
if ok, _ := filepath.Match(pattern, part); ok {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Analyze 同时支持普通 Windows 路径与 WSL UNC 路径。WSL 文件通过 UNC 读取,Git 则由 GitService 在发行版内部执行。
|
|||
|
|
func (Scanner) Analyze(ctx context.Context, root string, rules []model.ExclusionRule, progress func(int, string)) ([]model.LanguageStat, []model.FileEntry, error) {
|
|||
|
|
desc, e := platform.ResolvePath(root)
|
|||
|
|
if e != nil {
|
|||
|
|
return nil, nil, e
|
|||
|
|
}
|
|||
|
|
root = desc.WindowsPath
|
|||
|
|
// WalkDir 对根目录回调错误若被忽略会得到“0 文件但成功”的假结果,因此分析前必须验证根目录。
|
|||
|
|
if info, statErr := os.Stat(root); statErr != nil {
|
|||
|
|
if desc.WSL {
|
|||
|
|
return nil, nil, &platform.PlatformError{Code: "WSL_PATH_UNAVAILABLE", Detail: statErr.Error()}
|
|||
|
|
}
|
|||
|
|
return nil, nil, statErr
|
|||
|
|
} else if !info.IsDir() {
|
|||
|
|
return nil, nil, errors.New("PROJECT_PATH_NOT_DIRECTORY")
|
|||
|
|
}
|
|||
|
|
// 每份 .gitignore 只控制其所在目录及后代。不能预先收集后再全局匹配,
|
|||
|
|
// 否则 vendor 等依赖目录中的局部通配符会错误排除项目根目录的源码。
|
|||
|
|
type scopedIgnore struct {
|
|||
|
|
base string
|
|||
|
|
matcher *gitignore.GitIgnore
|
|||
|
|
}
|
|||
|
|
ignores := []scopedIgnore{}
|
|||
|
|
addIgnore := func(dir, base string) {
|
|||
|
|
matcher, compileErr := gitignore.CompileIgnoreFile(filepath.Join(dir, ".gitignore"))
|
|||
|
|
if compileErr == nil {
|
|||
|
|
ignores = append(ignores, scopedIgnore{base: filepath.ToSlash(base), matcher: matcher})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
excluded := func(rel string) bool {
|
|||
|
|
slash := filepath.ToSlash(rel)
|
|||
|
|
for _, r := range rules {
|
|||
|
|
if MatchExcludedPattern(r.Pattern, slash) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for _, scoped := range ignores {
|
|||
|
|
local := slash
|
|||
|
|
if scoped.base != "." && scoped.base != "" {
|
|||
|
|
prefix := strings.TrimSuffix(scoped.base, "/") + "/"
|
|||
|
|
if !strings.HasPrefix(slash, prefix) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
local = strings.TrimPrefix(slash, prefix)
|
|||
|
|
}
|
|||
|
|
if scoped.matcher.MatchesPath(local) || scoped.matcher.MatchesPath("/"+local) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
files := []model.FileEntry{}
|
|||
|
|
codeFiles := []string{}
|
|||
|
|
count := 0
|
|||
|
|
e = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|||
|
|
if err != nil {
|
|||
|
|
if path == root {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
select {
|
|||
|
|
case <-ctx.Done():
|
|||
|
|
return ctx.Err()
|
|||
|
|
default:
|
|||
|
|
}
|
|||
|
|
if path == root {
|
|||
|
|
addIgnore(root, ".")
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
rel, x := filepath.Rel(root, path)
|
|||
|
|
if x != nil {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|||
|
|
if d.IsDir() {
|
|||
|
|
return filepath.SkipDir
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
if excluded(rel) {
|
|||
|
|
if d.IsDir() {
|
|||
|
|
return filepath.SkipDir
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
if d.IsDir() {
|
|||
|
|
addIgnore(path, rel)
|
|||
|
|
}
|
|||
|
|
info, x := d.Info()
|
|||
|
|
if x != nil {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
entry := model.FileEntry{Path: filepath.ToSlash(rel), Name: d.Name(), Extension: strings.ToLower(filepath.Ext(d.Name())), Size: info.Size(), IsDir: d.IsDir(), Parent: filepath.ToSlash(filepath.Dir(rel))}
|
|||
|
|
files = append(files, entry)
|
|||
|
|
if !d.IsDir() {
|
|||
|
|
count++
|
|||
|
|
ext := strings.TrimPrefix(entry.Extension, ".")
|
|||
|
|
if _, ok := gocloc.Exts[ext]; ok {
|
|||
|
|
codeFiles = append(codeFiles, path)
|
|||
|
|
}
|
|||
|
|
if count%250 == 0 {
|
|||
|
|
progress(min(65, 10+count/50), "task.scan")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
})
|
|||
|
|
if e != nil {
|
|||
|
|
return nil, nil, e
|
|||
|
|
}
|
|||
|
|
progress(70, "task.count")
|
|||
|
|
defs := gocloc.NewDefinedLanguages()
|
|||
|
|
opts := gocloc.NewClocOptions()
|
|||
|
|
totals := map[string]*model.LanguageStat{}
|
|||
|
|
for i, path := range codeFiles {
|
|||
|
|
select {
|
|||
|
|
case <-ctx.Done():
|
|||
|
|
return nil, nil, ctx.Err()
|
|||
|
|
default:
|
|||
|
|
}
|
|||
|
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
|
|||
|
|
name := gocloc.Exts[ext]
|
|||
|
|
lang := defs.Langs[name]
|
|||
|
|
if lang == nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
cf, parseErr := analyzeFileSafe(path, lang, opts)
|
|||
|
|
if parseErr != nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
x := totals[name]
|
|||
|
|
if x == nil {
|
|||
|
|
x = &model.LanguageStat{Name: name}
|
|||
|
|
totals[name] = x
|
|||
|
|
}
|
|||
|
|
x.Files++
|
|||
|
|
x.Code += int64(cf.Code)
|
|||
|
|
x.Comments += int64(cf.Comments)
|
|||
|
|
x.Blanks += int64(cf.Blanks)
|
|||
|
|
if i%100 == 0 {
|
|||
|
|
progress(70+min(25, i*25/max(1, len(codeFiles))), "task.count")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
langs := make([]model.LanguageStat, 0, len(totals))
|
|||
|
|
for _, x := range totals {
|
|||
|
|
langs = append(langs, *x)
|
|||
|
|
}
|
|||
|
|
sort.Slice(langs, func(i, j int) bool { return langs[i].Code > langs[j].Code })
|
|||
|
|
progress(98, "task.save")
|
|||
|
|
return langs, files, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// analyzeFileSafe 隔离第三方语言解析器的单文件异常。某个特殊源码不能影响整个桌面进程和其他文件统计。
|
|||
|
|
func analyzeFileSafe(path string, lang *gocloc.Language, opts *gocloc.ClocOptions) (result *gocloc.ClocFile, err error) {
|
|||
|
|
defer func() {
|
|||
|
|
if r := recover(); r != nil {
|
|||
|
|
err = fmt.Errorf("CODE_FILE_PARSE_PANIC: %s: %v", path, r)
|
|||
|
|
}
|
|||
|
|
}()
|
|||
|
|
return gocloc.AnalyzeFile(path, lang, opts), nil
|
|||
|
|
}
|