Files
2026-07-13 12:49:10 +08:00

775 lines
20 KiB
Go

/*
* Copyright 2025 CloudWeGo Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package filesystem
import (
"context"
"fmt"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/bmatcuk/doublestar/v4"
)
type fileEntry struct {
content string
modifiedAt time.Time
}
// InMemoryBackend is an in-memory implementation of the Backend interface.
// It stores files in a map and is safe for concurrent use.
type InMemoryBackend struct {
mu sync.RWMutex
files map[string]*fileEntry
}
// NewInMemoryBackend creates a new in-memory backend.
func NewInMemoryBackend() *InMemoryBackend {
return &InMemoryBackend{
files: make(map[string]*fileEntry),
}
}
// LsInfo lists file information under the given path.
func (b *InMemoryBackend) LsInfo(ctx context.Context, req *LsInfoRequest) ([]FileInfo, error) {
b.mu.RLock()
defer b.mu.RUnlock()
// Normalize path
path := normalizePath(req.Path)
var result []FileInfo
seen := make(map[string]bool)
dirInfo := make(map[string]*FileInfo)
for filePath, entry := range b.files {
normalizedFilePath := normalizePath(filePath)
// Check if file is under the given path
if path == "/" || strings.HasPrefix(normalizedFilePath, path+"/") || normalizedFilePath == path {
// For directory listing, we want to show immediate children
relativePath := strings.TrimPrefix(normalizedFilePath, path)
relativePath = strings.TrimPrefix(relativePath, "/")
if relativePath == "" {
// The path itself is a file
if !seen[normalizedFilePath] {
result = append(result, FileInfo{
Path: filepath.Base(normalizedFilePath),
IsDir: false,
Size: int64(len(entry.content)),
ModifiedAt: entry.modifiedAt.Format(time.RFC3339Nano),
})
seen[normalizedFilePath] = true
}
continue
}
// Get the first segment (immediate child)
parts := strings.SplitN(relativePath, "/", 2)
if len(parts) > 0 {
childPath := path
if path != "/" {
childPath += "/"
}
childPath += parts[0]
isDir := len(parts) > 1
if !seen[childPath] {
if isDir {
dirInfo[childPath] = &FileInfo{
Path: parts[0],
IsDir: true,
Size: 0,
ModifiedAt: entry.modifiedAt.Format(time.RFC3339Nano),
}
} else {
result = append(result, FileInfo{
Path: parts[0],
IsDir: false,
Size: int64(len(entry.content)),
ModifiedAt: entry.modifiedAt.Format(time.RFC3339Nano),
})
}
seen[childPath] = true
} else if isDir {
if info, ok := dirInfo[childPath]; ok {
if entry.modifiedAt.After(mustParseTime(info.ModifiedAt)) {
info.ModifiedAt = entry.modifiedAt.Format(time.RFC3339Nano)
}
}
}
}
}
}
for _, info := range dirInfo {
result = append(result, *info)
}
return result, nil
}
func mustParseTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339Nano, s)
return t
}
// Read reads file content with offset and limit.
func (b *InMemoryBackend) Read(ctx context.Context, req *ReadRequest) (*FileContent, error) {
b.mu.RLock()
defer b.mu.RUnlock()
filePath := normalizePath(req.FilePath)
entry, exists := b.files[filePath]
if !exists {
return nil, fmt.Errorf("file not found: %s", filePath)
}
// Convert 1-based offset to 0-based index; values < 1 default to line 1
offset := req.Offset - 1
if offset < 0 {
offset = 0
}
limit := req.Limit
content := entry.content
// Fast path: no offset and no limit — return as-is
if offset == 0 && limit <= 0 {
return &FileContent{Content: content}, nil
}
// Fast path: no offset, content fits within limit — return as-is
if offset == 0 {
lineCount := strings.Count(content, "\n") + 1
if lineCount <= limit {
return &FileContent{Content: content}, nil
}
}
// Skip `offset` lines by scanning for newlines directly
start := 0
for i := 0; i < offset; i++ {
idx := strings.IndexByte(content[start:], '\n')
if idx == -1 {
// offset exceeds total lines
return &FileContent{}, nil
}
start += idx + 1
}
// No limit: return everything from start
if limit <= 0 {
return &FileContent{Content: content[start:]}, nil
}
// Find the end position after `limit` lines
end := start
for i := 0; i < limit; i++ {
idx := strings.IndexByte(content[end:], '\n')
if idx == -1 {
// Reached the end of content
return &FileContent{Content: content[start:]}, nil
}
end += idx + 1
}
// Trim the trailing newline from the last included line
return &FileContent{Content: content[start : end-1]}, nil
}
// GrepRaw returns matches for the given pattern.
func (b *InMemoryBackend) GrepRaw(ctx context.Context, req *GrepRequest) ([]GrepMatch, error) {
b.mu.RLock()
defer b.mu.RUnlock()
if req.Pattern == "" {
return nil, fmt.Errorf("pattern cannot be empty")
}
re, err := b.compilePattern(req)
if err != nil {
return nil, err
}
searchPath := "/"
if req.Path != "" {
searchPath = normalizePath(req.Path)
}
filteredFiles, err := b.filterFiles(searchPath, req)
if err != nil {
return nil, err
}
if len(filteredFiles) == 0 {
return []GrepMatch{}, nil
}
if len(filteredFiles) == 1 {
collector := newGrepCollector()
entry := b.files[filteredFiles[0]]
collector.processFile(filteredFiles[0], entry.content, re, req)
return collector.buildResults(b, req)
}
matches, err := b.grepFilesInParallel(filteredFiles, re, req)
if err != nil {
return nil, err
}
if req.BeforeLines > 0 || req.AfterLines > 0 {
matches = b.applyContext(matches, req)
}
return matches, nil
}
func (b *InMemoryBackend) grepFilesInParallel(filteredFiles []string, re *regexp.Regexp, req *GrepRequest) ([]GrepMatch, error) {
numWorkers := len(filteredFiles)
if numWorkers > 10 {
numWorkers = 10
}
type fileTask struct {
path string
content string
}
tasks := make(chan fileTask, len(filteredFiles))
results := make(chan []GrepMatch, len(filteredFiles))
errChan := make(chan error, numWorkers)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("worker %d panic: %v", workerID, r)
}
}()
collector := newGrepCollector()
for task := range tasks {
fileMatches := collector.findMatches(task.path, task.content, re, req)
if len(fileMatches) > 0 {
results <- fileMatches
}
}
}(i)
}
for _, filePath := range filteredFiles {
entry := b.files[filePath]
tasks <- fileTask{
path: filePath,
content: entry.content,
}
}
close(tasks)
go func() {
wg.Wait()
close(results)
close(errChan)
}()
var allMatches []GrepMatch
var errs []error
for {
select {
case matches, ok := <-results:
if !ok {
results = nil
} else {
allMatches = append(allMatches, matches...)
}
case err, ok := <-errChan:
if !ok {
errChan = nil
} else if err != nil {
errs = append(errs, err)
}
}
if results == nil && errChan == nil {
break
}
}
if len(errs) > 0 {
return nil, fmt.Errorf("grep failed with %d error(s): %v", len(errs), errs[0])
}
return allMatches, nil
}
func (b *InMemoryBackend) compilePattern(req *GrepRequest) (*regexp.Regexp, error) {
pattern := req.Pattern
if req.CaseInsensitive {
pattern = "(?i)" + pattern
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid regex pattern: %w", err)
}
return re, nil
}
func (b *InMemoryBackend) filterFiles(searchPath string, req *GrepRequest) ([]string, error) {
var candidateFiles []string
for filePath := range b.files {
normalizedFilePath := normalizePath(filePath)
if searchPath != "/" && !strings.HasPrefix(normalizedFilePath, searchPath+"/") && normalizedFilePath != searchPath {
continue
}
candidateFiles = append(candidateFiles, normalizedFilePath)
}
if req.Glob != "" {
filtered, err := b.filterByGlob(candidateFiles, searchPath, req.Glob)
if err != nil {
return nil, err
}
candidateFiles = filtered
}
if req.FileType != "" {
candidateFiles = b.filterByFileType(candidateFiles, req.FileType)
}
return candidateFiles, nil
}
func (b *InMemoryBackend) filterByGlob(files []string, searchPath string, globPattern string) ([]string, error) {
var result []string
for _, filePath := range files {
var matchPath string
if strings.Contains(globPattern, "/") || strings.Contains(globPattern, "**") {
if searchPath == "/" {
matchPath = strings.TrimPrefix(filePath, "/")
} else {
matchPath = strings.TrimPrefix(filePath, searchPath+"/")
}
} else {
matchPath = filepath.Base(filePath)
}
matched, err := doublestar.Match(globPattern, matchPath)
if err != nil {
return nil, fmt.Errorf("invalid glob pattern: %w", err)
}
if matched {
result = append(result, filePath)
}
}
return result, nil
}
func (b *InMemoryBackend) filterByFileType(files []string, fileType string) []string {
var result []string
for _, filePath := range files {
ext := strings.TrimPrefix(filepath.Ext(filePath), ".")
if matchFileType(ext, fileType) {
result = append(result, filePath)
}
}
return result
}
// matchFileType checks if the file extension matches the given file type.
func matchFileType(ext, fileType string) bool {
typeMap := map[string][]string{
"ada": {"adb", "ads"},
"agda": {"agda", "lagda"},
"aidl": {"aidl"},
"amake": {"bp", "mk"},
"asciidoc": {"adoc", "asc", "asciidoc"},
"asm": {"S", "asm", "s"},
"asp": {"ascx", "asp", "aspx"},
"ats": {"ats", "dats", "hats", "sats"},
"avro": {"avdl", "avpr", "avsc"},
"awk": {"awk"},
"bat": {"bat"},
"bazel": {"BUILD", "bazel", "bzl"},
"bitbake": {"bb", "bbappend", "bbclass", "conf", "inc"},
"c": {"c", "h", "H", "cats"},
"cabal": {"cabal"},
"cbor": {"cbor"},
"ceylon": {"ceylon"},
"clojure": {"clj", "cljc", "cljs", "cljx"},
"cmake": {"cmake"},
"coffeescript": {"coffee"},
"config": {"cfg", "conf", "config", "ini"},
"coq": {"v"},
"cpp": {"C", "cc", "cpp", "cxx", "c++", "h", "hh", "hpp", "hxx", "h++", "inl"},
"crystal": {"cr", "ecr"},
"cs": {"cs"},
"csharp": {"cs"},
"cshtml": {"cshtml"},
"css": {"css", "scss", "sass", "less"},
"csv": {"csv"},
"cuda": {"cu", "cuh"},
"cython": {"pxd", "pxi", "pyx"},
"d": {"d"},
"dart": {"dart"},
"devicetree": {"dts", "dtsi"},
"dhall": {"dhall"},
"diff": {"diff", "patch"},
"docker": {"dockerfile"},
"go": {"go"},
"groovy": {"gradle", "groovy"},
"haskell": {"c2hs", "cpphs", "hs", "hsc", "lhs"},
"html": {"ejs", "htm", "html"},
"java": {"java", "jsp", "jspx", "properties"},
"js": {"cjs", "js", "jsx", "mjs", "vue"},
"json": {"json", "sarif"},
"jsonl": {"jsonl"},
"julia": {"jl"},
"jupyter": {"ipynb", "jpynb"},
"kotlin": {"kt", "kts"},
"less": {"less"},
"lua": {"lua"},
"make": {"mak", "mk"},
"markdown": {"markdown", "md", "mdown", "mdwn", "mdx", "mkd", "mkdn"},
"md": {"markdown", "md", "mdown", "mdwn", "mdx", "mkd", "mkdn"},
"matlab": {"m"},
"ocaml": {"ml", "mli", "mll", "mly"},
"perl": {"PL", "perl", "pl", "plh", "plx", "pm", "t"},
"php": {"php", "php3", "php4", "php5", "php7", "php8", "pht", "phtml"},
"python": {"py", "pyi"},
"py": {"py", "pyi"},
"ruby": {"gemspec", "rb", "rbw"},
"rust": {"rs"},
"sass": {"sass", "scss"},
"scala": {"sbt", "scala"},
"sh": {"bash", "sh", "zsh"},
"sql": {"psql", "sql"},
"swift": {"swift"},
"toml": {"toml"},
"ts": {"cts", "mts", "ts", "tsx"},
"typescript": {"cts", "mts", "ts", "tsx"},
"txt": {"txt"},
"vue": {"vue"},
"xml": {"dtd", "xml", "xsd", "xsl", "xslt"},
"yaml": {"yaml", "yml"},
"zig": {"zig"},
}
if exts, ok := typeMap[fileType]; ok {
for _, e := range exts {
if ext == e {
return true
}
}
}
return ext == fileType
}
// applyContext adds context lines around matches.
func (b *InMemoryBackend) applyContext(matches []GrepMatch, req *GrepRequest) []GrepMatch {
if len(matches) == 0 {
return matches
}
beforeLines := 0
afterLines := 0
if req.BeforeLines > 0 {
beforeLines = req.BeforeLines
}
if req.AfterLines > 0 {
afterLines = req.AfterLines
}
if beforeLines <= 0 && afterLines <= 0 {
return matches
}
// Group matches by file path for efficient processing
matchesByFile := make(map[string][]GrepMatch)
fileOrder := make([]string, 0)
seenFiles := make(map[string]bool)
for _, match := range matches {
if !seenFiles[match.Path] {
fileOrder = append(fileOrder, match.Path)
seenFiles[match.Path] = true
}
matchesByFile[match.Path] = append(matchesByFile[match.Path], match)
}
var result []GrepMatch
// Process each file once
for _, filePath := range fileOrder {
fileMatches := matchesByFile[filePath]
// Get file content once per file
b.mu.RLock()
entry, exists := b.files[filePath]
b.mu.RUnlock()
if !exists {
// If file doesn't exist, keep original matches
result = append(result, fileMatches...)
continue
}
lines := strings.Split(entry.content, "\n")
processedLines := make(map[int]bool)
// Process all matches for this file
for _, match := range fileMatches {
startLine := match.Line - beforeLines
if startLine < 1 {
startLine = 1
}
endLine := match.Line + afterLines
if endLine > len(lines) {
endLine = len(lines)
}
for lineNum := startLine; lineNum <= endLine; lineNum++ {
if !processedLines[lineNum] {
processedLines[lineNum] = true
result = append(result, GrepMatch{
Path: filePath,
Line: lineNum,
Content: lines[lineNum-1],
})
}
}
}
}
return result
}
// GlobInfo returns file info entries matching the glob pattern.
func (b *InMemoryBackend) GlobInfo(ctx context.Context, req *GlobInfoRequest) ([]FileInfo, error) {
b.mu.RLock()
defer b.mu.RUnlock()
basePath := normalizePath(req.Path)
isAbsolutePattern := strings.HasPrefix(req.Pattern, "/")
var result []FileInfo
for filePath, entry := range b.files {
normalizedFilePath := normalizePath(filePath)
var matchPath string
var resultPath string
if isAbsolutePattern {
matchPath = normalizedFilePath
resultPath = normalizedFilePath
} else {
if basePath != "/" && !strings.HasPrefix(normalizedFilePath, basePath+"/") && normalizedFilePath != basePath {
continue
}
if basePath == "/" {
matchPath = strings.TrimPrefix(normalizedFilePath, "/")
} else {
matchPath = strings.TrimPrefix(normalizedFilePath, basePath+"/")
}
resultPath = matchPath
}
matched, err := doublestar.Match(req.Pattern, matchPath)
if err != nil {
return nil, fmt.Errorf("invalid glob pattern: %w", err)
}
if matched {
result = append(result, FileInfo{
Path: resultPath,
IsDir: false,
Size: int64(len(entry.content)),
ModifiedAt: entry.modifiedAt.Format(time.RFC3339Nano),
})
}
}
return result, nil
}
// Write creates or overwrites file content.
func (b *InMemoryBackend) Write(ctx context.Context, req *WriteRequest) error {
b.mu.Lock()
defer b.mu.Unlock()
filePath := normalizePath(req.FilePath)
b.files[filePath] = &fileEntry{
content: req.Content,
modifiedAt: time.Now(),
}
return nil
}
// Edit replaces string occurrences in a file.
func (b *InMemoryBackend) Edit(ctx context.Context, req *EditRequest) error {
b.mu.Lock()
defer b.mu.Unlock()
filePath := normalizePath(req.FilePath)
entry, exists := b.files[filePath]
if !exists {
return fmt.Errorf("file not found: %s", filePath)
}
if req.OldString == "" {
return fmt.Errorf("oldString must be non-empty")
}
content := entry.content
if !strings.Contains(content, req.OldString) {
return fmt.Errorf("oldString not found in file: %s", filePath)
}
if !req.ReplaceAll {
firstIndex := strings.Index(content, req.OldString)
if firstIndex != -1 {
// Check if there's another occurrence after the first one
if strings.Contains(content[firstIndex+len(req.OldString):], req.OldString) {
return fmt.Errorf("multiple occurrences of oldString found in file %s, but ReplaceAll is false", filePath)
}
}
}
var newContent string
if req.ReplaceAll {
newContent = strings.ReplaceAll(content, req.OldString, req.NewString)
} else {
newContent = strings.Replace(content, req.OldString, req.NewString, 1)
}
b.files[filePath] = &fileEntry{
content: newContent,
modifiedAt: time.Now(),
}
return nil
}
// normalizePath normalizes a file path by ensuring it starts with "/" and removing trailing slashes.
func normalizePath(path string) string {
if path == "" {
return "/"
}
// Ensure path starts with "/"
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return filepath.Clean(path)
}
type grepCollector struct {
allMatches []GrepMatch
}
func newGrepCollector() *grepCollector {
return &grepCollector{
allMatches: []GrepMatch{},
}
}
func (c *grepCollector) processFile(filePath, content string, re *regexp.Regexp, req *GrepRequest) {
fileMatches := c.findMatches(filePath, content, re, req)
if len(fileMatches) > 0 {
c.allMatches = append(c.allMatches, fileMatches...)
}
}
func (c *grepCollector) findMatches(filePath, content string, re *regexp.Regexp, req *GrepRequest) []GrepMatch {
if req.EnableMultiline {
return c.findMultilineMatches(filePath, content, re)
}
return c.findSingleLineMatches(filePath, content, re)
}
func (c *grepCollector) findMultilineMatches(filePath, content string, re *regexp.Regexp) []GrepMatch {
var fileMatches []GrepMatch
matches := re.FindAllStringIndex(content, -1)
lines := strings.Split(content, "\n")
for _, match := range matches {
matchStart := match[0]
matchEnd := match[1]
startLineNum := 1 + strings.Count(content[:matchStart], "\n")
endLineNum := 1 + strings.Count(content[:matchEnd], "\n")
for lineNum := startLineNum; lineNum <= endLineNum && lineNum <= len(lines); lineNum++ {
fileMatches = append(fileMatches, GrepMatch{
Path: filePath,
Line: lineNum,
Content: lines[lineNum-1],
})
}
}
return fileMatches
}
func (c *grepCollector) findSingleLineMatches(filePath, content string, re *regexp.Regexp) []GrepMatch {
var fileMatches []GrepMatch
lines := strings.Split(content, "\n")
for lineNum, line := range lines {
if re.MatchString(line) {
fileMatches = append(fileMatches, GrepMatch{
Path: filePath,
Line: lineNum + 1,
Content: line,
})
}
}
return fileMatches
}
func (c *grepCollector) buildResults(b *InMemoryBackend, req *GrepRequest) ([]GrepMatch, error) {
return c.buildContentResult(b, req), nil
}
func (c *grepCollector) buildContentResult(b *InMemoryBackend, req *GrepRequest) []GrepMatch {
results := c.allMatches
if req.BeforeLines > 0 || req.AfterLines > 0 {
results = b.applyContext(c.allMatches, req)
}
return results
}