text editing util

This commit is contained in:
2026-06-03 15:49:54 -05:00
commit 07e1d6d12b
25 changed files with 1415 additions and 0 deletions
+21
View File
@@ -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 ./...
+56
View File
@@ -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
+169
View File
@@ -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 ./...
```
+73
View File
@@ -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
View File
@@ -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")
}
+75
View File
@@ -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)")
}
+73
View File
@@ -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
View File
@@ -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)
}
+9
View File
@@ -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
)
+10
View File
@@ -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=
+109
View File
@@ -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)
}
+162
View File
@@ -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)
}
})
}
}
+134
View File
@@ -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, ", ")
}
+227
View File
@@ -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)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
package fileops
import "regexp"
func compilePattern(pattern string) (*regexp.Regexp, error) {
return regexp.Compile(pattern)
}
+22
View File
@@ -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
}
+53
View File
@@ -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)
}
}
+42
View File
@@ -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")
}
+88
View File
@@ -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])
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
package main
import "github.com/jasonross/text-shark/cmd"
func main() {
cmd.Execute()
}
+1
View File
@@ -0,0 +1 @@
world
+2
View File
@@ -0,0 +1,2 @@
debug log entry
normal line
View File
View File
Executable
BIN
View File
Binary file not shown.