package projectindex import ( "context" "strings" ) // contextRadius 是 LineMatch.Context 中匹配行每侧的 // 显示行数。 const contextRadius = 2 // LineMatch 是章节内容中的一条匹配行及其 // 周边上下文。LineNumber 在章节的 Content 中以 1 为基。 type LineMatch struct { LineNumber int Text string Context string } // ChapterHit 是内容命中关键词的一个章节,附带定位到的 // 匹配行(及其上下文)。 type ChapterHit struct { Chapter *Chapter Matches []LineMatch } // SearchResult 是一次 Search 的分层结果。Project 携带命中的 // 项目索引条目 + 描述(第 1/2 层),仅当关键词命中 // 项目标题或 desc 时设置;Chapters 携带命中的章节 // (第 3 层)及定位到的行。无任何命中时,Project 为 nil, // Chapters 为空,且不报错。 type SearchResult struct { Project *Project Chapters []ChapterHit } // Search 在单个项目内查找关键词,沿层级 // 项目索引 → 项目描述 → 章节内容 遍历。它先加载项目(不存在时返回 // ErrProjectNotFound),当关键词命中 // 项目标题或描述时设置 SearchResult.Project,然后扫描每个章节找内容命中, // 产出定位到的行及其上下文。关键词为空返回 // ErrMissingKeyword;未命中任何内容的关键词返回非 nil 的 SearchResult, // 其中 Project 与 Chapters 不设置。 func (s *Store) Search(ctx context.Context, projectID, keyword string) (*SearchResult, error) { kw := strings.ToLower(strings.TrimSpace(keyword)) if kw == "" { return nil, ErrMissingKeyword } proj, err := s.GetProject(ctx, projectID) if err != nil { return nil, err } result := &SearchResult{} if containsFold(proj.Title, kw) || containsFold(proj.Desc, kw) { result.Project = proj } chapters, err := s.ListChapters(ctx, projectID) if err != nil { return nil, err } for i := range chapters { matches := findLineMatches(chapters[i].Content, kw) if len(matches) == 0 { continue } ch := chapters[i] result.Chapters = append(result.Chapters, ChapterHit{Chapter: &ch, Matches: matches}) } return result, nil } // containsFold 报告 s 是否(不区分大小写地)包含 sub, // sub 必须已由调用方转为小写。 func containsFold(s, sub string) bool { return strings.Contains(strings.ToLower(s), sub) } // findLineMatches 为每行(不区分大小写地)包含 keyword 的内容返回一个 // LineMatch。Context 是每侧 contextRadius 行的周边上下文, // 以换行连接,并包含匹配行本身。 func findLineMatches(content, keyword string) []LineMatch { lines := strings.Split(content, "\n") matches := make([]LineMatch, 0) for i, line := range lines { if strings.Contains(strings.ToLower(line), keyword) { matches = append(matches, LineMatch{ LineNumber: i + 1, Text: line, Context: buildContext(lines, i), }) } } return matches } // buildContext 返回 idx 周围(含 idx)的 lines[start..end],以 \n 连接, // 并夹到切片边界内,因此上下文绝不会越界。 func buildContext(lines []string, idx int) string { start := idx - contextRadius if start < 0 { start = 0 } end := idx + contextRadius if end >= len(lines) { end = len(lines) - 1 } return strings.Join(lines[start:end+1], "\n") }