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
|
||
}
|