commit 07e1d6d12b24a6fc2db9ec8f4e84958b4c10f600 Author: Jason Ross Date: Wed Jun 3 15:49:54 2026 -0500 text editing util diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7574164 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6ad29df --- /dev/null +++ b/.github/workflows/release.yml @@ -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<> "$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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..eeb18e7 --- /dev/null +++ b/README.md @@ -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 [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 [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 `` 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 [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 [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 ./... +``` diff --git a/cmd/delete_line.go b/cmd/delete_line.go new file mode 100644 index 0000000..ee8a3f7 --- /dev/null +++ b/cmd/delete_line.go @@ -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 [path]", + Short: "Delete all lines matching 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") +} diff --git a/cmd/find.go b/cmd/find.go new file mode 100644 index 0000000..7ca6adc --- /dev/null +++ b/cmd/find.go @@ -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 [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") +} diff --git a/cmd/find_delete_files.go b/cmd/find_delete_files.go new file mode 100644 index 0000000..96364c9 --- /dev/null +++ b/cmd/find_delete_files.go @@ -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 [path]", + Short: "Find files matching (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)") +} diff --git a/cmd/replace.go b/cmd/replace.go new file mode 100644 index 0000000..dc554b9 --- /dev/null +++ b/cmd/replace.go @@ -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 [path]", + Short: "Replace all occurrences of with 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 as a regular expression") +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..778064c --- /dev/null +++ b/cmd/root.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3076f34 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/fileops/delete.go b/internal/fileops/delete.go new file mode 100644 index 0000000..a3a06c3 --- /dev/null +++ b/internal/fileops/delete.go @@ -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) +} diff --git a/internal/fileops/delete_test.go b/internal/fileops/delete_test.go new file mode 100644 index 0000000..e114b26 --- /dev/null +++ b/internal/fileops/delete_test.go @@ -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) + } + }) + } +} diff --git a/internal/fileops/replace.go b/internal/fileops/replace.go new file mode 100644 index 0000000..a40c14a --- /dev/null +++ b/internal/fileops/replace.go @@ -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, ", ") +} diff --git a/internal/fileops/replace_test.go b/internal/fileops/replace_test.go new file mode 100644 index 0000000..ed9571b --- /dev/null +++ b/internal/fileops/replace_test.go @@ -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) + } + } +} diff --git a/internal/fileops/util.go b/internal/fileops/util.go new file mode 100644 index 0000000..6cb3800 --- /dev/null +++ b/internal/fileops/util.go @@ -0,0 +1,7 @@ +package fileops + +import "regexp" + +func compilePattern(pattern string) (*regexp.Regexp, error) { + return regexp.Compile(pattern) +} diff --git a/internal/runner/fd.go b/internal/runner/fd.go new file mode 100644 index 0000000..dc29382 --- /dev/null +++ b/internal/runner/fd.go @@ -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 +} diff --git a/internal/runner/fd_test.go b/internal/runner/fd_test.go new file mode 100644 index 0000000..679de84 --- /dev/null +++ b/internal/runner/fd_test.go @@ -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) + } +} diff --git a/internal/runner/rg.go b/internal/runner/rg.go new file mode 100644 index 0000000..f9d5670 --- /dev/null +++ b/internal/runner/rg.go @@ -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") +} diff --git a/internal/runner/rg_test.go b/internal/runner/rg_test.go new file mode 100644 index 0000000..62d46d2 --- /dev/null +++ b/internal/runner/rg_test.go @@ -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]) + } + } + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..8c0d0b3 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/jasonross/text-shark/cmd" + +func main() { + cmd.Execute() +} diff --git a/testdata/other.txt b/testdata/other.txt new file mode 100644 index 0000000..cc628cc --- /dev/null +++ b/testdata/other.txt @@ -0,0 +1 @@ +world diff --git a/testdata/sample.txt b/testdata/sample.txt new file mode 100644 index 0000000..f428eec --- /dev/null +++ b/testdata/sample.txt @@ -0,0 +1,2 @@ +debug log entry +normal line diff --git a/testdata/todelete/foo_test.txt b/testdata/todelete/foo_test.txt new file mode 100644 index 0000000..e69de29 diff --git a/testdata/todelete/keep.txt b/testdata/todelete/keep.txt new file mode 100644 index 0000000..e69de29 diff --git a/text-shark b/text-shark new file mode 100755 index 0000000..396fc81 Binary files /dev/null and b/text-shark differ