diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 25eba1c..af3c8d4 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -19,6 +19,10 @@ jobs: - name: Build run: go build -v ./... + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: v1.58 - name: Test run: go test -v ./... diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..8e33efb --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,7 @@ +linters: + enable-all: true + disable: + - depguard + - gofumpt + - ireturn + - gochecknoglobals diff --git a/parser/parser.go b/parser/parser.go index fef80ea..17f6028 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -32,19 +32,21 @@ type Line struct { Content string } -func parseLine(line string) (string, error) { +func ParseLine(line string) (string, error) { r := regexp.MustCompile(`^(diff|index|---|\+\+\+|@@)`) if !r.MatchString(line) { return line, nil } - return "", errors.New("no match") + + return "", MatchError() } -func parseBlock(line string) (Block, error) { +func ParseBlock(line string) (Block, error) { r := regexp.MustCompile(`^@@ -([0-9]+),([0-9]+) \+([0-9]+),?([0-9]*) @@`) matches := r.FindAllStringSubmatch(line, -1) + if len(matches) != 1 { - return Block{}, errors.New("match not found") + return Block{}, MatchError() } OldStart, _ := strconv.Atoi(matches[0][1]) @@ -54,34 +56,51 @@ func parseBlock(line string) (Block, error) { NewLines, _ := strconv.Atoi(matches[0][4]) NewEnd := NewStart + NewLines - return Block{OldStart: OldStart, OldEnd: OldEnd, NewStart: NewStart, NewEnd: NewEnd}, nil + return Block{ + OldStart: OldStart, + OldEnd: OldEnd, + NewStart: NewStart, + NewEnd: NewEnd, + LargestLineNumber: 0, + Lines: []Line{}, + }, nil } -func parseFile(line string) (File, error) { +func ParseFile(line string) (File, error) { r := regexp.MustCompile(`^(?:\-\-\- a\/|\+\+\+ b\/)(.*)`) matches := r.FindAllStringSubmatch(line, -1) + if len(matches) != 1 { - return File{}, errors.New("match not found") + return File{}, MatchError() } - return File{Name: matches[0][1]}, nil + + return File{Name: matches[0][1], Blocks: []Block{}}, nil } func ParseDiff(lines string) (Diff, error) { - diff := Diff{} var parsedLines []string + + diff := Diff{ + Files: []File{}, + } sc := bufio.NewScanner(strings.NewReader(lines)) + for sc.Scan() { parsedLines = append(parsedLines, sc.Text()) } + var ( oldLineCounter int newLineCounter int ) - for _, v := range parsedLines { - file, _ := parseFile(v) + + for _, value := range parsedLines { + file, _ := ParseFile(value) + if file.Name == "" && len(diff.Files) == 0 { continue } + if file.Name != "" { fileExists := slices.ContainsFunc(diff.Files, func(f File) bool { return f.Name == file.Name @@ -93,33 +112,44 @@ func ParseDiff(lines string) (Diff, error) { diff.Files = append(diff.Files, file) } } + lastFile := &diff.Files[len(diff.Files)-1] - block, err := parseBlock(v) + block, err := ParseBlock(value) + if err == nil { newLineCounter = block.NewStart oldLineCounter = block.OldStart block.LargestLineNumber = max(block.NewEnd, block.OldEnd) lastFile.Blocks = append(lastFile.Blocks, block) } + blocks := lastFile.Blocks - line, err := parseLine(v) - if err == nil { + line, err := ParseLine(value) - l := Line{Content: line} + if err == nil { + structuredLine := Line{Content: line, Number: 0} if strings.HasPrefix(line, "-") { - l.Number = oldLineCounter + structuredLine.Number = oldLineCounter newLineCounter-- } else if strings.HasPrefix(line, "+") { - l.Number = newLineCounter + structuredLine.Number = newLineCounter oldLineCounter-- } else { - l.Number = newLineCounter + structuredLine.Number = newLineCounter } - blocks[len(blocks)-1].Lines = append(blocks[len(blocks)-1].Lines, l) + + blocks[len(blocks)-1].Lines = append(blocks[len(blocks)-1].Lines, structuredLine) newLineCounter++ oldLineCounter++ } } + return diff, nil } + +var errMatch = errors.New("match not found") + +func MatchError() error { + return errMatch +} diff --git a/parser/parser_test.go b/parser/parser_test.go index 51e7407..e0a9cf1 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -1,174 +1,236 @@ -package parser +package parser_test import ( "fmt" "os" "testing" + + "github.com/jshawl/abcd/parser" ) func TestParseFile(t *testing.T) { + t.Parallel() + expected := "file" - actual, _ := parseFile("+++ b/file") + actual, _ := parser.ParseFile("+++ b/file") + if expected != actual.Name { t.Fatalf(fmt.Sprintf("Expected: %s Actual: %s", expected, actual.Name)) } } func TestParseFileWithExtension(t *testing.T) { + t.Parallel() + expected := "file.txt" - actual, _ := parseFile("+++ b/file.txt") + actual, _ := parser.ParseFile("+++ b/file.txt") + if expected != actual.Name { t.Fatalf(fmt.Sprintf("Expected: %s Actual: %s", expected, actual.Name)) } } func TestParseFileWithSlashes(t *testing.T) { + t.Parallel() + expected := "folder/file.txt" - actual, _ := parseFile("+++ b/folder/file.txt") + actual, _ := parser.ParseFile("+++ b/folder/file.txt") + if expected != actual.Name { t.Fatalf(fmt.Sprintf("Expected: %s Actual: %s", expected, actual.Name)) } } func TestParseFileOnNonFile(t *testing.T) { - _, err := parseFile("not a line with a filename") + t.Parallel() + + _, err := parser.ParseFile("not a line with a filename") + if err == nil { t.Fatalf("Expected error, got match") } } func TestParseBlockWithAddedLines(t *testing.T) { - actual, _ := parseBlock("@@ -0,0 +1 @@") + t.Parallel() + + actual, _ := parser.ParseBlock("@@ -0,0 +1 @@") + if actual.OldStart != 0 { t.Fatalf("Expected OldStart to be 1") } + if actual.OldEnd != 0 { t.Fatalf("Expected OldEnd to be 0") } + if actual.NewStart != 1 { t.Fatalf("Expected NewStart to be 1") } + if actual.NewEnd != 1 { t.Fatalf("Expected NewEnd to be 1") } } func TestParseBlockWithChangedLines(t *testing.T) { - actual, _ := parseBlock("@@ -1,4 +1,4 @@") + t.Parallel() + + actual, _ := parser.ParseBlock("@@ -1,4 +1,4 @@") + if actual.OldStart != 1 { t.Fatalf("Expected OldStart to be 1") } + if actual.OldEnd != 5 { t.Fatalf("Expected OldEnd to be 5") } + if actual.NewStart != 1 { t.Fatalf("Expected NewStart to be 1") } + if actual.NewEnd != 5 { t.Fatalf("Expected NewEnd to be 5") } } func TestParseLineDiffPreamble(t *testing.T) { - actual, _ := parseLine("diff --git a/file b/file") + t.Parallel() + + actual, _ := parser.ParseLine("diff --git a/file b/file") + if actual != "" { t.Fatalf("Expected diff line to be ignored") } } func TestParseLineIndexPreamble(t *testing.T) { - actual, _ := parseLine("index e69de29..d00491f 100644") + t.Parallel() + + actual, _ := parser.ParseLine("index e69de29..d00491f 100644") + if actual != "" { t.Fatalf("Expected index line to be ignored") } } func TestParseLineOldPreamble(t *testing.T) { - actual, _ := parseLine("--- a/file") + t.Parallel() + + actual, _ := parser.ParseLine("--- a/file") if actual != "" { t.Fatalf("Expected old line to be ignored") } } func TestParseLineBlockPreamble(t *testing.T) { - actual, _ := parseLine("@@ -0,0 +1 @@") + t.Parallel() + + actual, _ := parser.ParseLine("@@ -0,0 +1 @@") if actual != "" { t.Fatalf("Expected block line to be ignored") } } func TestParseLineDiff(t *testing.T) { - actual, _ := parseLine("- removed this line") + t.Parallel() + + actual, _ := parser.ParseLine("- removed this line") if actual != "- removed this line" { t.Fatalf("Expected line to be parsed") } } func TestParseLineDiffEmptyLine(t *testing.T) { - _, err := parseLine("") + t.Parallel() + + _, err := parser.ParseLine("") if err != nil { t.Fatalf("Expected line to be parsed") } } func TestParseLineNewPreamble(t *testing.T) { - actual, _ := parseLine("+++ a/file") + t.Parallel() + + actual, _ := parser.ParseLine("+++ a/file") + if actual != "" { t.Fatalf("Expected new line to be ignored") } } func TestParseDiff(t *testing.T) { + t.Parallel() + contents, _ := os.ReadFile("./test/one-file-one-block.diff") - actual, _ := ParseDiff(string(contents)) + actual, _ := parser.ParseDiff(string(contents)) if len(actual.Files) != 1 { t.Fatalf("Expected 1 file") } + if actual.Files[0].Name != "file" { t.Fatalf("Expected 1 file name to be 'file'") } + if len(actual.Files[0].Blocks[0].Lines) != 1 { t.Fatalf("Expected 1 File, 1 Block, 1 Line") } } func TestParseDiffOneFileTwoBlocks(t *testing.T) { + t.Parallel() + contents, _ := os.ReadFile("./test/one-file-two-blocks.diff") - actual, _ := ParseDiff(string(contents)) + actual, _ := parser.ParseDiff(string(contents)) + if len(actual.Files) != 1 { t.Fatalf("Expected 1 file") } + if len(actual.Files[0].Blocks) != 2 { t.Fatalf("Expected 1 File, 2 Blocks") } } func TestParseDiffTwoFilesTwoBlocks(t *testing.T) { + t.Parallel() + contents, _ := os.ReadFile("./test/two-files-two-blocks.diff") - actual, _ := ParseDiff(string(contents)) + actual, _ := parser.ParseDiff(string(contents)) + if len(actual.Files) != 2 { t.Fatalf("Expected 2 files") } + if len(actual.Files[0].Blocks) != 2 { t.Fatalf("Expected 2 Files, 2 Blocks") } } func TestParseDiffNewFile(t *testing.T) { + t.Parallel() + contents, _ := os.ReadFile("./test/new-file.diff") - actual, _ := ParseDiff(string(contents)) + actual, _ := parser.ParseDiff(string(contents)) + if len(actual.Files) != 1 { t.Fatalf("Expected 1 file") } + if actual.Files[0].Name != ".gitignore" { t.Fatalf("Expected filename .gitignore") } } func TestParseDiffDeletedFile(t *testing.T) { + t.Parallel() + contents, _ := os.ReadFile("./test/deleted-file.diff") - actual, _ := ParseDiff(string(contents)) + actual, _ := parser.ParseDiff(string(contents)) + if len(actual.Files) != 1 { t.Fatalf("Expected 1 file") } diff --git a/ui/diff.go b/ui/diff.go index 58f3777..79c733d 100644 --- a/ui/diff.go +++ b/ui/diff.go @@ -29,7 +29,9 @@ func (m Diff) command() []string { if m.staged { cmd = append(cmd, "--staged") } + cmd = append(cmd, m.args...) + return cmd } @@ -39,13 +41,21 @@ func (m Diff) Tick(immediately ...bool) tea.Cmd { return TickMsg{} } } - return tea.Tick(time.Second, func(t time.Time) tea.Msg { + + return tea.Tick(time.Second, func(_ time.Time) tea.Msg { return TickMsg{} }) } func NewDiff(staged bool, args []string) Diff { - return Diff{staged: staged, args: args} + return Diff{ + staged: staged, + args: args, + ready: false, + viewport: viewport.New(0, 0), + files: []File{}, + currentFile: 0, + } } func (m Diff) Init() tea.Cmd { @@ -55,59 +65,67 @@ func (m Diff) Init() tea.Cmd { func (m Diff) ViewEmpty() string { if m.staged { return "No changes staged..." - } else { - return "No diff to show! Working directory is clean." } + + return "No diff to show! Working directory is clean." } func (m Diff) lines() string { + var content strings.Builder + if len(m.files) == 0 { return m.ViewEmpty() - } else { - var content strings.Builder - for _, file := range m.files { - content.WriteString(file.View(m.viewport.Width)) - } - return content.String() } + + for _, file := range m.files { + content.WriteString(file.View(m.viewport.Width)) + } + + return content.String() } func (m *Diff) windowSizeUpdate(msg tea.WindowSizeMsg) tea.Cmd { footerHeight := lipgloss.Height(m.footerView()) helpHeight := lipgloss.Height("\n") - verticalMarginHeight := footerHeight + helpHeight - 2 + verticalMarginHeight := footerHeight + helpHeight - 2 //nolint:gomnd,mnd if !m.ready { m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) m.viewport.YPosition = 0 m.ready = true + return m.Tick(true) - } else { - m.viewport.Width = msg.Width - m.viewport.Height = msg.Height - verticalMarginHeight - return nil } + + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - verticalMarginHeight + + return nil } func (m Diff) FileHeights() []int { heights := []int{0} + for i := range m.files { height := m.files[i].Height(m.viewport.Width) - 1 total := height + heights[len(heights)-1] heights = append(heights, total) } + return heights } func (m Diff) getFileIndexInViewport() int { index := slices.IndexFunc(m.FileHeights(), func(i int) bool { offset := m.viewport.YOffset - 1 + return offset-i <= -2 }) - 1 if index >= 0 && len(m.files) > 0 { return index } + return m.currentFile } @@ -119,39 +137,47 @@ func (m Diff) Update(msg tea.Msg) (Diff, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - k := msg.String() - if k == "s" { + key := msg.String() + + if key == "s" { m.staged = !m.staged cmd = m.Tick(true) cmds = append(cmds, cmd) } - if k == "shift+tab" { + + if key == "shift+tab" { // decrement the current file only if the first line of the // file is visible in the viewport. If the viewport has scrolled // past the top of the file, shift+tab should scroll to the top // of the file. if slices.Contains(m.FileHeights(), m.viewport.YOffset) { - m.currentFile -= 1 + m.currentFile-- if m.currentFile < 0 || m.viewport.AtTop() { m.currentFile = len(m.files) - 1 } } + m.viewport.SetYOffset(m.FileHeights()[m.currentFile]) } - if k == "tab" { - m.currentFile += 1 + + if key == "tab" { + m.currentFile++ if m.currentFile == len(m.files) || m.viewport.AtBottom() { m.currentFile = 0 } + m.viewport.SetYOffset(m.FileHeights()[m.currentFile]) } case TickMsg: diff, _ := parser.ParseDiff(m.gitDiffRaw()) m.files = []File{} + for _, file := range diff.Files { m.files = append(m.files, NewFile(file)) } + m.viewport.SetContent(m.lines()) + return m, m.Tick() case tea.WindowSizeMsg: @@ -171,22 +197,26 @@ func (m Diff) View() string { if !m.ready { return "\n Initializing..." } + return fmt.Sprintf("%s\n%s", m.viewport.View(), m.footerView()) } func (m Diff) footerView() string { help := "? toggle help " - info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) + info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) //nolint:gomnd,mnd cmd := strings.Join(m.command(), " ") space := m.viewport.Width - lipgloss.Width(info) - lipgloss.Width(help) - lipgloss.Width(cmd) line := strings.Repeat(" ", max(0, space)) + return lipgloss.JoinHorizontal(lipgloss.Center, cmd, line, help, info) } func (m Diff) gitDiffRaw() string { var cmd *exec.Cmd + cmds := m.command() - cmd = exec.Command(cmds[0], cmds[1:]...) + cmd = exec.Command(cmds[0], cmds[1:]...) //nolint:gosec stdout, _ := cmd.Output() + return string(stdout) } diff --git a/ui/file.go b/ui/file.go index 419184e..8a6c7ba 100644 --- a/ui/file.go +++ b/ui/file.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "strconv" "strings" "github.com/charmbracelet/lipgloss" @@ -26,28 +27,36 @@ func (m File) Height(viewportWidth int) int { func (m File) View(viewportWidth int) string { var content strings.Builder + content.WriteString(fileStyle.Width(viewportWidth).Render(m.Name)) content.WriteString("\n") + for blockI, block := range m.Blocks { for _, line := range block.Lines { - largestLineNumber := fmt.Sprintf("%d", block.LargestLineNumber) + largestLineNumber := strconv.Itoa(block.LargestLineNumber) fmtString := fmt.Sprintf("%%%dd ", lipgloss.Width(largestLineNumber)) lineNumber := lineNumberStyle.Render(fmt.Sprintf(fmtString, line.Number)) width := viewportWidth - lipgloss.Width(largestLineNumber) - 1 + content.WriteString(lineNumber) - if strings.HasPrefix(line.Content, "-") { + + switch { + case strings.HasPrefix(line.Content, "-"): content.WriteString(removedStyle.Width(width).Render(line.Content)) - } else if strings.HasPrefix(line.Content, "+") { + case strings.HasPrefix(line.Content, "+"): content.WriteString(addedStyle.Width(width).Render(line.Content)) - } else { + default: content.WriteString(line.Content) } + content.WriteString("\n") } + if blockI < len(m.Blocks)-1 { content.WriteString(hr.Width(viewportWidth).Render("ยทยทยท")) content.WriteString("\n") } } + return content.String() } diff --git a/ui/help.go b/ui/help.go index 5456e23..ff7760d 100644 --- a/ui/help.go +++ b/ui/help.go @@ -39,22 +39,27 @@ func (m Help) Update(msg tea.Msg) (Help, tea.Cmd) { m.isOpen = false } } + return m, nil } func (m Help) View() string { var content strings.Builder + if m.isOpen { keys := make([]string, 0) for key := range m.keys { keys = append(keys, key) } + sort.Strings(keys) + for _, key := range keys { content.WriteString(fmt.Sprintf("%s\t%s\n", key, m.keys[key])) } + return content.String() - } else { - return "? toggle help" } + + return "? toggle help" } diff --git a/ui/main.go b/ui/main.go index 3580c3f..c36cc4a 100644 --- a/ui/main.go +++ b/ui/main.go @@ -28,6 +28,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" { return m, tea.Quit } + m.help, _ = m.help.Update(msg) } @@ -40,28 +41,34 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) View() string { if m.help.isOpen { return m.help.View() - } else { - return fmt.Sprintf("%s %s", m.diff.View(), m.help.View()) } + + return fmt.Sprintf("%s %s", m.diff.View(), m.help.View()) } func Render() { if len(os.Getenv("DEBUG")) > 0 { - f, err := tea.LogToFile("debug.log", "debug") - f.Truncate(0) - f.Seek(0, 0) + file, err := tea.LogToFile("debug.log", "debug") + _ = file.Truncate(0) + _, _ = file.Seek(0, 0) + log.Println("program starting...") + if err != nil { - fmt.Println("fatal:", err) - os.Exit(1) + fmt.Println("fatal:", err) //nolint:forbidigo + + defer func() { + os.Exit(1) + }() } - defer f.Close() + + defer file.Close() } staged := flag.Bool("staged", false, "diff --staged ?") flag.Parse() - p := tea.NewProgram( + program := tea.NewProgram( model{ help: NewHelp(), diff: NewDiff(*staged, flag.Args()), @@ -70,8 +77,11 @@ func Render() { tea.WithMouseCellMotion(), ) - if _, err := p.Run(); err != nil { - fmt.Println("could not run program:", err) - os.Exit(1) + if _, err := program.Run(); err != nil { + fmt.Println("could not run program:", err) //nolint:forbidigo + + defer func() { + os.Exit(1) + }() } } diff --git a/ui/styles.go b/ui/styles.go index a10eea1..5b25828 100644 --- a/ui/styles.go +++ b/ui/styles.go @@ -6,22 +6,20 @@ var fileStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FAFAFA")). Background(lipgloss.Color("#7D56F4")). - Width(100) + Width(100) //nolint:gomnd,mnd var removedStyle = lipgloss.NewStyle(). Background(lipgloss.Color("#ffebeb")). - Width(100) + Width(100) //nolint:gomnd,mnd var addedStyle = lipgloss.NewStyle(). Background(lipgloss.Color("#f4f8f1")). - Width(100) + Width(100) //nolint:gomnd,mnd var hr = lipgloss.NewStyle(). Background(lipgloss.Color("#f6f6f6")). - Width(100) + Width(100) //nolint:gomnd,mnd var titleStyle = lipgloss.NewStyle().Padding(0, 0) -var infoStyle = func() lipgloss.Style { - return titleStyle.Copy() -}() +var infoStyle = titleStyle.Copy()