初始化

This commit is contained in:
李琦
2026-08-11 19:07:05 +08:00
commit d2aeb13a09
94 changed files with 8704 additions and 0 deletions

198
service/scanner.go Normal file
View File

@@ -0,0 +1,198 @@
// 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
}