Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(clipper): Implement concurrent file reading and refactor project structure #34

Merged
merged 5 commits into from
Jun 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 3 additions & 72 deletions cli/clipper/clipper.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,17 @@ package clipper

import (
"fmt"
"io"
"os"
"strings"

"github.com/atotto/clipboard"
"github.com/supitsdu/clipper/cli/options"
"github.com/supitsdu/clipper/cli/reader"
)

// ContentReader defines an interface for reading content from various sources.
type ContentReader interface {
Read() (string, error)
}

// ClipboardWriter defines an interface for writing content to the clipboard.
type ClipboardWriter interface {
Write(content string) error
}

// FileContentReader reads content from a specified file path.
type FileContentReader struct {
FilePath string
}

// Read reads the content from the file specified in FileContentReader.
func (f FileContentReader) Read() (string, error) {
content, err := os.ReadFile(f.FilePath)
if err != nil {
return "", fmt.Errorf("error reading file '%s': %w", f.FilePath, err)
}
return string(content), nil
}

// StdinContentReader reads content from the standard input (stdin).
type StdinContentReader struct{}

// Read reads the content from stdin.
func (s StdinContentReader) Read() (string, error) {
input, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("error reading from stdin: %w", err)
}
return string(input), nil
}

// DefaultClipboardWriter writes content to the clipboard using the default clipboard implementation.
type DefaultClipboardWriter struct{}

Expand All @@ -54,52 +21,16 @@ func (c DefaultClipboardWriter) Write(content string) error {
return clipboard.WriteAll(content)
}

// ParseContent aggregates content from the provided readers, or returns the direct text if provided.
func ParseContent(directText *string, readers ...ContentReader) (string, error) {
if directText != nil && *directText != "" {
return *directText, nil
}

if len(readers) == 0 {
return "", fmt.Errorf("no content readers provided")
}

var sb strings.Builder
for _, reader := range readers {
content, err := reader.Read()
if err != nil {
return "", err
}
sb.WriteString(content + "\n")
}

return sb.String(), nil
}

func GetReaders(targets []string) []ContentReader {
if len(targets) == 0 {
// If no file paths are provided, use StdinContentReader to read from stdin.
return []ContentReader{StdinContentReader{}}
}

// If file paths are provided as arguments, create FileContentReader instances for each.
var readers []ContentReader
for _, filePath := range targets {
readers = append(readers, FileContentReader{FilePath: filePath})
}
return readers
}

// Run executes the clipper tool logic based on the provided configuration.
func Run(config *options.Config, writer ClipboardWriter) (string, error) {
if *config.ShowVersion {
return options.Version, nil
}

readers := GetReaders(config.Args)
readers := reader.GetReaders(config.Args)

// Aggregate the content from the provided sources.
content, err := ParseContent(config.DirectText, readers...)
content, err := reader.ParseContent(config.DirectText, readers...)
if err != nil {
return "", fmt.Errorf("parsing content: %w", err)
}
Expand Down
120 changes: 120 additions & 0 deletions cli/reader/reader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package reader

import (
"fmt"
"io"
"os"
"strings"
"sync"
)

// ContentReader defines an interface for reading content from various sources.
type ContentReader interface {
Read() (string, error)
}

// FileContentReader reads content from a specified file path.
type FileContentReader struct {
FilePath string
}

// Read reads the content from the file specified in FileContentReader.
// It reads the entire file content into memory, which is suitable for smaller files.
func (f FileContentReader) Read() (string, error) {
content, err := os.ReadFile(f.FilePath)
if err != nil {
return "", fmt.Errorf("error reading file '%s': %w", f.FilePath, err)
}
return string(content), nil
}

// StdinContentReader reads content from the standard input (stdin).
type StdinContentReader struct{}

// Read reads the content from stdin.
// It reads all the data from stdin until EOF, which is useful for piping input.
func (s StdinContentReader) Read() (string, error) {
input, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("error reading from stdin: %w", err)
}
return string(input), nil
}

// ReadContentConcurrently reads content from multiple readers concurrently and returns the results.
// This function utilizes goroutines to perform concurrent reads, improving performance for multiple files.
func ReadContentConcurrently(readers []ContentReader) ([]string, error) {
supitsdu marked this conversation as resolved.
Show resolved Hide resolved
var wg sync.WaitGroup
var mu sync.Mutex
errChan := make(chan error, len(readers)) // Channel to capture errors
results := make([]string, len(readers)) // Slice to store results

for i, reader := range readers {
wg.Add(1)
go func(i int, reader ContentReader) {
defer wg.Done()
content, err := reader.Read()
if err != nil {
errChan <- err // Send error to channel
return
}
mu.Lock()
results[i] = content // Safely write to results slice
mu.Unlock()
}(i, reader)
}

wg.Wait()
close(errChan) // Close error channel after all reads are done

if len(errChan) > 0 {
return nil, <-errChan // Return the first error encountered
}

return results, nil
}

// AggregateContent aggregates the content from the provided results and returns it as a single string.
// It combines the content of all readers into a single string with newline separators.
func AggregateContent(results []string) string {
var sb strings.Builder
for _, content := range results {
sb.WriteString(content + "\n")
}
return sb.String()
}

// ParseContent aggregates content from the provided readers, or returns the direct text if provided.
// This function first checks for direct text input, then reads from the provided readers concurrently.
func ParseContent(directText *string, readers ...ContentReader) (string, error) {
supitsdu marked this conversation as resolved.
Show resolved Hide resolved
if directText != nil && *directText != "" {
return *directText, nil // Return direct text if provided
}

if len(readers) == 0 {
return "", fmt.Errorf("no content readers provided")
}

results, err := ReadContentConcurrently(readers) // Read content concurrently
if err != nil {
return "", err
}

return AggregateContent(results), nil // Aggregate and return the content
}

// GetReaders constructs the appropriate ContentReaders based on the provided file paths or lack thereof.
// If no targets are provided, it defaults to using StdinContentReader.
func GetReaders(targets []string) []ContentReader {
if len(targets) == 0 {
// If no file paths are provided, use StdinContentReader to read from stdin.
return []ContentReader{StdinContentReader{}}
}

// Create FileContentReader instances for each provided file path.
var readers []ContentReader
for _, filePath := range targets {
readers = append(readers, FileContentReader{FilePath: filePath})
}
return readers
}
37 changes: 37 additions & 0 deletions tests/clipper/clipper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package clipper

import (
"testing"

"github.com/atotto/clipboard"
"github.com/supitsdu/clipper/cli/clipper"
"github.com/supitsdu/clipper/tests"
)

func TestClipboardWriter(t *testing.T) {
t.Run("DefaultClipboardWriter", func(t *testing.T) {
if testing.Short() == true {
t.Skip("Skipping clipboard test in short mode. Helps avoid errors when on CI environments.")
}

// Create a DefaultClipboardWriter
writer := clipper.DefaultClipboardWriter{}

// Write some content to the clipboard
err := writer.Write(tests.SampleText_32)
if err != nil {
t.Errorf("Error writing to clipboard: %v", err)
}

// Check the clipboard content
clipboardContent, err := clipboard.ReadAll()
if err != nil {
t.Errorf("Error reading from clipboard: %v", err)
}

// Check the content
if clipboardContent != tests.SampleText_32 {
t.Errorf("Expected '%s', got '%s'", tests.SampleText_32, clipboardContent)
}
})
}
Loading
Loading