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