初始化

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

321
service/git.go Normal file
View File

@@ -0,0 +1,321 @@
package service
import (
"context"
"errors"
"os/exec"
"sort"
"strconv"
"strings"
"time"
"view/model"
"view/platform"
)
// GitError 使用稳定错误码作为 Error() 返回值,同时保留命令输出细节。
// 这样旧调用方仍能按错误码判断,诊断接口也能把真实原因展示给用户。
type GitError struct {
Code string
Detail string
}
func (e *GitError) Error() string { return e.Code }
// GitErrorDetail 提取 GitError 的详细原因,用于中文日志或诊断面板。
func GitErrorDetail(err error) string {
var ge *GitError
if errors.As(err, &ge) {
return ge.Detail
}
if err != nil {
return err.Error()
}
return ""
}
// GitService 根据项目路径自动选择 Windows Git 或 WSL 内 Git。
// 所有命令都经过 platform.RunHidden确保分析时不会弹出 CMD/PowerShell/WSL 窗口。
type GitService struct{}
func (g GitService) run(ctx context.Context, dir string, args ...string) (string, error) {
desc, e := platform.ResolvePath(dir)
if e != nil {
return "", e
}
if desc.WSL {
// WSL 的裸 "-- git ..." 在部分环境会交给默认 shell 解析,
// Git format 参数中的 "%(...)" 会被 bash 当成语法。--exec 可直接执行 git。
base := []string{"-d", desc.Distro, "--exec", "git", "-C", desc.LinuxPath}
out, e := platform.RunHidden(ctx, "wsl.exe", append(base, args...)...)
if e != nil {
return "", mapGitError(desc, e)
}
return out, nil
}
if _, e = exec.LookPath("git"); e != nil {
return "", &GitError{Code: "GIT_NOT_INSTALLED", Detail: e.Error()}
}
out, e := platform.RunHidden(ctx, "git", append([]string{"-C", desc.WindowsPath}, args...)...)
if e != nil {
return "", mapGitError(desc, e)
}
return out, nil
}
func mapGitError(desc platform.PathDescriptor, err error) error {
detail := strings.TrimSpace(err.Error())
lower := strings.ToLower(detail)
code := "GIT_COMMAND_FAILED"
switch {
case strings.Contains(lower, "not a git repository"):
code = "NOT_GIT_REPOSITORY"
case strings.Contains(lower, "detected dubious ownership"):
code = "GIT_SAFE_DIRECTORY"
case strings.Contains(lower, "permission denied") || strings.Contains(lower, "could not open"):
code = "GIT_PERMISSION_DENIED"
case strings.Contains(lower, "executable file not found") || strings.Contains(lower, "not recognized"):
if desc.WSL {
code = "WSL_NOT_INSTALLED"
} else {
code = "GIT_NOT_INSTALLED"
}
case strings.Contains(lower, "git: not found") || strings.Contains(lower, "git: command not found"):
code = "WSL_GIT_NOT_INSTALLED"
case strings.Contains(lower, "there is no distribution") || strings.Contains(lower, "specified distribution") || strings.Contains(lower, "wsl_e_distro_not_found"):
code = "WSL_DISTRO_NOT_FOUND"
case strings.Contains(lower, "cannot access") && desc.WSL:
code = "WSL_PATH_UNREADABLE"
case strings.Contains(lower, "unknown revision") || strings.Contains(lower, "bad revision"):
code = "GIT_REF_NOT_FOUND"
}
return &GitError{Code: code, Detail: detail}
}
// Diagnostics 分步探测 Git 状态,供详情页解释“为什么 Git 统计是 0”。
func (g GitService) Diagnostics(ctx context.Context, dir string, ref string) model.GitDiagnostics {
desc, e := platform.ResolvePath(dir)
d := model.GitDiagnostics{}
if e != nil {
d.ErrorCode = e.Error()
d.Detail = e.Error()
return d
}
d.IsWSL, d.Distro, d.LinuxPath = desc.WSL, desc.Distro, desc.LinuxPath
if _, e = g.run(ctx, dir, "rev-parse", "--git-dir"); e != nil {
d.ErrorCode = e.Error()
d.Detail = GitErrorDetail(e)
return d
}
branch, _ := g.run(ctx, dir, "branch", "--show-current")
refs, e := g.run(ctx, dir, "for-each-ref", "--format=%(refname:short)%x1f%(objectname)%x1f%(refname)", "refs/heads", "refs/remotes")
if e != nil {
d.ErrorCode = e.Error()
d.Detail = GitErrorDetail(e)
return d
}
if ref == "" {
ref = strings.TrimSpace(branch)
}
refCount := 0
for _, line := range strings.Split(refs, "\n") {
if strings.TrimSpace(line) != "" {
refCount++
}
}
d.Available = true
d.WorkspaceBranch = strings.TrimSpace(branch)
d.ViewRef = strings.TrimSpace(ref)
d.RefCount = refCount
return d
}
// Analyze 分析工作区当前分支。
func (g GitService) Analyze(ctx context.Context, dir string) (model.GitStats, error) {
return g.AnalyzeRef(ctx, dir, "")
}
// AnalyzeRef 只切换统计视图,不修改用户工作区。
func (g GitService) AnalyzeRef(ctx context.Context, dir, ref string) (model.GitStats, error) {
if _, e := g.run(ctx, dir, "rev-parse", "--git-dir"); e != nil {
return model.GitStats{Available: false, Error: e.Error()}, e
}
branch, _ := g.run(ctx, dir, "branch", "--show-current")
branch = strings.TrimSpace(branch)
if ref == "" {
ref = branch
}
out := model.GitStats{Available: true, CurrentBranch: branch, WorkspaceBranch: branch, ViewRef: ref, Commits: []model.GitCommit{}, Refs: []model.GitRef{}, Contributors: []model.Contributor{}, Heatmap: []model.HeatDay{}}
refs, e := g.run(ctx, dir, "for-each-ref", "--format=%(refname:short)%x1f%(objectname)%x1f%(refname)", "refs/heads", "refs/remotes")
if e != nil {
out.Available = false
out.Error = e.Error()
return out, e
}
for _, line := range strings.Split(refs, "\n") {
p := strings.Split(line, "\x1f")
if len(p) != 3 {
p = strings.Split(line, "%x1f")
}
if len(p) != 3 {
continue
}
kind := "local"
if strings.HasPrefix(p[2], "refs/remotes/") {
kind = "remote"
}
out.Refs = append(out.Refs, model.GitRef{Name: p[0], Hash: ShortHash(p[1]), Kind: kind, Current: p[0] == branch})
}
args := []string{"log", "--use-mailmap", "--date=iso-strict", "--pretty=format:@@CC@@%H%x1f%aN%x1f%aE%x1f%aI%x1f%s", "--numstat"}
if strings.TrimSpace(ref) != "" {
args = append(args, ref)
}
log, e := g.run(ctx, dir, args...)
if e != nil {
out.Available = false
out.Error = e.Error()
return out, e
}
parseLog(log, &out)
return out, nil
}
// CommitDetail 按需读取单次提交和逐文件增删行,避免 Git 首页一次传输过多数据。
func (g GitService) CommitDetail(ctx context.Context, dir, hash string) (model.GitCommitDetail, error) {
meta, e := g.run(ctx, dir, "show", "-s", "--use-mailmap", "--date=iso-strict", "--format=%H%x1f%aN%x1f%aE%x1f%aI%x1f%s", hash)
if e != nil {
return model.GitCommitDetail{}, e
}
p := strings.Split(meta, "\x1f")
if len(p) != 5 {
return model.GitCommitDetail{}, &GitError{Code: "GIT_COMMIT_PARSE_FAILED", Detail: meta}
}
d := model.GitCommitDetail{GitCommit: model.GitCommit{Hash: p[0], Author: p[1], Email: strings.ToLower(p[2]), Date: p[3], Message: p[4]}, Files: []model.GitFileChange{}}
nums, e := g.run(ctx, dir, "show", "--format=", "--numstat", hash)
if e != nil {
return d, e
}
for _, line := range strings.Split(nums, "\n") {
x := strings.Split(line, "\t")
if len(x) < 3 {
continue
}
a, ea := strconv.ParseInt(x[0], 10, 64)
del, ed := strconv.ParseInt(x[1], 10, 64)
if ea != nil {
a = 0
}
if ed != nil {
del = 0
}
status := "modified"
if a > 0 && del == 0 {
status = "added"
}
if del > 0 && a == 0 {
status = "deleted"
}
d.Files = append(d.Files, model.GitFileChange{Path: x[2], Status: status, Added: a, Deleted: del})
d.Added += a
d.Deleted += del
}
return d, nil
}
// CheckoutBranch 在修改工作区前先检查已跟踪和未跟踪变更;发现任何内容都拒绝切换。
func (g GitService) CheckoutBranch(ctx context.Context, dir, ref string) (model.CheckoutResult, error) {
dirty, e := g.run(ctx, dir, "status", "--porcelain")
if e != nil {
return model.CheckoutResult{}, e
}
if strings.TrimSpace(dirty) != "" {
return model.CheckoutResult{}, &GitError{Code: "GIT_WORKTREE_DIRTY", Detail: dirty}
}
kind, _ := g.run(ctx, dir, "for-each-ref", "--format=%(refname)", "refs/remotes/"+ref)
if strings.TrimSpace(kind) != "" {
local := ref
if i := strings.Index(ref, "/"); i >= 0 {
local = ref[i+1:]
}
if _, x := g.run(ctx, dir, "show-ref", "--verify", "--quiet", "refs/heads/"+local); x == nil {
_, e = g.run(ctx, dir, "switch", local)
} else {
_, e = g.run(ctx, dir, "switch", "--track", ref)
}
} else {
_, e = g.run(ctx, dir, "switch", ref)
}
if e != nil {
return model.CheckoutResult{}, e
}
branch, _ := g.run(ctx, dir, "branch", "--show-current")
return model.CheckoutResult{Branch: strings.TrimSpace(branch), Message: "分支切换成功"}, nil
}
func parseLog(log string, out *model.GitStats) {
var cur *model.GitCommit
for _, line := range strings.Split(log, "\n") {
if strings.HasPrefix(line, "@@CC@@") {
if cur != nil {
out.Commits = append(out.Commits, *cur)
}
p := strings.Split(strings.TrimPrefix(line, "@@CC@@"), "\x1f")
if len(p) == 5 {
cur = &model.GitCommit{Hash: p[0], Author: p[1], Email: strings.ToLower(p[2]), Date: p[3], Message: p[4]}
} else {
cur = nil
}
continue
}
if cur != nil {
p := strings.Split(line, "\t")
if len(p) >= 2 {
a, e1 := strconv.ParseInt(p[0], 10, 64)
d, e2 := strconv.ParseInt(p[1], 10, 64)
if e1 == nil {
cur.Added += a
}
if e2 == nil {
cur.Deleted += d
}
}
}
}
if cur != nil {
out.Commits = append(out.Commits, *cur)
}
cm := map[string]*model.Contributor{}
hm := map[string]int64{}
cut := time.Now().AddDate(-1, 0, 0)
for _, c := range out.Commits {
out.Added += c.Added
out.Deleted += c.Deleted
x := cm[c.Email]
if x == nil {
x = &model.Contributor{Name: c.Author, Email: c.Email}
cm[c.Email] = x
}
x.Commits++
x.Added += c.Added
x.Deleted += c.Deleted
if t, e := time.Parse(time.RFC3339, c.Date); e == nil && t.After(cut) {
hm[t.Local().Format("2006-01-02")]++
}
}
out.CommitCount = int64(len(out.Commits))
for _, x := range cm {
out.Contributors = append(out.Contributors, *x)
}
out.ContributorCount = int64(len(out.Contributors))
sort.Slice(out.Contributors, func(i, j int) bool { return out.Contributors[i].Commits > out.Contributors[j].Commits })
for d, c := range hm {
out.Heatmap = append(out.Heatmap, model.HeatDay{Date: d, Count: c})
}
sort.Slice(out.Heatmap, func(i, j int) bool { return out.Heatmap[i].Date < out.Heatmap[j].Date })
}
func ShortHash(s string) string {
if len(s) > 7 {
return s[:7]
}
return s
}

