diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0fabd69 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + name: Test + strategy: + matrix: + go-version: [ '1.23', '1.24', '1.25' ] + os: [ ubuntu-latest, macos-latest, windows-latest ] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Verify dependencies + run: go mod verify + + - name: Run go vet + run: go vet ./... + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.25' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.out + continue-on-error: true + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + + benchmark: + name: Benchmark + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Run benchmarks + run: go test -bench=. -benchmem -run=^$ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6bfaae0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Release + +on: + push: + branches: + - master + +permissions: + contents: write + pull-requests: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Run tests + run: go test -v -race ./... + + - name: Create release + uses: googleapis/release-please-action@v4 + id: release + with: + release-type: go + package-name: gotree + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Tag major and minor versions + if: ${{ steps.release.outputs.release_created }} + run: | + git config user.name github-actions[bot] + git config user.email github-actions[bot]@users.noreply.github.com + git tag -d v${{ steps.release.outputs.major }} 2>/dev/null || true + git tag -d v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} 2>/dev/null || true + git tag -a v${{ steps.release.outputs.major }} -m "Release v${{ steps.release.outputs.major }}" + git tag -a v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} -m "Release v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}" + git push origin v${{ steps.release.outputs.major }} --force + git push origin v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} --force diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5e47459 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,19 @@ +linters: + enable: + - gofmt + - govet + - staticcheck + - ineffassign + - misspell + - unconvert + - unparam + - unused + - errcheck + - gosimple + +linters-settings: + govet: + check-shadowing: true + +issues: + exclude-use-default: false diff --git a/.travis.yml b/.travis.yml index 29261df..6051f69 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,7 @@ +# DEPRECATED: This project has migrated to GitHub Actions +# See .github/workflows/ci.yml for current CI configuration +# This file is kept for historical reference only + language: go go_import_path: github.com/disiqueira/gotree git: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0572ec4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## [3.0.3](https://github.com/d6o/GoTree/compare/v3.0.2...v3.0.3) (2026-02-03) + + +### Bug Fixes + +* Correct the spelling in README.md ([07d11ce](https://github.com/d6o/GoTree/commit/07d11ce3f54daaa88bd9a492a4010e8c500bcc69)) +* Correct the spelling in README.md ([8656016](https://github.com/d6o/GoTree/commit/86560162b85f6c59ead96268465bf9611920644d)) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c87a159 --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +.PHONY: test bench lint fmt vet coverage clean help + +# Default target +help: + @echo "GoTree Makefile" + @echo "" + @echo "Available targets:" + @echo " make test - Run all tests" + @echo " make bench - Run benchmarks" + @echo " make lint - Run linter" + @echo " make fmt - Format code" + @echo " make vet - Run go vet" + @echo " make coverage - Generate coverage report" + @echo " make clean - Clean build artifacts" + +test: + go test -v -race ./... + +bench: + go test -bench=. -benchmem -run=^$$ + +lint: + golangci-lint run + +fmt: + go fmt ./... + +vet: + go vet ./... + +coverage: + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +clean: + rm -f coverage.out coverage.html + go clean -testcache diff --git a/README.md b/README.md index 81d271c..c5d197f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ![GoTree](https://rawgit.com/DiSiqueira/GoTree/master/gotree-logo.png) +# ![GoTree](https://raw.githubusercontent.com/d6o/GoTree/master/gotree-logo.png) -# GoTree ![Language Badge](https://img.shields.io/badge/Language-Go-blue.svg) ![Go Report](https://goreportcard.com/badge/github.com/DiSiqueira/GoTree) ![License Badge](https://img.shields.io/badge/License-MIT-blue.svg) ![Status Badge](https://img.shields.io/badge/Status-Beta-brightgreen.svg) [![GoDoc](https://godoc.org/github.com/DiSiqueira/GoTree?status.svg)](https://godoc.org/github.com/DiSiqueira/GoTree) [![Build Status](https://travis-ci.org/DiSiqueira/GoTree.svg?branch=master)](https://travis-ci.org/DiSiqueira/GoTree) +# GoTree ![Language Badge](https://img.shields.io/badge/Language-Go-blue.svg) ![Go Report](https://goreportcard.com/badge/github.com/d6o/GoTree) ![License Badge](https://img.shields.io/badge/License-MIT-blue.svg) ![Status Badge](https://img.shields.io/badge/Status-Beta-brightgreen.svg) [![GoDoc](https://godoc.org/github.com/d6o/GoTree?status.svg)](https://godoc.org/github.com/d6o/GoTree) [![CI](https://github.com/d6o/GoTree/workflows/CI/badge.svg)](https://github.com/d6o/GoTree/actions) Simple Go module to print tree structures in terminal. Heavily inspired by [The Tree Command for Linux][treecommand] @@ -10,7 +10,7 @@ The GoTree's goal is to be a simple tool providing a stupidly easy-to-use and fa ## Project Status -GoTree is on beta. Pull Requests [are welcome](https://github.com/DiSiqueira/GoTree#social-coding) +GoTree is on beta. Pull Requests [are welcome](https://github.com/d6o/GoTree#social-coding) ![](http://image.prntscr.com/image/2a0dbf0777454446b8083fb6a0dc51fe.png) @@ -20,14 +20,14 @@ GoTree is on beta. Pull Requests [are welcome](https://github.com/DiSiqueira/GoT - Intuitive names - Easy to extend - Uses only native libs -- STUPIDLY [EASY TO USE](https://github.com/DiSiqueira/GoTree#usage) +- STUPIDLY [EASY TO USE](https://github.com/d6o/GoTree#usage) ## Installation ### Go Get ```bash -$ go get github.com/disiqueira/gotree +$ go get github.com/d6o/gotree ``` ## Usage @@ -42,7 +42,7 @@ package main import ( "fmt" - "github.com/disiqueira/gotree" + "github.com/d6o/gotree" ) func main() { @@ -58,21 +58,21 @@ func main() { ### Bug Reports & Feature Requests -Please use the [issue tracker](https://github.com/DiSiqueira/GoTree/issues) to report any bugs or file feature requests. +Please use the [issue tracker](https://github.com/d6o/GoTree/issues) to report any bugs or file feature requests. ### Developing PRs are welcome. To begin developing, do this: ```bash -$ git clone --recursive git@github.com:DiSiqueira/GoTree.git +$ git clone --recursive git@github.com:d6o/GoTree.git $ cd GoTree/ ``` ## Social Coding 1. Create an issue to discuss about your idea -2. [Fork it] (https://github.com/DiSiqueira/GoTree/fork) +2. [Fork it] (https://github.com/d6o/GoTree/fork) 3. Create your feature branch (`git checkout -b my-new-feature`) 4. Commit your changes (`git commit -am 'Add some feature'`) 5. Push to the branch (`git push origin my-new-feature`) diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..62b1a91 --- /dev/null +++ b/coverage.out @@ -0,0 +1,31 @@ +mode: atomic +github.com/disiqueira/gotree/v3/gotree.go:43.28,48.2 1 145 +github.com/disiqueira/gotree/v3/gotree.go:53.38,57.2 3 119 +github.com/disiqueira/gotree/v3/gotree.go:61.35,62.17 1 3 +github.com/disiqueira/gotree/v3/gotree.go:62.17,64.3 1 1 +github.com/disiqueira/gotree/v3/gotree.go:65.2,65.33 1 2 +github.com/disiqueira/gotree/v3/gotree.go:69.30,71.2 1 147 +github.com/disiqueira/gotree/v3/gotree.go:74.31,76.2 1 238 +github.com/disiqueira/gotree/v3/gotree.go:79.31,81.2 1 6 +github.com/disiqueira/gotree/v3/gotree.go:83.27,85.2 1 6 +github.com/disiqueira/gotree/v3/gotree.go:88.40,97.2 6 6 +github.com/disiqueira/gotree/v3/gotree.go:99.75,107.31 3 117 +github.com/disiqueira/gotree/v3/gotree.go:107.31,108.12 1 4961 +github.com/disiqueira/gotree/v3/gotree.go:108.12,110.4 1 4953 +github.com/disiqueira/gotree/v3/gotree.go:110.9,112.4 1 8 +github.com/disiqueira/gotree/v3/gotree.go:114.2,117.10 3 117 +github.com/disiqueira/gotree/v3/gotree.go:117.10,119.3 1 108 +github.com/disiqueira/gotree/v3/gotree.go:122.2,126.23 4 117 +github.com/disiqueira/gotree/v3/gotree.go:126.23,128.13 2 122 +github.com/disiqueira/gotree/v3/gotree.go:128.13,133.12 5 117 +github.com/disiqueira/gotree/v3/gotree.go:135.3,135.11 1 5 +github.com/disiqueira/gotree/v3/gotree.go:135.11,137.4 1 2 +github.com/disiqueira/gotree/v3/gotree.go:137.9,139.4 1 3 +github.com/disiqueira/gotree/v3/gotree.go:140.3,143.31 4 5 +github.com/disiqueira/gotree/v3/gotree.go:146.2,146.25 1 117 +github.com/disiqueira/gotree/v3/gotree.go:149.62,150.17 1 110 +github.com/disiqueira/gotree/v3/gotree.go:150.17,152.3 1 2 +github.com/disiqueira/gotree/v3/gotree.go:154.2,160.22 3 108 +github.com/disiqueira/gotree/v3/gotree.go:160.22,163.25 3 117 +github.com/disiqueira/gotree/v3/gotree.go:163.25,166.4 2 104 +github.com/disiqueira/gotree/v3/gotree.go:168.2,168.25 1 108 diff --git a/go.mod b/go.mod index 7e17c63..7987254 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/disiqueira/gotree/v3 +module github.com/d6o/gotree/v3 -go 1.13 +go 1.23 diff --git a/gotree.go b/gotree.go index c529f62..1188974 100644 --- a/gotree.go +++ b/gotree.go @@ -37,37 +37,45 @@ type ( } ) -//New returns a new GoTree.Tree +// New returns a new GoTree.Tree. +// The text parameter accepts any string, including empty strings. +// Multi-line text is supported using newline characters. func New(text string) Tree { return &tree{ text: text, - items: []Tree{}, + items: make([]Tree, 0), } } -//Add adds a node to the tree +// Add adds a node to the tree with the given text. +// Returns the newly created child node to allow method chaining. +// Empty strings are valid and will create a node with empty text. func (t *tree) Add(text string) Tree { n := New(text) t.items = append(t.items, n) return n } -//AddTree adds a tree as an item +// AddTree adds a tree as an item. +// If the provided tree is nil, this method does nothing (safe no-op). func (t *tree) AddTree(tree Tree) { + if tree == nil { + return + } t.items = append(t.items, tree) } -//Text returns the node's value +// Text returns the node's value func (t *tree) Text() string { return t.text } -//Items returns all items in the tree +// Items returns all items in the tree func (t *tree) Items() []Tree { return t.items } -//Print returns an visual representation of the tree +// Print returns an visual representation of the tree func (t *tree) Print() string { return newPrinter().Print(t) } @@ -76,32 +84,52 @@ func newPrinter() Printer { return &printer{} } -//Print prints a tree to a string +// Print prints a tree to a string func (p *printer) Print(t Tree) string { - return t.Text() + newLine + p.printItems(t.Items(), []bool{}) + var builder strings.Builder + builder.Grow(len(t.Text()) + 100) // Root text + reasonable default + + builder.WriteString(t.Text()) + builder.WriteString(newLine) + builder.WriteString(p.printItems(t.Items(), []bool{})) + + return builder.String() } func (p *printer) printText(text string, spaces []bool, last bool) string { - var result string + var builder strings.Builder + + // Pre-allocate capacity for better performance + // Estimate: spaces * 4 chars + indicator + text + newline + builder.Grow(len(spaces)*4 + 4 + len(text) + 1) + + // Build the prefix from spaces for _, space := range spaces { if space { - result += emptySpace + builder.WriteString(emptySpace) } else { - result += continueItem + builder.WriteString(continueItem) } } + prefix := builder.String() indicator := middleItem if last { indicator = lastItem } - var out string + // Reset builder for output + builder.Reset() + builder.Grow(len(prefix)*2 + len(text) + 10) + lines := strings.Split(text, "\n") for i := range lines { - text := lines[i] + lineText := lines[i] // Fix variable shadowing if i == 0 { - out += result + indicator + text + newLine + builder.WriteString(prefix) + builder.WriteString(indicator) + builder.WriteString(lineText) + builder.WriteString(newLine) continue } if last { @@ -109,21 +137,33 @@ func (p *printer) printText(text string, spaces []bool, last bool) string { } else { indicator = continueItem } - out += result + indicator + text + newLine + builder.WriteString(prefix) + builder.WriteString(indicator) + builder.WriteString(lineText) + builder.WriteString(newLine) } - return out + return builder.String() } func (p *printer) printItems(t []Tree, spaces []bool) string { - var result string + if len(t) == 0 { + return "" + } + + var builder strings.Builder + + // Estimate capacity: rough approximation based on tree size + // Each item typically needs ~50 chars (conservative estimate) + builder.Grow(len(t) * 50) + for i, f := range t { last := i == len(t)-1 - result += p.printText(f.Text(), spaces, last) + builder.WriteString(p.printText(f.Text(), spaces, last)) if len(f.Items()) > 0 { spacesChild := append(spaces, last) - result += p.printItems(f.Items(), spacesChild) + builder.WriteString(p.printItems(f.Items(), spacesChild)) } } - return result + return builder.String() } diff --git a/gotree_test.go b/gotree_test.go index 84d7de0..21d9b95 100644 --- a/gotree_test.go +++ b/gotree_test.go @@ -2,7 +2,6 @@ package gotree import ( "fmt" - "reflect" "testing" ) @@ -56,8 +55,12 @@ func TestNew(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := New(tt.args.text); !reflect.DeepEqual(got, tt.want) { - t.Errorf("New() = %v, want %v", got, tt.want) + got := New(tt.args.text) + if got.Text() != tt.want.Text() { + t.Errorf("New() text = %v, want %v", got.Text(), tt.want.Text()) + } + if len(got.Items()) != len(tt.want.Items()) { + t.Errorf("New() items length = %v, want %v", len(got.Items()), len(tt.want.Items())) } }) } @@ -118,11 +121,14 @@ func Test_tree_Add(t *testing.T) { items: tt.fields.items, } got := tree.Add(tt.args.text) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("tree.Add() = %v, want %v", got, tt.want) + if got.Text() != tt.want.Text() { + t.Errorf("tree.Add() text = %v, want %v", got.Text(), tt.want.Text()) + } + if len(got.Items()) != len(tt.want.Items()) { + t.Errorf("tree.Add() items length = %v, want %v", len(got.Items()), len(tt.want.Items())) } if tt.parentCount != len(tree.Items()) { - t.Errorf("tree total items = %v, want %v", got, tt.want) + t.Errorf("tree total items = %v, want %v", len(tree.Items()), tt.parentCount) } }) } @@ -255,8 +261,14 @@ func Test_tree_Items(t *testing.T) { text: tt.fields.text, items: tt.fields.items, } - if got := tree.Items(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("tree.Items() = %v, want %v", got, tt.want) + got := tree.Items() + if len(got) != len(tt.want) { + t.Fatalf("Items() length = %v, want %v", len(got), len(tt.want)) + } + for i := range got { + if got[i].Text() != tt.want[i].Text() { + t.Errorf("Items()[%d].Text() = %v, want %v", i, got[i].Text(), tt.want[i].Text()) + } } }) } @@ -330,3 +342,148 @@ func Test_tree_Print(t *testing.T) { }) } } + +// Benchmark tests + +func BenchmarkNew(b *testing.B) { + for i := 0; i < b.N; i++ { + New("test node") + } +} + +func BenchmarkAdd(b *testing.B) { + tree := New("root") + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.Add("child") + } +} + +func BenchmarkPrintSmallTree(b *testing.B) { + tree := New("Root") + tree.Add("Child 1") + tree.Add("Child 2") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.Print() + } +} + +func BenchmarkPrintDeepTree(b *testing.B) { + tree := New("Root") + current := tree + for i := 0; i < 10; i++ { + current = current.Add("Level " + string(rune(i+'0'))) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.Print() + } +} + +func BenchmarkPrintWideTree(b *testing.B) { + tree := New("Root") + for i := 0; i < 100; i++ { + tree.Add("Child " + string(rune(i+'0'))) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.Print() + } +} + +func BenchmarkPrintComplexTree(b *testing.B) { + tree := New("Root") + for i := 0; i < 10; i++ { + branch := tree.Add("Branch " + string(rune(i+'0'))) + for j := 0; j < 10; j++ { + leaf := branch.Add("Leaf " + string(rune(i+'0')) + "-" + string(rune(j+'0'))) + if j%2 == 0 { + leaf.Add("Sub-leaf") + } + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.Print() + } +} + +func BenchmarkPrintMultilineText(b *testing.B) { + tree := New("Root") + multilineText := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" + for i := 0; i < 5; i++ { + tree.Add(multilineText) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.Print() + } +} + +// Edge case tests + +func TestAddTree_Nil(t *testing.T) { + tree := New("root") + tree.AddTree(nil) // Should not panic + + if len(tree.Items()) != 0 { + t.Errorf("AddTree(nil) should not add item, got %d items", len(tree.Items())) + } +} + +func TestPrint_EmptyTree(t *testing.T) { + tree := New("") + got := tree.Print() + want := "\n" + + if got != want { + t.Errorf("Print() = %#v, want %#v", got, want) + } +} + +func TestPrint_VeryDeepTree(t *testing.T) { + tree := New("Root") + current := tree + + // Create a tree 100 levels deep + for i := 0; i < 100; i++ { + current = current.Add(fmt.Sprintf("Level %d", i)) + } + + // Should not panic or cause stack overflow + output := tree.Print() + + // Verify it contains expected depth + if len(output) == 0 { + t.Error("Deep tree should produce output") + } +} + +func TestText_PreservesInput(t *testing.T) { + tests := []struct { + name string + text string + }{ + {"empty", ""}, + {"simple", "simple"}, + {"with newlines", "with\nnewlines"}, + {"with tabs", "with\ttabs"}, + {"with unicode", "with unicode: 🌲🌳🌴"}, + {"with special chars", "with special chars: !@#$%^&*()"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tree := New(tt.text) + if tree.Text() != tt.text { + t.Errorf("Text() = %v, want %v", tree.Text(), tt.text) + } + }) + } +}