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:
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,12 @@ func New(cli types.CLI) *Processor {
|
||||
}
|
||||
|
||||
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)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("path not found: %s", p.cli.Path)
|
||||
@@ -34,13 +40,13 @@ func (p *Processor) Process() error {
|
||||
if info.IsDir() {
|
||||
return p.processDirectory(p.cli.Path)
|
||||
}
|
||||
|
||||
|
||||
return p.processFile(p.cli.Path)
|
||||
}
|
||||
|
||||
func (p *Processor) processDirectory(dirPath string) error {
|
||||
var files []string
|
||||
|
||||
|
||||
if p.cli.Recursive {
|
||||
err := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -59,7 +65,7 @@ func (p *Processor) processDirectory(dirPath string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
fullPath := filepath.Join(dirPath, entry.Name())
|
||||
@@ -82,7 +88,7 @@ func (p *Processor) processDirectory(dirPath string) error {
|
||||
if p.cli.Verbose {
|
||||
fmt.Printf("Processing: %s\n", file)
|
||||
}
|
||||
|
||||
|
||||
if err := p.processFile(file); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", file, err)
|
||||
continue
|
||||
@@ -102,7 +108,7 @@ func (p *Processor) processFile(filename string) error {
|
||||
fmt.Printf("Processing %s...\n", filename)
|
||||
fmt.Printf("Detected language: %s\n", GetLanguageName(filename))
|
||||
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)
|
||||
} else {
|
||||
fmt.Printf("Comment types: line (%s)\n", language.LineComment)
|
||||
@@ -112,7 +118,7 @@ func (p *Processor) processFile(filename string) error {
|
||||
if p.cli.DryRun {
|
||||
return p.showPreview(filename, language)
|
||||
}
|
||||
|
||||
|
||||
sedCmd := p.buildSedCommand(language)
|
||||
|
||||
if p.cli.Backup {
|
||||
@@ -142,7 +148,7 @@ func (p *Processor) processFile(filename string) error {
|
||||
|
||||
func (p *Processor) buildSedCommand(language types.Language) string {
|
||||
var commands []string
|
||||
|
||||
|
||||
if !p.cli.Block && language.LineComment != "" {
|
||||
escaped := escapeForSed(language.LineComment)
|
||||
// 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
|
||||
commands = append(commands, fmt.Sprintf("s/%s.*//g", escaped))
|
||||
}
|
||||
|
||||
|
||||
if !p.cli.Inline && language.BlockComment != nil {
|
||||
startEscaped := escapeForSed(language.BlockComment.Start)
|
||||
endEscaped := escapeForSed(language.BlockComment.End)
|
||||
@@ -159,9 +165,9 @@ func (p *Processor) buildSedCommand(language types.Language) string {
|
||||
// Remove multi-line block comments
|
||||
commands = append(commands, fmt.Sprintf("/%s/,/%s/d", startEscaped, endEscaped))
|
||||
}
|
||||
|
||||
|
||||
// Don't remove empty lines - preserve original file structure
|
||||
|
||||
|
||||
return strings.Join(commands, "; ")
|
||||
}
|
||||
|
||||
@@ -181,19 +187,19 @@ func escapeForSed(pattern string) string {
|
||||
|
||||
func (p *Processor) createBackup(filename string) error {
|
||||
backupName := filename + ".bak"
|
||||
|
||||
|
||||
srcFile, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
|
||||
dstFile, err := os.Create(backupName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
|
||||
buffer := make([]byte, 1024)
|
||||
for {
|
||||
n, err := srcFile.Read(buffer)
|
||||
@@ -203,12 +209,12 @@ func (p *Processor) createBackup(filename string) error {
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
if _, err := dstFile.Write(buffer[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user