text editing util
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install fd
|
||||
run: |
|
||||
sudo apt-get install -y fd-find
|
||||
sudo ln -sf /usr/bin/fdfind /usr/local/bin/fd
|
||||
|
||||
- run: go test ./...
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version number (e.g. v1.2.0)"
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build binaries
|
||||
run: |
|
||||
mkdir dist
|
||||
GOOS=linux GOARCH=amd64 go build -trimpath -o dist/text-shark-linux-amd64 .
|
||||
GOOS=linux GOARCH=arm64 go build -trimpath -o dist/text-shark-linux-arm64 .
|
||||
GOOS=darwin GOARCH=arm64 go build -trimpath -o dist/text-shark-darwin-arm64 .
|
||||
GOOS=windows GOARCH=amd64 go build -trimpath -o dist/text-shark-windows-amd64.exe .
|
||||
|
||||
- name: Generate checksums
|
||||
id: checksums
|
||||
run: |
|
||||
cd dist
|
||||
sha256sum text-shark-linux-amd64 text-shark-linux-arm64 text-shark-darwin-arm64 text-shark-windows-amd64.exe \
|
||||
| tee checksums.txt
|
||||
{
|
||||
echo 'body<<EOF'
|
||||
echo '## SHA256 Checksums'
|
||||
echo '```'
|
||||
cat checksums.txt
|
||||
echo '```'
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ inputs.version }}
|
||||
name: ${{ inputs.version }}
|
||||
body: ${{ steps.checksums.outputs.body }}
|
||||
files: |
|
||||
dist/text-shark-linux-amd64
|
||||
dist/text-shark-linux-arm64
|
||||
dist/text-shark-darwin-arm64
|
||||
dist/text-shark-windows-amd64.exe
|
||||
dist/checksums.txt
|
||||
@@ -0,0 +1,169 @@
|
||||
# text-shark
|
||||
|
||||
A Go CLI for fast text search, in-place replacement, line deletion, and file deletion. Powered by [`rg`](https://github.com/BurntSushi/ripgrep) for content operations and [`fd`](https://github.com/sharkdp/fd) for file-name finding.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`)
|
||||
- [fd](https://github.com/sharkdp/fd) (`fd`)
|
||||
- Go 1.21+ (to build from source)
|
||||
|
||||
**Install on Ubuntu/Debian:**
|
||||
```sh
|
||||
sudo apt install ripgrep fd-find
|
||||
```
|
||||
|
||||
**Install on macOS:**
|
||||
```sh
|
||||
brew install ripgrep fd
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
git clone https://github.com/jasonross/text-shark
|
||||
cd text-shark
|
||||
go build -o text-shark .
|
||||
```
|
||||
|
||||
Move the binary somewhere on your `$PATH`:
|
||||
```sh
|
||||
mv text-shark /usr/local/bin/
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `find`
|
||||
|
||||
Search for a pattern in file contents. Output is streamed directly from `rg`, preserving colors and formatting.
|
||||
|
||||
```sh
|
||||
text-shark find <pattern> [path]
|
||||
```
|
||||
|
||||
```sh
|
||||
text-shark find 'TODO'
|
||||
text-shark find 'TODO' ./src
|
||||
text-shark find -i 'error' # case-insensitive
|
||||
text-shark find -t go 'context' # Go files only
|
||||
text-shark find -g '*.json' 'port' # glob filter
|
||||
text-shark find -F 'foo.bar' # literal string (no regex)
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-i`, `--ignore-case` | Case-insensitive search |
|
||||
| `-F`, `--fixed-strings` | Treat pattern as a literal string |
|
||||
| `-t`, `--type` | File type filter (e.g. `go`, `py`, `js`) |
|
||||
| `-g`, `--glob` | Glob pattern for file paths |
|
||||
|
||||
---
|
||||
|
||||
### `replace`
|
||||
|
||||
Replace all occurrences of a string (or regex) in matching files. Reports each modified file and the line numbers that changed.
|
||||
|
||||
```sh
|
||||
text-shark replace <from> <to> [path]
|
||||
```
|
||||
|
||||
```sh
|
||||
text-shark replace 'foo' 'bar'
|
||||
text-shark replace 'foo' 'bar' ./src
|
||||
text-shark replace 'foo' 'bar' --dry-run # preview changes without writing
|
||||
text-shark replace -r 'foo\d+' 'item' ./src # regex mode
|
||||
text-shark replace -t go 'oldPkg' 'newPkg' # Go files only
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
./internal/config.go: lines 12, 47
|
||||
./cmd/root.go: lines 3
|
||||
2 file(s) modified.
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--dry-run` | Print what would change without writing files |
|
||||
| `-r`, `--regex` | Treat `<from>` as a regular expression |
|
||||
| `-t`, `--type` | File type filter |
|
||||
| `-g`, `--glob` | Glob pattern for file paths |
|
||||
|
||||
---
|
||||
|
||||
### `delete-line`
|
||||
|
||||
Delete all lines containing a pattern. Reports each modified file and the original line numbers that were removed.
|
||||
|
||||
```sh
|
||||
text-shark delete-line <pattern> [path]
|
||||
```
|
||||
|
||||
```sh
|
||||
text-shark delete-line 'debug'
|
||||
text-shark delete-line 'debug' ./src
|
||||
text-shark delete-line 'debug' --dry-run
|
||||
text-shark delete-line -r '^#.*TODO' ./src # regex mode
|
||||
text-shark delete-line -t py 'print(' ./lib # Python files only
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
./internal/server.go: deleted lines 5, 23
|
||||
./cmd/run.go: deleted lines 1
|
||||
2 file(s) modified, 3 line(s) deleted.
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--dry-run` | Print what would change without writing files |
|
||||
| `-r`, `--regex` | Treat pattern as a regular expression |
|
||||
| `-t`, `--type` | File type filter |
|
||||
| `-g`, `--glob` | Glob pattern for file paths |
|
||||
|
||||
---
|
||||
|
||||
### `find-delete-files`
|
||||
|
||||
Find files by **name pattern** using `fd`, display the full candidate list, then prompt for a single group confirmation before deleting.
|
||||
|
||||
```sh
|
||||
text-shark find-delete-files <pattern> [path]
|
||||
```
|
||||
|
||||
```sh
|
||||
text-shark find-delete-files '_backup'
|
||||
text-shark find-delete-files '_backup' ./archive
|
||||
text-shark find-delete-files '_backup' --yes # skip prompt (scripting)
|
||||
text-shark find-delete-files '\.log$' -t f ./logs # files only, regex name
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
Found 3 file(s):
|
||||
./archive/data_backup.csv
|
||||
./archive/config_backup.json
|
||||
./logs/old_backup.log
|
||||
|
||||
Delete these 3 file(s)? [y/N] y
|
||||
3 file(s) deleted.
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-y`, `--yes` | Skip confirmation prompt and delete all matches |
|
||||
| `-t`, `--type` | fd type filter: `f` (file), `d` (dir), `l` (symlink) — defaults to `f` |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All `replace` and `delete-line` writes are **atomic**: changes are written to a temp file in the same directory, then renamed into place, so a failure mid-write cannot corrupt the original.
|
||||
- `path` defaults to `.` (current directory) on all subcommands. All operations are recursive.
|
||||
- `replace` and `delete-line` use `rg -l` internally to skip files that don't contain the pattern before doing any I/O.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jasonross/text-shark/internal/fileops"
|
||||
"github.com/jasonross/text-shark/internal/runner"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
deleteLineDryRun bool
|
||||
deleteLineType string
|
||||
deleteLineGlob string
|
||||
deleteLineRegex bool
|
||||
)
|
||||
|
||||
var deleteLineCmd = &cobra.Command{
|
||||
Use: "delete-line <pattern> [path]",
|
||||
Short: "Delete all lines matching <pattern> from files",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pattern := args[0]
|
||||
path := "."
|
||||
if len(args) == 2 {
|
||||
path = args[1]
|
||||
}
|
||||
|
||||
var rgExtra []string
|
||||
if deleteLineType != "" {
|
||||
rgExtra = append(rgExtra, "--type", deleteLineType)
|
||||
}
|
||||
if deleteLineGlob != "" {
|
||||
rgExtra = append(rgExtra, "--glob", deleteLineGlob)
|
||||
}
|
||||
|
||||
files, err := runner.RgListFiles(pattern, path, rgExtra...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
fmt.Println("No matches found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []fileops.DeleteResult
|
||||
for _, f := range files {
|
||||
var res fileops.DeleteResult
|
||||
if deleteLineRegex {
|
||||
res, err = fileops.DeleteLinesInFileRegex(f, pattern, deleteLineDryRun)
|
||||
} else {
|
||||
res, err = fileops.DeleteLinesInFile(f, pattern, deleteLineDryRun)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", f, err)
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
|
||||
if deleteLineDryRun {
|
||||
fmt.Println("[dry-run] would modify:")
|
||||
}
|
||||
fileops.PrintDeleteResults(results)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
deleteLineCmd.Flags().BoolVar(&deleteLineDryRun, "dry-run", false, "Print what would change without writing")
|
||||
deleteLineCmd.Flags().StringVarP(&deleteLineType, "type", "t", "", "File type filter (e.g. go, py)")
|
||||
deleteLineCmd.Flags().StringVarP(&deleteLineGlob, "glob", "g", "", "Glob pattern for file paths")
|
||||
deleteLineCmd.Flags().BoolVarP(&deleteLineRegex, "regex", "r", false, "Treat pattern as a regular expression")
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/jasonross/text-shark/internal/runner"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
findType string
|
||||
findGlob string
|
||||
findFixed bool
|
||||
findIgnoreCase bool
|
||||
)
|
||||
|
||||
var findCmd = &cobra.Command{
|
||||
Use: "find <pattern> [path]",
|
||||
Short: "Search for a pattern in files (wraps rg)",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pattern := args[0]
|
||||
path := "."
|
||||
if len(args) == 2 {
|
||||
path = args[1]
|
||||
}
|
||||
|
||||
rgArgs := []string{}
|
||||
if findIgnoreCase {
|
||||
rgArgs = append(rgArgs, "-i")
|
||||
}
|
||||
if findFixed {
|
||||
rgArgs = append(rgArgs, "-F")
|
||||
}
|
||||
if findType != "" {
|
||||
rgArgs = append(rgArgs, "--type", findType)
|
||||
}
|
||||
if findGlob != "" {
|
||||
rgArgs = append(rgArgs, "--glob", findGlob)
|
||||
}
|
||||
rgArgs = append(rgArgs, pattern, path)
|
||||
|
||||
return runner.RgStream(rgArgs...)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
findCmd.Flags().StringVarP(&findType, "type", "t", "", "File type filter (e.g. go, py, js)")
|
||||
findCmd.Flags().StringVarP(&findGlob, "glob", "g", "", "Glob pattern for file paths")
|
||||
findCmd.Flags().BoolVarP(&findFixed, "fixed-strings", "F", false, "Treat pattern as a literal string")
|
||||
findCmd.Flags().BoolVarP(&findIgnoreCase, "ignore-case", "i", false, "Case-insensitive search")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jasonross/text-shark/internal/runner"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
findDeleteYes bool
|
||||
findDeleteType string
|
||||
)
|
||||
|
||||
var findDeleteFilesCmd = &cobra.Command{
|
||||
Use: "find-delete-files <pattern> [path]",
|
||||
Short: "Find files matching <pattern> (by name) and delete them after confirmation",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pattern := args[0]
|
||||
path := "."
|
||||
if len(args) == 2 {
|
||||
path = args[1]
|
||||
}
|
||||
|
||||
var fdExtra []string
|
||||
if findDeleteType != "" {
|
||||
fdExtra = append(fdExtra, "--type", findDeleteType)
|
||||
}
|
||||
|
||||
files, err := runner.FdListFiles(pattern, path, fdExtra...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
fmt.Println("No files found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d file(s):\n", len(files))
|
||||
for _, f := range files {
|
||||
fmt.Printf(" %s\n", f)
|
||||
}
|
||||
|
||||
if !findDeleteYes {
|
||||
fmt.Printf("\nDelete these %d file(s)? [y/N] ", len(files))
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
if answer != "y" && answer != "yes" {
|
||||
fmt.Println("Aborted. No files deleted.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, f := range files {
|
||||
if err := os.Remove(f); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error deleting %s: %v\n", f, err)
|
||||
} else {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
fmt.Printf("%d file(s) deleted.\n", deleted)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
findDeleteFilesCmd.Flags().BoolVarP(&findDeleteYes, "yes", "y", false, "Skip confirmation prompt and delete all matches")
|
||||
findDeleteFilesCmd.Flags().StringVarP(&findDeleteType, "type", "t", "f", "fd type filter: f (file), d (dir), l (symlink)")
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jasonross/text-shark/internal/fileops"
|
||||
"github.com/jasonross/text-shark/internal/runner"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
replaceDryRun bool
|
||||
replaceType string
|
||||
replaceGlob string
|
||||
replaceRegex bool
|
||||
)
|
||||
|
||||
var replaceCmd = &cobra.Command{
|
||||
Use: "replace <from> <to> [path]",
|
||||
Short: "Replace all occurrences of <from> with <to> in files",
|
||||
Args: cobra.RangeArgs(2, 3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
from, to := args[0], args[1]
|
||||
path := "."
|
||||
if len(args) == 3 {
|
||||
path = args[2]
|
||||
}
|
||||
|
||||
var rgExtra []string
|
||||
if replaceType != "" {
|
||||
rgExtra = append(rgExtra, "--type", replaceType)
|
||||
}
|
||||
if replaceGlob != "" {
|
||||
rgExtra = append(rgExtra, "--glob", replaceGlob)
|
||||
}
|
||||
|
||||
files, err := runner.RgListFiles(from, path, rgExtra...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
fmt.Println("No matches found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []fileops.ReplaceResult
|
||||
for _, f := range files {
|
||||
var res fileops.ReplaceResult
|
||||
if replaceRegex {
|
||||
res, err = fileops.ReplaceInFileRegex(f, from, to, replaceDryRun)
|
||||
} else {
|
||||
res, err = fileops.ReplaceInFile(f, from, to, replaceDryRun)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", f, err)
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
|
||||
if replaceDryRun {
|
||||
fmt.Println("[dry-run] would modify:")
|
||||
}
|
||||
fileops.PrintReplaceResults(results)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
replaceCmd.Flags().BoolVar(&replaceDryRun, "dry-run", false, "Print what would change without writing")
|
||||
replaceCmd.Flags().StringVarP(&replaceType, "type", "t", "", "File type filter (e.g. go, py)")
|
||||
replaceCmd.Flags().StringVarP(&replaceGlob, "glob", "g", "", "Glob pattern for file paths")
|
||||
replaceCmd.Flags().BoolVarP(&replaceRegex, "regex", "r", false, "Treat <from> as a regular expression")
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "text-shark",
|
||||
Short: "Fast text search, replace, and file operations powered by rg and fd",
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(findCmd)
|
||||
rootCmd.AddCommand(replaceCmd)
|
||||
rootCmd.AddCommand(deleteLineCmd)
|
||||
rootCmd.AddCommand(findDeleteFilesCmd)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
module github.com/jasonross/text-shark
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -0,0 +1,109 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DeleteResult struct {
|
||||
File string
|
||||
DeletedLines []int
|
||||
}
|
||||
|
||||
// DeleteLinesInFile removes all lines matching pattern (fixed string) from the file.
|
||||
// Returns the original 1-indexed line numbers that were deleted.
|
||||
func DeleteLinesInFile(path, pattern string, dryRun bool) (DeleteResult, error) {
|
||||
result := DeleteResult{File: path}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
var kept []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
if strings.Contains(line, pattern) {
|
||||
result.DeletedLines = append(result.DeletedLines, lineNum)
|
||||
} else {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if len(result.DeletedLines) == 0 || dryRun {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
content := ""
|
||||
if len(kept) > 0 {
|
||||
content = strings.Join(kept, "\n") + "\n"
|
||||
}
|
||||
return result, atomicWrite(path, content)
|
||||
}
|
||||
|
||||
// DeleteLinesInFileRegex removes lines matching the regex pattern from the file.
|
||||
func DeleteLinesInFileRegex(path, pattern string, dryRun bool) (DeleteResult, error) {
|
||||
result := DeleteResult{File: path}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
re, err := compilePattern(pattern)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return result, err
|
||||
}
|
||||
|
||||
var kept []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
if re.MatchString(line) {
|
||||
result.DeletedLines = append(result.DeletedLines, lineNum)
|
||||
} else {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if len(result.DeletedLines) == 0 || dryRun {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
content := ""
|
||||
if len(kept) > 0 {
|
||||
content = strings.Join(kept, "\n") + "\n"
|
||||
}
|
||||
return result, atomicWrite(path, content)
|
||||
}
|
||||
|
||||
func PrintDeleteResults(results []DeleteResult) {
|
||||
totalFiles := 0
|
||||
totalLines := 0
|
||||
for _, r := range results {
|
||||
if len(r.DeletedLines) == 0 {
|
||||
continue
|
||||
}
|
||||
totalFiles++
|
||||
totalLines += len(r.DeletedLines)
|
||||
nums := formatLineNums(r.DeletedLines)
|
||||
fmt.Printf("%s: deleted lines %s\n", r.File, nums)
|
||||
}
|
||||
fmt.Printf("%d file(s) modified, %d line(s) deleted.\n", totalFiles, totalLines)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeleteLinesInFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
pattern string
|
||||
dryRun bool
|
||||
wantDeleted []int
|
||||
wantContent string
|
||||
}{
|
||||
{
|
||||
name: "delete single matching line",
|
||||
content: "keep this\ndelete foo\nkeep this too\n",
|
||||
pattern: "foo",
|
||||
wantDeleted: []int{2},
|
||||
wantContent: "keep this\nkeep this too\n",
|
||||
},
|
||||
{
|
||||
name: "delete multiple matching lines",
|
||||
content: "foo line\nkeep\nanother foo\n",
|
||||
pattern: "foo",
|
||||
wantDeleted: []int{1, 3},
|
||||
wantContent: "keep\n",
|
||||
},
|
||||
{
|
||||
name: "delete all lines",
|
||||
content: "foo\nfoo\nfoo\n",
|
||||
pattern: "foo",
|
||||
wantDeleted: []int{1, 2, 3},
|
||||
wantContent: "",
|
||||
},
|
||||
{
|
||||
name: "no match leaves file unchanged",
|
||||
content: "line one\nline two\n",
|
||||
pattern: "xyz",
|
||||
wantDeleted: nil,
|
||||
wantContent: "line one\nline two\n",
|
||||
},
|
||||
{
|
||||
name: "dry run reports deletions but does not write",
|
||||
content: "foo bar\nkeep\n",
|
||||
pattern: "foo",
|
||||
dryRun: true,
|
||||
wantDeleted: []int{1},
|
||||
wantContent: "foo bar\nkeep\n",
|
||||
},
|
||||
{
|
||||
name: "pattern matches substring of line",
|
||||
content: "prefix_foo_suffix\nclean\n",
|
||||
pattern: "foo",
|
||||
wantDeleted: []int{1},
|
||||
wantContent: "clean\n",
|
||||
},
|
||||
{
|
||||
name: "empty file",
|
||||
content: "",
|
||||
pattern: "foo",
|
||||
wantDeleted: nil,
|
||||
wantContent: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeTempFile(t, tt.content)
|
||||
res, err := DeleteLinesInFile(path, tt.pattern, tt.dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !equalIntSlice(res.DeletedLines, tt.wantDeleted) {
|
||||
t.Errorf("DeletedLines = %v, want %v", res.DeletedLines, tt.wantDeleted)
|
||||
}
|
||||
got := readTempFile(t, path)
|
||||
if got != tt.wantContent {
|
||||
t.Errorf("file content = %q, want %q", got, tt.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteLinesInFile_MissingFile(t *testing.T) {
|
||||
_, err := DeleteLinesInFile("/no/such/file.txt", "foo", false)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteLinesInFileRegex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
pattern string
|
||||
dryRun bool
|
||||
wantDeleted []int
|
||||
wantContent string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "delete lines matching regex",
|
||||
content: "error: bad thing\ninfo: ok\nerror: another\n",
|
||||
pattern: `^error:`,
|
||||
wantDeleted: []int{1, 3},
|
||||
wantContent: "info: ok\n",
|
||||
},
|
||||
{
|
||||
name: "digit pattern",
|
||||
content: "line 1\nno digits\nline 2\n",
|
||||
pattern: `\d`,
|
||||
wantDeleted: []int{1, 3},
|
||||
wantContent: "no digits\n",
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
content: "hello\nworld\n",
|
||||
pattern: `\d+`,
|
||||
wantDeleted: nil,
|
||||
wantContent: "hello\nworld\n",
|
||||
},
|
||||
{
|
||||
name: "invalid regex returns error",
|
||||
content: "foo\n",
|
||||
pattern: `[bad`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "dry run with regex",
|
||||
content: "debug log\ninfo log\n",
|
||||
pattern: `^debug`,
|
||||
dryRun: true,
|
||||
wantDeleted: []int{1},
|
||||
wantContent: "debug log\ninfo log\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeTempFile(t, tt.content)
|
||||
res, err := DeleteLinesInFileRegex(path, tt.pattern, tt.dryRun)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !equalIntSlice(res.DeletedLines, tt.wantDeleted) {
|
||||
t.Errorf("DeletedLines = %v, want %v", res.DeletedLines, tt.wantDeleted)
|
||||
}
|
||||
got := readTempFile(t, path)
|
||||
if got != tt.wantContent {
|
||||
t.Errorf("file content = %q, want %q", got, tt.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ReplaceResult struct {
|
||||
File string
|
||||
ChangedLines []int
|
||||
}
|
||||
|
||||
// ReplaceInFile replaces all occurrences of from with to in the file at path.
|
||||
// Returns the 1-indexed line numbers that were changed. If dryRun is true,
|
||||
// the file is not written.
|
||||
func ReplaceInFile(path, from, to string, dryRun bool) (ReplaceResult, error) {
|
||||
result := ReplaceResult{File: path}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
replaced := strings.ReplaceAll(line, from, to)
|
||||
if replaced != line {
|
||||
result.ChangedLines = append(result.ChangedLines, lineNum)
|
||||
}
|
||||
lines = append(lines, replaced)
|
||||
}
|
||||
f.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if len(result.ChangedLines) == 0 || dryRun {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return result, atomicWrite(path, strings.Join(lines, "\n")+trailingNewline(lines))
|
||||
}
|
||||
|
||||
// ReplaceInFileRegex replaces regex matches of pattern with repl in the file at path.
|
||||
func ReplaceInFileRegex(path, pattern, repl string, dryRun bool) (ReplaceResult, error) {
|
||||
result := ReplaceResult{File: path}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
re, err := compilePattern(pattern)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return result, err
|
||||
}
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
replaced := re.ReplaceAllString(line, repl)
|
||||
if replaced != line {
|
||||
result.ChangedLines = append(result.ChangedLines, lineNum)
|
||||
}
|
||||
lines = append(lines, replaced)
|
||||
}
|
||||
f.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if len(result.ChangedLines) == 0 || dryRun {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return result, atomicWrite(path, strings.Join(lines, "\n")+trailingNewline(lines))
|
||||
}
|
||||
|
||||
func PrintReplaceResults(results []ReplaceResult) {
|
||||
total := 0
|
||||
for _, r := range results {
|
||||
if len(r.ChangedLines) == 0 {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
nums := formatLineNums(r.ChangedLines)
|
||||
fmt.Printf("%s: lines %s\n", r.File, nums)
|
||||
}
|
||||
fmt.Printf("%d file(s) modified.\n", total)
|
||||
}
|
||||
|
||||
func atomicWrite(path, content string) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".text-shark-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.WriteString(content); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
func trailingNewline(lines []string) string {
|
||||
if len(lines) > 0 {
|
||||
return "\n"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatLineNums(nums []int) string {
|
||||
parts := make([]string, len(nums))
|
||||
for i, n := range nums {
|
||||
parts[i] = fmt.Sprintf("%d", n)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTempFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp(t.TempDir(), "ts-test-*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func readTempFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func equalIntSlice(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestReplaceInFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
from, to string
|
||||
dryRun bool
|
||||
wantLines []int
|
||||
wantContent string
|
||||
}{
|
||||
{
|
||||
name: "single occurrence",
|
||||
content: "foo bar\nbaz\n",
|
||||
from: "foo",
|
||||
to: "qux",
|
||||
wantLines: []int{1},
|
||||
wantContent: "qux bar\nbaz\n",
|
||||
},
|
||||
{
|
||||
name: "multiple occurrences on same line",
|
||||
content: "foo foo foo\n",
|
||||
from: "foo",
|
||||
to: "x",
|
||||
wantLines: []int{1},
|
||||
wantContent: "x x x\n",
|
||||
},
|
||||
{
|
||||
name: "occurrences across multiple lines",
|
||||
content: "keep\nfoo here\nalso foo\n",
|
||||
from: "foo",
|
||||
to: "bar",
|
||||
wantLines: []int{2, 3},
|
||||
wantContent: "keep\nbar here\nalso bar\n",
|
||||
},
|
||||
{
|
||||
name: "no match leaves file unchanged",
|
||||
content: "hello world\n",
|
||||
from: "xyz",
|
||||
to: "abc",
|
||||
wantLines: nil,
|
||||
wantContent: "hello world\n",
|
||||
},
|
||||
{
|
||||
name: "dry run reports lines but does not write",
|
||||
content: "foo bar\n",
|
||||
from: "foo",
|
||||
to: "baz",
|
||||
dryRun: true,
|
||||
wantLines: []int{1},
|
||||
wantContent: "foo bar\n",
|
||||
},
|
||||
{
|
||||
name: "empty file",
|
||||
content: "",
|
||||
from: "foo",
|
||||
to: "bar",
|
||||
wantLines: nil,
|
||||
wantContent: "",
|
||||
},
|
||||
{
|
||||
name: "replacement on last line without trailing newline",
|
||||
content: "line1\nfoo",
|
||||
from: "foo",
|
||||
to: "bar",
|
||||
wantLines: []int{2},
|
||||
wantContent: "line1\nbar\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeTempFile(t, tt.content)
|
||||
res, err := ReplaceInFile(path, tt.from, tt.to, tt.dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !equalIntSlice(res.ChangedLines, tt.wantLines) {
|
||||
t.Errorf("ChangedLines = %v, want %v", res.ChangedLines, tt.wantLines)
|
||||
}
|
||||
got := readTempFile(t, path)
|
||||
if got != tt.wantContent {
|
||||
t.Errorf("file content = %q, want %q", got, tt.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceInFile_MissingFile(t *testing.T) {
|
||||
_, err := ReplaceInFile("/no/such/file.txt", "x", "y", false)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceInFileRegex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
pattern string
|
||||
repl string
|
||||
dryRun bool
|
||||
wantLines []int
|
||||
wantContent string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic regex replacement",
|
||||
content: "foo123\nbar\nfoo456\n",
|
||||
pattern: `foo\d+`,
|
||||
repl: "replaced",
|
||||
wantLines: []int{1, 3},
|
||||
wantContent: "replaced\nbar\nreplaced\n",
|
||||
},
|
||||
{
|
||||
name: "capture group substitution",
|
||||
content: "hello world\n",
|
||||
pattern: `(hello) (world)`,
|
||||
repl: "$2 $1",
|
||||
wantLines: []int{1},
|
||||
wantContent: "world hello\n",
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
content: "no match here\n",
|
||||
pattern: `\d+`,
|
||||
repl: "NUM",
|
||||
wantLines: nil,
|
||||
wantContent: "no match here\n",
|
||||
},
|
||||
{
|
||||
name: "invalid regex returns error",
|
||||
content: "foo\n",
|
||||
pattern: `[invalid`,
|
||||
repl: "x",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "dry run with regex",
|
||||
content: "abc123\n",
|
||||
pattern: `\d+`,
|
||||
repl: "NUM",
|
||||
dryRun: true,
|
||||
wantLines: []int{1},
|
||||
wantContent: "abc123\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeTempFile(t, tt.content)
|
||||
res, err := ReplaceInFileRegex(path, tt.pattern, tt.repl, tt.dryRun)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !equalIntSlice(res.ChangedLines, tt.wantLines) {
|
||||
t.Errorf("ChangedLines = %v, want %v", res.ChangedLines, tt.wantLines)
|
||||
}
|
||||
got := readTempFile(t, path)
|
||||
if got != tt.wantContent {
|
||||
t.Errorf("file content = %q, want %q", got, tt.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLineNums(t *testing.T) {
|
||||
tests := []struct {
|
||||
nums []int
|
||||
want string
|
||||
}{
|
||||
{[]int{1}, "1"},
|
||||
{[]int{1, 2, 3}, "1, 2, 3"},
|
||||
{[]int{10, 47}, "10, 47"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := formatLineNums(tt.nums)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatLineNums(%v) = %q, want %q", tt.nums, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package fileops
|
||||
|
||||
import "regexp"
|
||||
|
||||
func compilePattern(pattern string) (*regexp.Regexp, error) {
|
||||
return regexp.Compile(pattern)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// FdListFiles runs fd to find files/dirs matching pattern under path.
|
||||
// extraArgs are passed directly to fd (e.g. "--type", "f").
|
||||
func FdListFiles(pattern, path string, extraArgs ...string) ([]string, error) {
|
||||
args := append([]string{pattern, path}, extraArgs...)
|
||||
cmd := exec.Command("fd", args...)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
if cmd.ProcessState != nil && cmd.ProcessState.ExitCode() == 1 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return splitLines(out.String()), nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFdListFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTempFile(t, dir, "foo_old.txt", "")
|
||||
writeTempFile(t, dir, "foo_backup.txt", "")
|
||||
writeTempFile(t, dir, "keep.txt", "")
|
||||
|
||||
files, err := FdListFiles("foo", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("want 2 files, got %d: %v", len(files), files)
|
||||
}
|
||||
sort.Strings(files)
|
||||
if filepath.Base(files[0]) != "foo_backup.txt" || filepath.Base(files[1]) != "foo_old.txt" {
|
||||
t.Errorf("unexpected files: %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFdListFiles_NoMatches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTempFile(t, dir, "keep.txt", "")
|
||||
|
||||
files, err := FdListFiles("no_match_pattern_xyz", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("want 0 files, got %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFdListFiles_TypeFilter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTempFile(t, dir, "foo_file.txt", "")
|
||||
|
||||
// With --type f should find the file
|
||||
files, err := FdListFiles("foo", dir, "--type", "f")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(files) != 1 || filepath.Base(files[0]) != "foo_file.txt" {
|
||||
t.Errorf("want [foo_file.txt], got %v", files)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Stream runs rg with args, streaming stdout/stderr directly to the terminal.
|
||||
func RgStream(args ...string) error {
|
||||
cmd := exec.Command("rg", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// ListFiles runs rg -l (files-with-matches) and returns the list of matching file paths.
|
||||
func RgListFiles(pattern, path string, extraArgs ...string) ([]string, error) {
|
||||
args := append([]string{"-l", pattern, path}, extraArgs...)
|
||||
cmd := exec.Command("rg", args...)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = io.Discard
|
||||
if err := cmd.Run(); err != nil {
|
||||
// exit code 1 means no matches — not an error for our purposes
|
||||
if cmd.ProcessState != nil && cmd.ProcessState.ExitCode() == 1 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return splitLines(out.String()), nil
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
s = strings.TrimRight(s, "\n")
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTempFile(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestRgListFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTempFile(t, dir, "a.txt", "foo bar\nbaz\n")
|
||||
writeTempFile(t, dir, "b.txt", "no match here\n")
|
||||
writeTempFile(t, dir, "c.txt", "another foo\n")
|
||||
|
||||
files, err := RgListFiles("foo", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("want 2 files, got %d: %v", len(files), files)
|
||||
}
|
||||
sort.Strings(files)
|
||||
if filepath.Base(files[0]) != "a.txt" || filepath.Base(files[1]) != "c.txt" {
|
||||
t.Errorf("unexpected files: %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRgListFiles_NoMatches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTempFile(t, dir, "a.txt", "hello world\n")
|
||||
|
||||
files, err := RgListFiles("xyz_no_match", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if files != nil {
|
||||
t.Errorf("want nil, got %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRgListFiles_CaseInsensitive(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTempFile(t, dir, "a.txt", "FOO bar\n")
|
||||
writeTempFile(t, dir, "b.txt", "no match\n")
|
||||
|
||||
files, err := RgListFiles("foo", dir, "-i")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(files) != 1 || filepath.Base(files[0]) != "a.txt" {
|
||||
t.Errorf("want [a.txt], got %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"", nil},
|
||||
{"a\n", []string{"a"}},
|
||||
{"a\nb\n", []string{"a", "b"}},
|
||||
{"a\nb\nc", []string{"a", "b", "c"}},
|
||||
{"\n\n", nil}, // only trailing newlines — trimmed to empty, returns nil
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := splitLines(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("splitLines(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("splitLines(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "github.com/jasonross/text-shark/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
world
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
debug log entry
|
||||
normal line
|
||||
Vendored
Vendored
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user