Skip to content

Commit

Permalink
Add --help, --version, and improved errors
Browse files Browse the repository at this point in the history
  • Loading branch information
dyson committed Mar 3, 2024
1 parent 411144e commit ccb7026
Show file tree
Hide file tree
Showing 17 changed files with 562 additions and 120 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,14 @@ first line of the input and return all other lines.

| Filter | |
| ------ | ------- |
| Columns(delimiter *string*, columns *string*) | Returns the selected `columns` in order where `columns` is a 1-indexed comma separated list of column positions. Columns are defined by splitting with the 'delimiter'. |
| Columns(delimiter *string*, columns *string*) | Returns the selected `columns` in order where `columns` is a 1-indexed comma separated list of column positions. Columns are defined by splitting with the `delimiter`. |
| ColumnsCSV(delimiter *string*, columns *string*)| Returns the selected `columns` in order where `columns` is a 1-indexed comma separated list of column positions. Parsing is CSV aware so quoted columns containing the `delimiter` when splitting are preserved. |
| CountLines() | Returns the line count. Lines are delimited by `\r?\n`. |
| CountRunes() | Returns the rune (Unicode code points) count. Erroneous and short encodings are treated as single runes of width 1 byte. |
| CountWords() | Returns the word count. Words are delimited by<br />`\t\|\n\|\v\|\f\|\r\| \|0x85\|0xA0`. |
| CountWords() | Returns the word count. Words are delimited by `\t\|\n\|\v\|\f\|\r\|&nbsp;\|0x85\|0xA0`. |
| First(n int) | Returns first `n` lines where `n` is a positive integer. If the input has less than `n` lines, all lines are returned. |
| !First(n int) | Returns all but the the first `n` lines where `n` is a positive integer. If the input has less than `n` lines, no lines are returned. |
| Frequency() | Ruturns a descending list containing frequency and unique line. Lines with equal frequency are sorted alphabetically. |
| Frequency() | Returns a descending list containing frequency and unique line. Lines with equal frequency are sorted alphabetically. |
| Join(delimiter *string*) | Joins all lines together seperated by `delimiter`. |
| Last(n int) | Returns last `n` lines where `n` is a positive integer. If the input has less than `n` lines, all lines are returned. |
| !Last(n int) | Returns all but the last `n` lines where `n` is a positive integer. If the input has less than `n` lines, no lines are returned. |
Expand All @@ -84,7 +84,7 @@ first line of the input and return all other lines.
| MatchRegex(regex *string*) | Returns all lines that match the compiled regular expression 'regex'. Regex is in the form of [Re2](https://github.com/google/re2/wiki/Syntax). |
| !MatchRegex(regex *string*) | Returns all lines that don't match the compiled regular expression 'regex'. Regex is in the form of [Re2](https://github.com/google/re2/wiki/Syntax). |
| Replace(old *string*, replace *string*) | Replaces all non-overlapping instances of `old` with `replace`. |
| ReplaceRegex(regex *string*, replace *string*) | Replaces all matches of the compiled regular expression `regex` with `replace`. Inside `replace`, `$` signs represent submatches. For example `$1` represents the text of the first submatch. |
| ReplaceRegex(regex *string*, replace *string*) | Replaces all matches of the compiled regular expression `regex` with `replace`. Inside `replace`, `$` signs represent submatches. For example `$1` represents the text of the first submatch. Regex is in the form of [Re2](https://github.com/google/re2/wiki/Syntax). |

## License
See [LICENSE](https://github.com/dyson/pipesore/blob/master/LICENSE) file.
23 changes: 8 additions & 15 deletions cmd/pipesore/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,17 @@ import (
"github.com/dyson/pipesore/internal/pipesore"
)

var (
version = "dev"
commit = "none"
date = "unknown"
)

func main() {
s, err := run()
s, err := pipesore.Run(version, commit, date)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintf(os.Stderr, "%v\n", err)
}

os.Exit(s)
}

func run() (int, error) {
if len(os.Args) != 2 {
return 1, fmt.Errorf("use a single string to define pipeline")
}

err := pipesore.Execute(os.Args[1], os.Stdin, os.Stdout)
if err != nil {
return 1, fmt.Errorf("error executing pipeline: %w", err)
}

return 0, nil
}
9 changes: 5 additions & 4 deletions internal/pipesore/ast.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
package pipesore

type ast struct {
functions []function
filters []filter
}

func newAST() *ast {
return &ast{
functions: []function{},
filters: []filter{},
}
}

type function struct {
type filter struct {
name string
arguments []any
position
}

func (f function) isNot() bool {
func (f filter) isNot() bool {
return f.name[0:1] == "!"
}
60 changes: 60 additions & 0 deletions internal/pipesore/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package pipesore

import (
"errors"
"fmt"
"os"
"path/filepath"

"github.com/dyson/pipesore/pkg/pipeline"
)

func Run(version, commit, date string) (int, error) {
seeHelp := fmt.Sprintf("See '%s --help'", filepath.Base(os.Args[0]))

if len(os.Args) != 2 {
return 1, fmt.Errorf("error: define a single pipeline or option.\n%s.", seeHelp)
}

input := os.Args[1]

switch input {
case "-h", "--help":
printHelp()
return 0, nil
case "-v", "--version":
fmt.Printf("pipesore version %s, commit %s, date %s\n", version, commit, date)
return 0, nil
case "":
return 1, fmt.Errorf("error: no pipeline defined.\n%s.", seeHelp)
}

err := execute(input, os.Stdin, os.Stdout)
if err != nil {
var syntaxError *syntaxError
if errors.As(err, &syntaxError) {
return 1, newFormattedError(err, input, syntaxError.position, seeHelp)
}

var filterNameError *filterNameError
if errors.As(err, &filterNameError) {
if filterNameError.suggestion != "" {
definition := pipeline.Filters[filterNameError.suggestion].Definition
seeHelp = fmt.Sprintf("Did you mean '%s'?\n%s", definition, seeHelp)
}

return 1, newFormattedError(err, input, filterNameError.position, seeHelp)
}

var filterArgumentError *filterArgumentError
if errors.As(err, &filterArgumentError) {
help := fmt.Sprintf("%s. %s", pipeline.Filters[filterArgumentError.name].Definition, seeHelp)

return 1, newFormattedError(err, input, filterArgumentError.position, help)
}

return 1, fmt.Errorf("%w.\n%s.", err, seeHelp)
}

return 0, nil
}
99 changes: 99 additions & 0 deletions internal/pipesore/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package pipesore

import "fmt"

type syntaxError struct {
err error
position
}

func newSyntaxError(err error, position position) *syntaxError {
return &syntaxError{
err: err,
position: position,
}
}

func (pe *syntaxError) Error() string {
return pe.err.Error()
}

type filterNameError struct {
err error
position
name string
suggestion string
}

func newFilterNameError(err error, position position, name, suggestion string) *filterNameError {
return &filterNameError{
err: err,
position: position,
name: name,
suggestion: suggestion,
}
}

func (fne *filterNameError) Error() string {
return fne.err.Error()
}

type filterArgumentError struct {
err error
name string
position
}

func newFilterArgumentError(err error, position position, name string) *filterArgumentError {
return &filterArgumentError{
err: err,
position: position,
name: name,
}
}

func (fne *filterArgumentError) Error() string {
return fne.err.Error()
}

func newFormattedError(err error, input string, position position, help string) error {
red := "\x1b[31m"
undercurl := "\x1b[4:3m"
reset := "\x1b[0m"

var inputBefore, inputAfter string

start := position.start
end := position.end

// handle EOF
if len(input) == start {
input += " "
}

if start > 0 {
inputBefore = input[:start]
}

inputError := input[start:end]

if len(input) > end {
inputAfter = input[end:]
}

if help != "" {
help = "\n" + help
}

return fmt.Errorf(
"%w:\n\t%s%s%s%s%s%s%s.",
err,
inputBefore,
red,
undercurl,
inputError,
reset,
inputAfter,
help,
)
}
59 changes: 42 additions & 17 deletions internal/pipesore/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import (
"regexp"
"strings"

"github.com/dyson/pipesore/pkg/levenshtein"
"github.com/dyson/pipesore/pkg/pipeline"
)

func Execute(input string, in io.Reader, out io.Writer) error {
func execute(input string, in io.Reader, out io.Writer) error {
tree, err := newParser(newLexer(input)).parse()
if err != nil {
return fmt.Errorf("error parsing pipeline: %w", err)
Expand All @@ -32,22 +33,41 @@ func newExecutor(tree *ast, r io.Reader, w io.Writer) *executor {
func (e executor) execute() error {
p := pipeline.NewPipeline(e.reader)

for _, inFunction := range e.tree.functions {
name := strings.ToLower(inFunction.name)
for _, inFilter := range e.tree.filters {
name := strings.ToLower(inFilter.name)

filter, ok := pipeline.Filters[name]
if !ok {
return fmt.Errorf("unknown function %s()", inFunction.name)
lowestScore := len(name)
suggestion := ""
for f := range pipeline.Filters {
distance := levenshtein.Distance(name, f)
if distance < lowestScore {
lowestScore = distance
suggestion = f
}
}

return newFilterNameError(
fmt.Errorf("error running pipeline: unknown filter '%s()'", inFilter.name),
inFilter.position,
inFilter.name,
suggestion,
)
}

filterType := filter.Type()
filterType := filter.Value.Type()

args, err := e.convertArguments(inFunction, filterType)
args, err := e.convertArguments(inFilter, filterType)
if err != nil {
return err
return newFilterArgumentError(
fmt.Errorf("error running pipeline: %w", err),
inFilter.position,
name,
)
}

p.Filter(filter.Call(args)[0].Interface().(func(io.Reader, io.Writer) error))
p.Filter(filter.Value.Call(args)[0].Interface().(func(io.Reader, io.Writer) error))
}

if _, err := p.Output(e.writer); err != nil {
Expand All @@ -57,40 +77,45 @@ func (e executor) execute() error {
return nil
}

func (e executor) convertArguments(inFunction function, filterType reflect.Type) ([]reflect.Value, error) {
if len(inFunction.arguments) != filterType.NumIn() {
return nil, fmt.Errorf("wrong number of arguments in call to %s(): expected %d, got %d", inFunction.name, filterType.NumIn(), len(inFunction.arguments))
func (e executor) convertArguments(inFilter filter, filterType reflect.Type) ([]reflect.Value, error) {
if len(inFilter.arguments) != filterType.NumIn() {
argument := "argument"
if filterType.NumIn() > 1 {
argument += "s"
}

return nil, fmt.Errorf("expected %d %s in call to '%s()', got %d", filterType.NumIn(), argument, inFilter.name, len(inFilter.arguments))
}

args := []reflect.Value{}

for i := 0; i < len(inFunction.arguments); i++ {
inArg := inFunction.arguments[i]
for i := 0; i < len(inFilter.arguments); i++ {
inArg := inFilter.arguments[i]
filterArgType := filterType.In(i)

switch filterArgType.String() {
case "string":
if reflect.TypeOf(inArg).String() != "string" {
return nil, fmt.Errorf("expected argument %d in call to %s() to be string, got: %v (%T)", i+1, inFunction.name, inArg, inArg)
return nil, fmt.Errorf("expected argument %d in call to '%s()' to be a string, got %v (%T)", i+1, inFilter.name, inArg, inArg)
}

args = append(args, reflect.ValueOf(inArg))

case "int":
if reflect.TypeOf(inArg).String() != "int" {
return nil, fmt.Errorf("expected argument %d in call to %s() to be int, got: %v (%T)", i+1, inFunction.name, inArg, inArg)
return nil, fmt.Errorf("expected argument %d in call to '%s()' to be an int, got %v (%T)", i+1, inFilter.name, inArg, inArg)
}

args = append(args, reflect.ValueOf(inArg))

case "*regexp.Regexp":
if reflect.TypeOf(inArg).String() != "string" {
return nil, fmt.Errorf("expected argument %d in call to %s() to be valid regex.Regexp string, got: %v (%T)", i+1, inFunction.name, inArg, inArg)
return nil, fmt.Errorf("expected argument %d in call to '%s()' to be a valid regex.Regexp string, got %v (%T)", i+1, inFilter.name, inArg, inArg)
}

re, err := regexp.Compile(inArg.(string))
if err != nil {
return nil, fmt.Errorf("expected argument %d in call to %s() to be valid regex.Regexp string, got: %v (%T), err: %v", i+1, inFunction.name, inArg, inArg, err)
return nil, fmt.Errorf("expected argument %d in call to '%s()' to be a valid regex.Regexp string, got %v (%T), err %v", i+1, inFilter.name, inArg, inArg, err)
}

args = append(args, reflect.ValueOf(re))
Expand Down
2 changes: 1 addition & 1 deletion internal/pipesore/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func TestExecute(t *testing.T) {
want := "4 bird\n"
got := &bytes.Buffer{}

err := Execute(filters, strings.NewReader(input), got)
err := execute(filters, strings.NewReader(input), got)
if err != nil {
t.Fatal(err)
}
Expand Down
Loading

0 comments on commit ccb7026

Please sign in to comment.