- 只翻注释/文档字符串:820 增 / 820 删 严格 1:1;剥掉整行注释与行尾注释尾巴后, 逐文件与 HEAD 比对「代码部分差异 = 0」;裁决/design 编号计数逐项与 HEAD 一致 - 保留可执行 SQL 样例、SQLite 报错原文、pgvector/HNSW/ANN/BLOB 等技术词原文 - 复核:go build 绿 + memory/projectindex/vector 三包测试绿;gofmt 仅剩 2 个既有未格式化文件(改动前即如此,未顺手修)
109 lines
3.3 KiB
Go
109 lines
3.3 KiB
Go
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")
|
||
}
|