Files
code-utils/service/scanner.go
2026-08-11 19:07:05 +08:00

199 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}