201
service/insights.go Normal file
View File

@@ -0,0 +1,201 @@
package service
import (
"bufio"
"context"
"os"
"path/filepath"
"sort"
"strings"
"time"
"view/model"
"view/platform"
)
const (
insightMaxFileSize = 1024 * 1024
insightMaxTotalRead = 20 * 1024 * 1024
)
// InsightService 基于既有统计快照做维护性检查。
// 它不会重新遍历整个仓库,只读取已记录的小型源码文件,用于发现 TODO、长文件等轻量风险。
type InsightService struct{}
func (InsightService) Analyze(ctx context.Context, project model.Project, structure model.StructureStats, git model.GitStats) (model.ProjectInsights, error) {
desc, e := platform.ResolvePath(project.Path)
if e != nil {
return model.ProjectInsights{}, e
}
out := model.ProjectInsights{ProjectID: project.ID, GeneratedAt: time.Now().Format(time.RFC3339), Issues: []model.InsightIssue{}}
add := func(issue model.InsightIssue) {
out.Issues = append(out.Issues, issue)
switch issue.Severity {
case "high":
out.Summary.High++
case "medium":
out.Summary.Medium++
default:
out.Summary.Low++
}
}
checkLanguageHealth(project.Languages, add)
out.Summary.LargeFiles = len(structure.LargeFiles)
checkStructureHealth(structure, add)
checkGitHealth(git, add)
if e = scanTextMarkers(ctx, desc.WindowsPath, structure.Files, add, &out.Summary); e != nil {
add(model.InsightIssue{Severity: "medium", Type: "scan_error", Title: "源码检查未完全完成", Detail: e.Error(), Suggestion: "检查项目目录权限,或重新执行项目统计。"})
}
sort.SliceStable(out.Issues, func(i, j int) bool {
return severityRank(out.Issues[i].Severity) > severityRank(out.Issues[j].Severity)
})
out.HealthScore = healthScore(out.Summary)
return out, nil
}
func checkLanguageHealth(langs []model.LanguageStat, add func(model.InsightIssue)) {
for _, x := range langs {
total := x.Code + x.Comments
if x.Code < 500 || x.Files < 3 || total == 0 {
continue
}
ratio := float64(x.Comments) / float64(total)
if ratio < 0.03 {
add(model.InsightIssue{Severity: "medium", Type: "low_comment_ratio", Title: "注释率偏低", Detail: x.Name + " 的注释占比低于 3%。", Suggestion: "为核心业务、复杂条件和公共接口补充解释性注释。"})
} else if ratio < 0.06 {
add(model.InsightIssue{Severity: "low", Type: "low_comment_ratio", Title: "注释率略低", Detail: x.Name + " 的注释占比低于 6%。", Suggestion: "优先补充复杂模块和团队协作频繁的文件。"})
}
}
}
func checkStructureHealth(s model.StructureStats, add func(model.InsightIssue)) {
for _, f := range s.LargeFiles {
severity := "medium"
if f.Size >= 5*1024*1024 {
severity = "high"
}
add(model.InsightIssue{Severity: severity, Type: "large_file", Title: "发现大文件", Detail: "文件体积可能影响仓库加载和代码审查。", Path: f.Path, Suggestion: "确认是否可以拆分、压缩或移动到对象存储。"})
}
if len(s.Folders) > 0 && s.TotalSize > 0 {
top := s.Folders[0]
if float64(top.Size)/float64(s.TotalSize) > 0.65 && top.Files > 20 {
add(model.InsightIssue{Severity: "medium", Type: "folder_concentration", Title: "目录体量过于集中", Detail: top.Name + " 占据了项目大部分体积。", Path: top.Name, Suggestion: "检查该目录是否混入构建产物、缓存或可拆分模块。"})
}
}
unknown := 0
for _, x := range s.Extensions {
if x.Extension == "" {
unknown += int(x.Files)
}
}
if unknown > 20 {
add(model.InsightIssue{Severity: "low", Type: "unknown_files", Title: "无扩展名文件较多", Detail: "项目中存在较多无扩展名文件。", Suggestion: "确认这些文件是否都是必要脚本、配置或运行产物。"})
}
}
func checkGitHealth(g model.GitStats, add func(model.InsightIssue)) {
if g.CommitCount == 0 || len(g.Contributors) == 0 {
return
}
top := g.Contributors[0]
if float64(top.Commits)/float64(g.CommitCount) > 0.75 && g.CommitCount > 20 {
add(model.InsightIssue{Severity: "medium", Type: "contributor_concentration", Title: "提交集中度较高", Detail: top.Name + " 贡献了超过 75% 的提交。", Suggestion: "关注知识分散、代码评审和关键模块交接风险。"})
}
for _, c := range g.Commits[:min(30, len(g.Commits))] {
if c.Added+c.Deleted > 3000 {
add(model.InsightIssue{Severity: "low", Type: "large_commit", Title: "近期存在大提交", Detail: c.Message, Path: c.Hash, Suggestion: "大提交建议拆分审查,降低回滚和定位问题的成本。"})
}
}
}
func scanTextMarkers(ctx context.Context, root string, files []model.FileEntry, add func(model.InsightIssue), summary *model.InsightSummary) error {
totalRead := int64(0)
for _, f := range files {
if f.IsDir || f.Size <= 0 || f.Size > insightMaxFileSize || !isInsightTextFile(f.Extension) {
continue
}
if totalRead+f.Size > insightMaxTotalRead {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
totalRead += f.Size
path := filepath.Join(root, filepath.FromSlash(f.Path))
lines, markers := inspectTextFile(path)
if lines >= 1500 {
summary.LongFiles++
add(model.InsightIssue{Severity: "high", Type: "long_file", Title: "文件过长", Detail: "文件超过 1500 行,理解和评审成本较高。", Path: f.Path, Suggestion: "按职责拆分模块,提取复用函数或服务。"})
} else if lines >= 800 {
summary.LongFiles++
add(model.InsightIssue{Severity: "medium", Type: "long_file", Title: "文件偏长", Detail: "文件超过 800 行。", Path: f.Path, Suggestion: "关注是否存在多个职责混在同一文件中。"})
}
for _, issue := range markers {
summary.TodoCount++
issue.Path = f.Path
add(issue)
}
}
return nil
}
func inspectTextFile(path string) (int, []model.InsightIssue) {
file, e := os.Open(path)
if e != nil {
return 0, nil
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
line := 0
issues := []model.InsightIssue{}
for scanner.Scan() {
line++
text := scanner.Text()
upper := strings.ToUpper(text)
kind, severity := "", "low"
switch {
case strings.Contains(upper, "FIXME"):
kind, severity = "FIXME", "medium"
case strings.Contains(upper, "HACK"):
kind, severity = "HACK", "medium"
case strings.Contains(upper, "TODO"):
kind = "TODO"
}
if kind != "" {
issues = append(issues, model.InsightIssue{Severity: severity, Type: "todo_marker", Title: "发现 " + kind + " 标记", Detail: "源码中存在待处理标记。", Line: line, Suggestion: "确认该标记是否仍有效,并补充负责人或处理计划。", Evidence: strings.TrimSpace(text)})
}
}
return line, issues
}
func isInsightTextFile(ext string) bool {
switch strings.ToLower(ext) {
case ".go", ".php", ".js", ".ts", ".vue", ".jsx", ".tsx", ".css", ".scss", ".sass", ".less", ".html", ".md", ".json", ".yaml", ".yml", ".xml", ".sql", ".py", ".java", ".cs", ".rb", ".rs", ".c", ".cpp", ".h":
return true
default:
return false
}
}
func healthScore(s model.InsightSummary) int {
score := 100 - s.High*12 - s.Medium*6 - s.Low*2
if score < 0 {
return 0
}
return score
}
func severityRank(s string) int {
switch s {
case "high":
return 3
case "medium":
return 2
default:
return 1
}
}

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
}