feat(processor): add git-aware comment removal with line-based processing

- Add processGitChanges() for git-aware operation routing
- Implement line-based comment removal with precise targeting
- Add GitTotals tracking for comprehensive dry-run reporting
- Create showGitPreviewWithTotals() with enhanced preview display
- Fix spacing preservation (only trim when comments are removed)
- Include comprehensive test suite for comment removal logic
This commit is contained in:
carraes
2025-07-05 22:28:27 -03:00
parent 161b5e1862
commit 79b6d28791
3 changed files with 504 additions and 16 deletions
+326
View File
@@ -0,0 +1,326 @@
package processor
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/carlosarraes/shush/internal/git"
"github.com/carlosarraes/shush/internal/types"
"github.com/fatih/color"
)
type GitTotals struct {
FilesProcessed int
TotalChanged int
TotalKept int
}
func (p *Processor) processGitChanges() error {
gitStatus, err := git.DetectRepo()
if err != nil {
return fmt.Errorf("failed to detect git repository: %v", err)
}
if !gitStatus.IsRepo {
return fmt.Errorf("not in a git repository. Use without git flags to process files normally")
}
if p.cli.Verbose {
fmt.Printf("Git repository detected: %s\n", gitStatus.RootDir)
}
var changes []git.FileChange
switch {
case p.cli.ChangesOnly:
changes, err = git.GetChangesOnly()
if err != nil {
return fmt.Errorf("failed to get git changes: %v", err)
}
case p.cli.Staged:
changes, err = git.GetStagedChanges()
if err != nil {
return fmt.Errorf("failed to get staged changes: %v", err)
}
case p.cli.Unstaged:
changes, err = git.GetUnstagedChanges()
if err != nil {
return fmt.Errorf("failed to get unstaged changes: %v", err)
}
}
if len(changes) == 0 {
fmt.Println("No changes found to process")
return nil
}
if p.cli.Verbose {
fmt.Printf("Found %d files with changes to process\n", len(changes))
}
totals := &GitTotals{}
for _, change := range changes {
if !IsSupportedFile(change.Path) {
if p.cli.Verbose {
fmt.Printf("Skipping unsupported file: %s\n", change.Path)
}
continue
}
if p.cli.Verbose {
fmt.Printf("Processing: %s\n", change.Path)
}
if p.cli.DryRun {
if err := p.showGitPreviewWithTotals(change.Path, change.LineRanges, totals); err != nil {
fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", change.Path, err)
continue
}
} else {
if err := p.processFileWithLineRanges(change.Path, change.LineRanges); err != nil {
fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", change.Path, err)
continue
}
}
totals.FilesProcessed++
}
if p.cli.DryRun && totals.FilesProcessed > 0 {
p.showGitTotals(totals)
}
return nil
}
func (p *Processor) processFileWithLineRanges(filename string, lineRanges []git.LineRange) error {
language, err := DetectLanguage(filename)
if err != nil {
return err
}
if p.cli.Verbose {
fmt.Printf("Processing %s (language: %s)\n", filename, GetLanguageName(filename))
if len(lineRanges) == 0 {
fmt.Printf("Processing entire file (untracked)\n")
} else {
fmt.Printf("Processing %d line ranges\n", len(lineRanges))
}
}
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return err
}
if p.cli.Backup {
if err := p.createBackup(filename); err != nil {
return fmt.Errorf("failed to create backup: %v", err)
}
if p.cli.Verbose {
fmt.Printf("✓ Backup created: %s.bak\n", filename)
}
}
processEntireFile := len(lineRanges) == 0
modified := false
for i, line := range lines {
lineNum := i + 1
shouldProcess := processEntireFile || git.IsInLineRanges(lineNum, lineRanges)
if shouldProcess {
newLine := p.removeCommentsFromLine(line, language)
if newLine != line {
lines[i] = newLine
modified = true
}
}
}
if modified {
outFile, err := os.Create(filename)
if err != nil {
return err
}
defer outFile.Close()
for _, line := range lines {
if _, err := fmt.Fprintln(outFile, line); err != nil {
return err
}
}
if p.cli.Verbose {
fmt.Printf("✓ Comments removed from %s\n", filename)
}
} else if p.cli.Verbose {
fmt.Printf("✓ No changes made to %s\n", filename)
}
return nil
}
func (p *Processor) removeCommentsFromLine(line string, language types.Language) string {
result := line
hasChanges := false
if !p.cli.Block && language.LineComment != "" {
if idx := strings.Index(result, language.LineComment); idx != -1 {
result = result[:idx]
hasChanges = true
}
}
if !p.cli.Inline && language.BlockComment != nil {
startComment := language.BlockComment.Start
endComment := language.BlockComment.End
for {
startIdx := strings.Index(result, startComment)
if startIdx == -1 {
break
}
endIdx := strings.Index(result[startIdx:], endComment)
if endIdx == -1 {
result = result[:startIdx]
hasChanges = true
break
}
endIdx += startIdx + len(endComment)
result = result[:startIdx] + result[endIdx:]
hasChanges = true
}
}
if hasChanges {
result = strings.TrimSpace(result)
}
return result
}
func (p *Processor) showGitPreviewWithTotals(filename string, lineRanges []git.LineRange, totals *GitTotals) error {
language, err := DetectLanguage(filename)
if err != nil {
return err
}
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
red := color.New(color.FgRed, color.CrossedOut)
green := color.New(color.FgGreen)
blue := color.New(color.FgBlue)
gray := color.New(color.FgHiBlack)
yellow := color.New(color.FgYellow)
fmt.Printf("\n%s %s\n", yellow.Sprint("Git Preview:"), filename)
if len(lineRanges) == 0 {
fmt.Printf("%s\n", blue.Sprint("Processing entire file (untracked)"))
} else {
fmt.Printf("%s %d line ranges\n", blue.Sprint("Processing"), len(lineRanges))
}
fmt.Println()
scanner := bufio.NewScanner(file)
lineNum := 0
keptCount := 0
changedCount := 0
processEntireFile := len(lineRanges) == 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
shouldProcess := processEntireFile || git.IsInLineRanges(lineNum, lineRanges)
lineNumStr := gray.Sprintf("%4d", lineNum)
if shouldProcess {
newLine := p.removeCommentsFromLine(line, language)
if newLine != line {
changedCount++
fmt.Printf("%s %s %s\n", lineNumStr, red.Sprint("~"), red.Sprint(line))
if strings.TrimSpace(newLine) != "" {
fmt.Printf("%s %s %s\n", lineNumStr, green.Sprint("+"), green.Sprint(newLine))
}
} else {
keptCount++
fmt.Printf("%s %s %s\n", lineNumStr, green.Sprint(" "), line)
}
} else {
keptCount++
fmt.Printf("%s %s %s\n", lineNumStr, gray.Sprint(" "), gray.Sprint(line))
}
}
if err := scanner.Err(); err != nil {
return err
}
fmt.Printf("\n%s\n", strings.Repeat("-", 50))
fmt.Printf("%s %d lines would be changed\n", yellow.Sprint("~"), changedCount)
fmt.Printf("%s %d lines would be kept\n\n", green.Sprint("✓"), keptCount)
totals.TotalChanged += changedCount
totals.TotalKept += keptCount
return nil
}
func (p *Processor) showGitTotals(totals *GitTotals) {
yellow := color.New(color.FgYellow)
green := color.New(color.FgGreen)
blue := color.New(color.FgBlue)
fmt.Printf("\n%s\n", strings.Repeat("=", 60))
fmt.Printf("%s\n", blue.Sprint("GIT PROCESSING TOTALS"))
fmt.Printf("%s\n", strings.Repeat("=", 60))
fmt.Printf("%s %d files processed\n", blue.Sprint("📁"), totals.FilesProcessed)
fmt.Printf("%s %d lines would be changed\n", yellow.Sprint("~"), totals.TotalChanged)
fmt.Printf("%s %d lines would be kept\n", green.Sprint("✓"), totals.TotalKept)
fmt.Printf("%s\n", strings.Repeat("=", 60))
}
+156
View File
@@ -0,0 +1,156 @@
package processor
import (
"testing"
"github.com/carlosarraes/shush/internal/types"
)
func TestRemoveCommentsFromLine(t *testing.T) {
cli := types.CLI{}
p := &Processor{cli: cli}
jsLanguage := types.Language{
LineComment: "
BlockComment: &types.BlockComment{
Start: "
End: "*/",
},
}
pyLanguage := types.Language{
LineComment: "#",
}
tests := []struct {
name string
line string
language types.Language
cli types.CLI
expected string
}{
{
name: "line comment removal - JavaScript",
line: "console.log('hello');
language: jsLanguage,
cli: types.CLI{},
expected: "console.log('hello');",
},
{
name: "line comment removal - Python",
line: "print('hello') # This is a comment",
language: pyLanguage,
cli: types.CLI{},
expected: "print('hello')",
},
{
name: "block comment removal - single line",
line: "var x = 5; var y = 10;",
language: jsLanguage,
cli: types.CLI{},
expected: "var x = 5; var y = 10;",
},
{
name: "only line comment when inline flag set",
line: "code();
language: jsLanguage,
cli: types.CLI{Inline: true},
expected: "code();",
},
{
name: "only block comment when block flag set",
line: "code();
language: jsLanguage,
cli: types.CLI{Block: true},
expected: "code();
},
{
name: "no comments to remove",
line: "var x = 5;",
language: jsLanguage,
cli: types.CLI{},
expected: "var x = 5;",
},
{
name: "comment-only line",
line: "
language: jsLanguage,
cli: types.CLI{},
expected: "",
},
{
name: "multiple block comments",
line: " code ",
language: jsLanguage,
cli: types.CLI{},
expected: "code",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p.cli = tt.cli
result := p.removeCommentsFromLine(tt.line, tt.language)
if result != tt.expected {
t.Errorf("removeCommentsFromLine() = %q, want %q", result, tt.expected)
}
})
}
}
func TestRemoveCommentsFromLineEdgeCases(t *testing.T) {
cli := types.CLI{}
p := &Processor{cli: cli}
jsLanguage := types.Language{
LineComment: "
BlockComment: &types.BlockComment{
Start: "
End: "*/",
},
}
tests := []struct {
name string
line string
expected string
}{
{
name: "empty line",
line: "",
expected: "",
},
{
name: "whitespace only",
line: " \t ",
expected: " \t ",
},
{
name: "comment at start",
line: "
expected: "",
},
{
name: "comment with special characters",
line: "code();
expected: "code();",
},
{
name: "comment after code",
line: `console.log("hello");
expected: `console.log("hello");`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := p.removeCommentsFromLine(tt.line, jsLanguage)
if result != tt.expected {
t.Errorf("removeCommentsFromLine() = %q, want %q", result, tt.expected)
}
})
}
}
+22 -16
View File
@@ -23,6 +23,12 @@ func New(cli types.CLI) *Processor {
} }
func (p *Processor) Process() error { func (p *Processor) Process() error {
if p.cli.ChangesOnly || p.cli.Staged || p.cli.Unstaged {
return p.processGitChanges()
}
info, err := os.Stat(p.cli.Path) info, err := os.Stat(p.cli.Path)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return fmt.Errorf("path not found: %s", p.cli.Path) return fmt.Errorf("path not found: %s", p.cli.Path)
@@ -34,13 +40,13 @@ func (p *Processor) Process() error {
if info.IsDir() { if info.IsDir() {
return p.processDirectory(p.cli.Path) return p.processDirectory(p.cli.Path)
} }
return p.processFile(p.cli.Path) return p.processFile(p.cli.Path)
} }
func (p *Processor) processDirectory(dirPath string) error { func (p *Processor) processDirectory(dirPath string) error {
var files []string var files []string
if p.cli.Recursive { if p.cli.Recursive {
err := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error { err := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
@@ -59,7 +65,7 @@ func (p *Processor) processDirectory(dirPath string) error {
if err != nil { if err != nil {
return err return err
} }
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() { if !entry.IsDir() {
fullPath := filepath.Join(dirPath, entry.Name()) fullPath := filepath.Join(dirPath, entry.Name())
@@ -82,7 +88,7 @@ func (p *Processor) processDirectory(dirPath string) error {
if p.cli.Verbose { if p.cli.Verbose {
fmt.Printf("Processing: %s\n", file) fmt.Printf("Processing: %s\n", file)
} }
if err := p.processFile(file); err != nil { if err := p.processFile(file); err != nil {
fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", file, err) fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", file, err)
continue continue
@@ -102,7 +108,7 @@ func (p *Processor) processFile(filename string) error {
fmt.Printf("Processing %s...\n", filename) fmt.Printf("Processing %s...\n", filename)
fmt.Printf("Detected language: %s\n", GetLanguageName(filename)) fmt.Printf("Detected language: %s\n", GetLanguageName(filename))
if language.BlockComment != nil { if language.BlockComment != nil {
fmt.Printf("Comment types: line (%s), block (%s %s)\n", fmt.Printf("Comment types: line (%s), block (%s %s)\n",
language.LineComment, language.BlockComment.Start, language.BlockComment.End) language.LineComment, language.BlockComment.Start, language.BlockComment.End)
} else { } else {
fmt.Printf("Comment types: line (%s)\n", language.LineComment) fmt.Printf("Comment types: line (%s)\n", language.LineComment)
@@ -112,7 +118,7 @@ func (p *Processor) processFile(filename string) error {
if p.cli.DryRun { if p.cli.DryRun {
return p.showPreview(filename, language) return p.showPreview(filename, language)
} }
sedCmd := p.buildSedCommand(language) sedCmd := p.buildSedCommand(language)
if p.cli.Backup { if p.cli.Backup {
@@ -142,7 +148,7 @@ func (p *Processor) processFile(filename string) error {
func (p *Processor) buildSedCommand(language types.Language) string { func (p *Processor) buildSedCommand(language types.Language) string {
var commands []string var commands []string
if !p.cli.Block && language.LineComment != "" { if !p.cli.Block && language.LineComment != "" {
escaped := escapeForSed(language.LineComment) escaped := escapeForSed(language.LineComment)
// Delete lines that are only comments (optionally with whitespace) // Delete lines that are only comments (optionally with whitespace)
@@ -150,7 +156,7 @@ func (p *Processor) buildSedCommand(language types.Language) string {
// Remove inline comments but keep the line // Remove inline comments but keep the line
commands = append(commands, fmt.Sprintf("s/%s.*//g", escaped)) commands = append(commands, fmt.Sprintf("s/%s.*//g", escaped))
} }
if !p.cli.Inline && language.BlockComment != nil { if !p.cli.Inline && language.BlockComment != nil {
startEscaped := escapeForSed(language.BlockComment.Start) startEscaped := escapeForSed(language.BlockComment.Start)
endEscaped := escapeForSed(language.BlockComment.End) endEscaped := escapeForSed(language.BlockComment.End)
@@ -159,9 +165,9 @@ func (p *Processor) buildSedCommand(language types.Language) string {
// Remove multi-line block comments // Remove multi-line block comments
commands = append(commands, fmt.Sprintf("/%s/,/%s/d", startEscaped, endEscaped)) commands = append(commands, fmt.Sprintf("/%s/,/%s/d", startEscaped, endEscaped))
} }
// Don't remove empty lines - preserve original file structure // Don't remove empty lines - preserve original file structure
return strings.Join(commands, "; ") return strings.Join(commands, "; ")
} }
@@ -181,19 +187,19 @@ func escapeForSed(pattern string) string {
func (p *Processor) createBackup(filename string) error { func (p *Processor) createBackup(filename string) error {
backupName := filename + ".bak" backupName := filename + ".bak"
srcFile, err := os.Open(filename) srcFile, err := os.Open(filename)
if err != nil { if err != nil {
return err return err
} }
defer srcFile.Close() defer srcFile.Close()
dstFile, err := os.Create(backupName) dstFile, err := os.Create(backupName)
if err != nil { if err != nil {
return err return err
} }
defer dstFile.Close() defer dstFile.Close()
buffer := make([]byte, 1024) buffer := make([]byte, 1024)
for { for {
n, err := srcFile.Read(buffer) n, err := srcFile.Read(buffer)
@@ -203,12 +209,12 @@ func (p *Processor) createBackup(filename string) error {
if n == 0 { if n == 0 {
break break
} }
if _, err := dstFile.Write(buffer[:n]); err != nil { if _, err := dstFile.Write(buffer[:n]); err != nil {
return err return err
} }
} }
return nil return nil
} }
@@ -284,4 +290,4 @@ func (p *Processor) showPreview(filename string, language types.Language) error
fmt.Printf("%s %d lines would be kept\n\n", green.Sprint("✓"), keptCount) fmt.Printf("%s %d lines would be kept\n\n", green.Sprint("✓"), keptCount)
return nil return nil
} }