mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
* feat(references): normalize and render local agent references * feat(show): add reference-aware file and directory viewing * docs: document references config and show usage * chore: remove local working docs from PR branch * fix: address lint issues in reference pipeline * fix: drop stale quiet leftovers after main rebase * fix: clean remaining rebase markers in show tests
544 lines
15 KiB
Go
544 lines
15 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
type ReferenceRenderCfg struct {
|
||
NormalizeAgents []string
|
||
RenderPlatforms []string
|
||
DisplayPath string
|
||
MarkerStyle string
|
||
EnclosureStyle string
|
||
}
|
||
|
||
type placeholderReplacement struct {
|
||
placeholder string
|
||
ref *localReference
|
||
keepText string
|
||
}
|
||
|
||
var (
|
||
reFenceBlock = regexp.MustCompile("(?s)```.*?```")
|
||
reInlineCodeSpan = regexp.MustCompile("`([^`\n]+)`")
|
||
reBareURL = regexp.MustCompile(`https?://[^\s<>()]+`)
|
||
reAbsOrFileRef = regexp.MustCompile(`file:///[^\s` + "`" + `<>\[\](),,、;;。!?!?]+|/[^\s` + "`" + `<>\[\](),,、;;。!?!?]+`)
|
||
reRelativeRef = regexp.MustCompile(`(?:\.\.?/|[A-Za-z0-9_.-]+/)[^\s` + "`" + `<>\[\](),,、;;。!?!?]+`)
|
||
reBasenameFileRef = regexp.MustCompile(`\b[A-Za-z0-9_.-]+\.[A-Za-z0-9_.-]+(?:#L\d+(?:C\d+)?|:\d+(?::\d+)?|:\d+-\d+)?\b`)
|
||
)
|
||
|
||
func DefaultReferenceRenderCfg() ReferenceRenderCfg {
|
||
return ReferenceRenderCfg{
|
||
DisplayPath: "dirname_basename",
|
||
MarkerStyle: "emoji",
|
||
EnclosureStyle: "code",
|
||
}
|
||
}
|
||
|
||
func normalizeReferenceRenderCfg(cfg ReferenceRenderCfg) ReferenceRenderCfg {
|
||
n := DefaultReferenceRenderCfg()
|
||
if strings.TrimSpace(cfg.DisplayPath) != "" {
|
||
n.DisplayPath = strings.ToLower(strings.TrimSpace(cfg.DisplayPath))
|
||
}
|
||
if strings.TrimSpace(cfg.MarkerStyle) != "" {
|
||
n.MarkerStyle = strings.ToLower(strings.TrimSpace(cfg.MarkerStyle))
|
||
}
|
||
if strings.TrimSpace(cfg.EnclosureStyle) != "" {
|
||
n.EnclosureStyle = strings.ToLower(strings.TrimSpace(cfg.EnclosureStyle))
|
||
}
|
||
n.NormalizeAgents = normalizeReferenceScope(cfg.NormalizeAgents, supportedReferenceNormalizeAgents)
|
||
n.RenderPlatforms = normalizeReferenceScope(cfg.RenderPlatforms, supportedReferenceRenderPlatforms)
|
||
return n
|
||
}
|
||
|
||
var supportedReferenceNormalizeAgents = []string{"codex", "claudecode"}
|
||
var supportedReferenceRenderPlatforms = []string{"feishu", "weixin"}
|
||
|
||
func normalizeReferenceScope(values []string, supported []string) []string {
|
||
if len(values) == 0 {
|
||
return nil
|
||
}
|
||
supportedSet := make(map[string]struct{}, len(supported))
|
||
for _, v := range supported {
|
||
supportedSet[v] = struct{}{}
|
||
}
|
||
hasAll := false
|
||
seen := map[string]struct{}{}
|
||
out := make([]string, 0, len(values))
|
||
for _, v := range values {
|
||
key := strings.ToLower(strings.TrimSpace(v))
|
||
if key == "" {
|
||
continue
|
||
}
|
||
if key == "all" {
|
||
hasAll = true
|
||
continue
|
||
}
|
||
if _, ok := supportedSet[key]; !ok {
|
||
continue
|
||
}
|
||
if _, ok := seen[key]; ok {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
out = append(out, key)
|
||
}
|
||
if hasAll {
|
||
return append([]string(nil), supported...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (cfg ReferenceRenderCfg) renderEnabled(agentName, platformName string) bool {
|
||
if len(cfg.NormalizeAgents) == 0 || len(cfg.RenderPlatforms) == 0 {
|
||
return false
|
||
}
|
||
agentName = strings.ToLower(strings.TrimSpace(agentName))
|
||
platformName = strings.ToLower(strings.TrimSpace(platformName))
|
||
if !containsFolded(cfg.NormalizeAgents, agentName) {
|
||
return false
|
||
}
|
||
return containsFolded(cfg.RenderPlatforms, platformName)
|
||
}
|
||
|
||
func containsFolded(values []string, want string) bool {
|
||
for _, v := range values {
|
||
if strings.EqualFold(strings.TrimSpace(v), want) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func TransformLocalReferences(text string, cfg ReferenceRenderCfg, agentName, platformName, workspaceDir string) string {
|
||
cfg = normalizeReferenceRenderCfg(cfg)
|
||
if !cfg.renderEnabled(agentName, platformName) || strings.TrimSpace(text) == "" {
|
||
return text
|
||
}
|
||
parts := splitWithMatches(text, reFenceBlock)
|
||
var out strings.Builder
|
||
for _, part := range parts {
|
||
if part.matched {
|
||
out.WriteString(part.text)
|
||
continue
|
||
}
|
||
out.WriteString(transformTextOutsideFence(part.text, cfg, workspaceDir))
|
||
}
|
||
return out.String()
|
||
}
|
||
|
||
func transformTextOutsideFence(text string, cfg ReferenceRenderCfg, workspaceDir string) string {
|
||
parts := splitWithMatches(text, reInlineCodeSpan)
|
||
replacements := make([]placeholderReplacement, 0)
|
||
var out strings.Builder
|
||
for _, part := range parts {
|
||
if !part.matched {
|
||
transformed, reps := transformNonCodeText(part.text, cfg, workspaceDir)
|
||
if len(replacements) > 0 && len(reps) > 0 {
|
||
offset := len(replacements)
|
||
for i := range reps {
|
||
oldPlaceholder := reps[i].placeholder
|
||
newPlaceholder := makeReferencePlaceholder(offset + i)
|
||
transformed = strings.ReplaceAll(transformed, oldPlaceholder, newPlaceholder)
|
||
reps[i].placeholder = newPlaceholder
|
||
}
|
||
}
|
||
out.WriteString(transformed)
|
||
replacements = append(replacements, reps...)
|
||
continue
|
||
}
|
||
match := reInlineCodeSpan.FindStringSubmatch(part.text)
|
||
if len(match) < 2 {
|
||
out.WriteString(part.text)
|
||
continue
|
||
}
|
||
ref, ok := parseLocalReference(match[1], workspaceDir)
|
||
if !ok {
|
||
out.WriteString(part.text)
|
||
continue
|
||
}
|
||
placeholder := makeReferencePlaceholder(len(replacements))
|
||
replacements = append(replacements, placeholderReplacement{placeholder: placeholder, ref: ref})
|
||
out.WriteString(placeholder)
|
||
}
|
||
return replaceReferencePlaceholders(out.String(), replacements, cfg)
|
||
}
|
||
|
||
func transformNonCodeText(text string, cfg ReferenceRenderCfg, workspaceDir string) (string, []placeholderReplacement) {
|
||
replacements := make([]placeholderReplacement, 0)
|
||
text = replaceProtectedWebMarkdownLinks(text, &replacements)
|
||
text = replaceProtectedLinks(text, reBareURL, &replacements)
|
||
text = replaceMarkdownLinks(text, &replacements, workspaceDir)
|
||
text = replaceLocalReferenceCandidates(text, reAbsOrFileRef, &replacements, workspaceDir)
|
||
text = replaceLocalReferenceCandidates(text, reRelativeRef, &replacements, workspaceDir)
|
||
text = replaceLocalReferenceCandidates(text, reBasenameFileRef, &replacements, workspaceDir)
|
||
return text, replacements
|
||
}
|
||
|
||
func replaceProtectedLinks(text string, re *regexp.Regexp, replacements *[]placeholderReplacement) string {
|
||
matches := re.FindAllStringIndex(text, -1)
|
||
if len(matches) == 0 {
|
||
return text
|
||
}
|
||
var out strings.Builder
|
||
last := 0
|
||
for _, m := range matches {
|
||
out.WriteString(text[last:m[0]])
|
||
token := text[m[0]:m[1]]
|
||
placeholder := makeReferencePlaceholder(len(*replacements))
|
||
*replacements = append(*replacements, placeholderReplacement{placeholder: placeholder, keepText: token})
|
||
out.WriteString(placeholder)
|
||
last = m[1]
|
||
}
|
||
out.WriteString(text[last:])
|
||
return out.String()
|
||
}
|
||
|
||
func replaceProtectedWebMarkdownLinks(text string, replacements *[]placeholderReplacement) string {
|
||
matches := reMarkdownLink.FindAllStringSubmatchIndex(text, -1)
|
||
if len(matches) == 0 {
|
||
return text
|
||
}
|
||
var out strings.Builder
|
||
last := 0
|
||
for _, m := range matches {
|
||
target := text[m[4]:m[5]]
|
||
if !isWebURL(target) {
|
||
continue
|
||
}
|
||
out.WriteString(text[last:m[0]])
|
||
token := text[m[0]:m[1]]
|
||
placeholder := makeReferencePlaceholder(len(*replacements))
|
||
*replacements = append(*replacements, placeholderReplacement{placeholder: placeholder, keepText: token})
|
||
out.WriteString(placeholder)
|
||
last = m[1]
|
||
}
|
||
out.WriteString(text[last:])
|
||
return out.String()
|
||
}
|
||
|
||
func replaceMarkdownLinks(text string, replacements *[]placeholderReplacement, workspaceDir string) string {
|
||
matches := reMarkdownLink.FindAllStringSubmatchIndex(text, -1)
|
||
if len(matches) == 0 {
|
||
return text
|
||
}
|
||
var out strings.Builder
|
||
last := 0
|
||
for _, m := range matches {
|
||
out.WriteString(text[last:m[0]])
|
||
target := text[m[4]:m[5]]
|
||
suffix := ""
|
||
if m[6] >= 0 {
|
||
suffix = text[m[6]:m[7]]
|
||
}
|
||
ref, ok := parseLocalReference(target+suffix, workspaceDir)
|
||
if !ok {
|
||
out.WriteString(text[m[0]:m[1]])
|
||
last = m[1]
|
||
continue
|
||
}
|
||
placeholder := makeReferencePlaceholder(len(*replacements))
|
||
*replacements = append(*replacements, placeholderReplacement{placeholder: placeholder, ref: ref})
|
||
out.WriteString(placeholder)
|
||
last = m[1]
|
||
}
|
||
out.WriteString(text[last:])
|
||
return out.String()
|
||
}
|
||
|
||
func replaceLocalReferenceCandidates(text string, re *regexp.Regexp, replacements *[]placeholderReplacement, workspaceDir string) string {
|
||
matches := re.FindAllStringIndex(text, -1)
|
||
if len(matches) == 0 {
|
||
return text
|
||
}
|
||
var out strings.Builder
|
||
last := 0
|
||
for _, m := range matches {
|
||
out.WriteString(text[last:m[0]])
|
||
token := text[m[0]:m[1]]
|
||
if re == reAbsOrFileRef && !isValidAbsoluteReferenceBoundary(text, m[0]) {
|
||
out.WriteString(token)
|
||
last = m[1]
|
||
continue
|
||
}
|
||
if re == reRelativeRef && !isValidRelativeReferenceBoundary(text, m[0]) {
|
||
out.WriteString(token)
|
||
last = m[1]
|
||
continue
|
||
}
|
||
ref, ok := parseLocalReference(token, workspaceDir)
|
||
if !ok {
|
||
out.WriteString(token)
|
||
last = m[1]
|
||
continue
|
||
}
|
||
placeholder := makeReferencePlaceholder(len(*replacements))
|
||
*replacements = append(*replacements, placeholderReplacement{placeholder: placeholder, ref: ref})
|
||
out.WriteString(placeholder)
|
||
last = m[1]
|
||
}
|
||
out.WriteString(text[last:])
|
||
return out.String()
|
||
}
|
||
|
||
func isValidAbsoluteReferenceBoundary(text string, start int) bool {
|
||
if start <= 0 {
|
||
return true
|
||
}
|
||
prev, _ := utf8.DecodeLastRuneInString(text[:start])
|
||
switch {
|
||
case prev == ' ', prev == '\n', prev == '\t', prev == '\r':
|
||
return true
|
||
case strings.ContainsRune("([<{\"'`、,,;;。!?!?::", prev):
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isValidRelativeReferenceBoundary(text string, start int) bool {
|
||
if start <= 0 {
|
||
return true
|
||
}
|
||
prev, _ := utf8.DecodeLastRuneInString(text[:start])
|
||
switch {
|
||
case prev == ' ', prev == '\n', prev == '\t', prev == '\r':
|
||
return true
|
||
case strings.ContainsRune("([<{\"'`、,,;;。!?!?::", prev):
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func replaceReferencePlaceholders(text string, replacements []placeholderReplacement, cfg ReferenceRenderCfg) string {
|
||
if len(replacements) == 0 {
|
||
return text
|
||
}
|
||
basenameCounts := make(map[string]int)
|
||
for _, rep := range replacements {
|
||
if rep.ref == nil {
|
||
continue
|
||
}
|
||
base := refBaseName(rep.ref)
|
||
if base != "" {
|
||
basenameCounts[base]++
|
||
}
|
||
}
|
||
sort.SliceStable(replacements, func(i, j int) bool {
|
||
return len(replacements[i].placeholder) > len(replacements[j].placeholder)
|
||
})
|
||
for _, rep := range replacements {
|
||
replacement := rep.keepText
|
||
if rep.ref != nil {
|
||
replacement = renderLocalReference(rep.ref, cfg, basenameCounts)
|
||
}
|
||
text = strings.ReplaceAll(text, rep.placeholder, replacement)
|
||
}
|
||
return text
|
||
}
|
||
|
||
type splitPart struct {
|
||
text string
|
||
matched bool
|
||
}
|
||
|
||
func splitWithMatches(text string, re *regexp.Regexp) []splitPart {
|
||
matches := re.FindAllStringIndex(text, -1)
|
||
if len(matches) == 0 {
|
||
return []splitPart{{text: text}}
|
||
}
|
||
parts := make([]splitPart, 0, len(matches)*2+1)
|
||
last := 0
|
||
for _, m := range matches {
|
||
if m[0] > last {
|
||
parts = append(parts, splitPart{text: text[last:m[0]]})
|
||
}
|
||
parts = append(parts, splitPart{text: text[m[0]:m[1]], matched: true})
|
||
last = m[1]
|
||
}
|
||
if last < len(text) {
|
||
parts = append(parts, splitPart{text: text[last:]})
|
||
}
|
||
return parts
|
||
}
|
||
|
||
func makeReferencePlaceholder(idx int) string {
|
||
return fmt.Sprintf("\x00REF_%03d\x00", idx)
|
||
}
|
||
|
||
func refBaseName(ref *localReference) string {
|
||
if ref == nil {
|
||
return ""
|
||
}
|
||
p := referenceDisplaySource(ref, "basename")
|
||
if p == "" {
|
||
return ""
|
||
}
|
||
return strings.TrimSuffix(p, "/")
|
||
}
|
||
|
||
func renderLocalReference(ref *localReference, cfg ReferenceRenderCfg, basenameCounts map[string]int) string {
|
||
body := referenceDisplaySource(ref, cfg.DisplayPath)
|
||
if cfg.DisplayPath == "smart" {
|
||
base := refBaseName(ref)
|
||
if basenameCounts[base] <= 1 {
|
||
body = referenceDisplaySource(ref, "basename")
|
||
} else {
|
||
body = referenceDisplaySource(ref, "dirname_basename")
|
||
if body == base {
|
||
body = referenceDisplaySource(ref, "relative")
|
||
}
|
||
}
|
||
}
|
||
body += renderReferenceLocation(ref)
|
||
body = applyReferenceEnclosure(cfg.EnclosureStyle, body)
|
||
return applyReferenceMarker(cfg.MarkerStyle, ref.kind, body)
|
||
}
|
||
|
||
func referenceDisplaySource(ref *localReference, mode string) string {
|
||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||
switch mode {
|
||
case "absolute":
|
||
if ref.pathAbs != "" {
|
||
return appendDirSuffix(ref.pathAbs, ref.kind)
|
||
}
|
||
return appendDirSuffix(cleanDisplayPath(ref.pathOriginal), ref.kind)
|
||
case "relative":
|
||
if ref.pathRel == "." {
|
||
if ref.kind == referenceKindDir {
|
||
return "./"
|
||
}
|
||
return "."
|
||
}
|
||
if rel := sanitizeRelativeDisplay(ref.pathRel); rel != "" {
|
||
return appendDirSuffix(rel, ref.kind)
|
||
}
|
||
if ref.isRelative {
|
||
return appendDirSuffix(cleanDisplayPath(ref.pathOriginal), ref.kind)
|
||
}
|
||
if ref.pathAbs != "" {
|
||
return appendDirSuffix(ref.pathAbs, ref.kind)
|
||
}
|
||
return appendDirSuffix(cleanDisplayPath(ref.pathOriginal), ref.kind)
|
||
case "basename":
|
||
return appendDirSuffix(pathTail(ref, 1), ref.kind)
|
||
case "dirname_basename":
|
||
return appendDirSuffix(pathTail(ref, 2), ref.kind)
|
||
case "smart":
|
||
return appendDirSuffix(pathTail(ref, 1), ref.kind)
|
||
default:
|
||
return appendDirSuffix(pathTail(ref, 2), ref.kind)
|
||
}
|
||
}
|
||
|
||
func sanitizeRelativeDisplay(rel string) string {
|
||
rel = filepath.ToSlash(strings.TrimSpace(rel))
|
||
if rel == "" || rel == "." || rel == ".." || strings.HasPrefix(rel, "../") {
|
||
return ""
|
||
}
|
||
return rel
|
||
}
|
||
|
||
func pathTail(ref *localReference, segs int) string {
|
||
source := sanitizeRelativeDisplay(ref.pathRel)
|
||
if source == "" {
|
||
if ref.isRelative {
|
||
source = cleanDisplayPath(ref.pathOriginal)
|
||
} else if ref.pathAbs != "" {
|
||
source = filepath.ToSlash(ref.pathAbs)
|
||
} else {
|
||
source = cleanDisplayPath(ref.pathOriginal)
|
||
}
|
||
}
|
||
source = strings.TrimSuffix(source, "/")
|
||
parts := strings.Split(filepath.ToSlash(source), "/")
|
||
if len(parts) == 0 {
|
||
return source
|
||
}
|
||
if segs <= 0 || len(parts) <= segs {
|
||
return source
|
||
}
|
||
return strings.Join(parts[len(parts)-segs:], "/")
|
||
}
|
||
|
||
func cleanDisplayPath(path string) string {
|
||
if path == "" {
|
||
return ""
|
||
}
|
||
path = filepath.ToSlash(path)
|
||
path = strings.TrimPrefix(path, "./")
|
||
return strings.TrimSpace(path)
|
||
}
|
||
|
||
func appendDirSuffix(path string, kind referenceKind) string {
|
||
path = filepath.ToSlash(strings.TrimSpace(path))
|
||
if path == "" {
|
||
return path
|
||
}
|
||
if kind == referenceKindDir && !strings.HasSuffix(path, "/") {
|
||
return path + "/"
|
||
}
|
||
return strings.TrimSuffix(path, "/")
|
||
}
|
||
|
||
func renderReferenceLocation(ref *localReference) string {
|
||
switch ref.locationFormat {
|
||
case referenceLocationColonLine:
|
||
return fmt.Sprintf(":%d", ref.lineStart)
|
||
case referenceLocationColonLineCol:
|
||
return fmt.Sprintf(":%d:%d", ref.lineStart, ref.column)
|
||
case referenceLocationColonRange:
|
||
return fmt.Sprintf(":%d-%d", ref.lineStart, ref.lineEnd)
|
||
case referenceLocationHashLine:
|
||
return fmt.Sprintf("#L%d", ref.lineStart)
|
||
case referenceLocationHashLineCol:
|
||
return fmt.Sprintf("#L%dC%d", ref.lineStart, ref.column)
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func applyReferenceMarker(style string, kind referenceKind, body string) string {
|
||
switch strings.ToLower(strings.TrimSpace(style)) {
|
||
case "ascii":
|
||
if kind == referenceKindDir {
|
||
return "[DIR] " + body
|
||
}
|
||
if kind == referenceKindFile {
|
||
return "[FILE] " + body
|
||
}
|
||
return body
|
||
case "emoji":
|
||
if kind == referenceKindDir {
|
||
return "📁 " + body
|
||
}
|
||
if kind == referenceKindFile {
|
||
return "📄 " + body
|
||
}
|
||
return body
|
||
default:
|
||
return body
|
||
}
|
||
}
|
||
|
||
func applyReferenceEnclosure(style, body string) string {
|
||
switch strings.ToLower(strings.TrimSpace(style)) {
|
||
case "bracket":
|
||
return "[" + body + "]"
|
||
case "angle":
|
||
return "<" + body + ">"
|
||
case "fullwidth":
|
||
return "【" + body + "】"
|
||
case "code":
|
||
return "`" + body + "`"
|
||
default:
|
||
return body
|
||
}
|
||
}
|