diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml new file mode 100644 index 0000000..8e29edb --- /dev/null +++ b/.github/workflows/go.yaml @@ -0,0 +1,22 @@ +name: Go + +on: + push + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index db3cc67..053811d 100644 --- a/README.md +++ b/README.md @@ -1 +1,39 @@ -# CLI \ No newline at end of file +# CLI + +[![CI Tests](https://github.com/HSE-Software-Development/CLI/actions/workflows/go.yaml/badge.svg)](https://github.com/HSE-Software-Development/CLI/actions) + +Простой интерпретатор командной строки, поддерживающий [самореализованные команды](#поддерживаемые-команды), вызов внешних программ, а также поддержку переменных, своих и окружения. + +## Поддерживаемые команды +- cat [FILE] — вывести на экран содержимое файла +- echo — вывести на экран свой аргумент (или аргументы) +- wc [FILE] — вывести количество строк, слов и байт в файле +- pwd — распечатать текущую директорию +- exit — выйти из интерпретатора + +## Запуск под Linux +``` bash +# build +chmod +x scripts/build.sh +./scripts/build.sh + +# Run +chmod +x scripts/run.sh +./scripts/run.sh +``` + +## Запуск под Windows +``` bash +# build +scripts\build.bat + +#Run +#scripts\run.bat +.\bin\cli-app.exe +``` + +## Переменные окружения + +При запуске, программа подгружает переменные окружения с вашего устройства. +При запуске под unix-подобной системой это будут: +- "PWD", "SHELL", "TERM", "USER", "OLDPWD", "LS_COLORS", "MAIL", "PATH", "LANG", "HOME", "_*" \ No newline at end of file diff --git a/bin/cli-app b/bin/cli-app new file mode 100644 index 0000000..7a5c8da Binary files /dev/null and b/bin/cli-app differ diff --git a/bin/cli-app.exe b/bin/cli-app.exe new file mode 100644 index 0000000..237d445 Binary files /dev/null and b/bin/cli-app.exe differ diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..149305b --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "CLI/internal/handler" + "fmt" + "os" + "os/signal" + "syscall" +) + +func main() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigChan + fmt.Println("\nexit:", sig) + os.Exit(0) + }() + handler := handler.New() + handler.Start() +} \ No newline at end of file diff --git a/docs/UML.png b/docs/UML.png new file mode 100644 index 0000000..4d23eef Binary files /dev/null and b/docs/UML.png differ diff --git a/docs/usecase.png b/docs/usecase.png new file mode 100644 index 0000000..6300664 Binary files /dev/null and b/docs/usecase.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d63de37 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module CLI + +go 1.18 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fe99d71 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/environment/environment.go b/internal/environment/environment.go new file mode 100644 index 0000000..bbf14bc --- /dev/null +++ b/internal/environment/environment.go @@ -0,0 +1,35 @@ +package environment + +import ( + "fmt" + "os" +) + +type Env map[string]string + + +// Constructor of environment +func New() Env { + env := Env{} + env_var := []string{ + "PWD", "SHELL", "TERM", "USER", "OLDPWD", "LS_COLORS", "MAIL", "PATH", "LANG", "HOME", "_*", + } + for _, v := range env_var { + cmd := os.Getenv(v) + env[v] = string(cmd) + } + + return env +} +// Set a new variable +func (env Env) Set(variable, value string) { + env[variable] = value +} + +// Get a new variable +func (env Env) Get(variable string) (string, error) { + if v, ok := env[variable]; ok { + return v, nil + } + return "", fmt.Errorf("unknown command: %s", variable) +} \ No newline at end of file diff --git a/internal/environment/environment_test.go b/internal/environment/environment_test.go new file mode 100644 index 0000000..82fc922 --- /dev/null +++ b/internal/environment/environment_test.go @@ -0,0 +1,36 @@ +package environment + +import ( + "testing" + "fmt" + + "github.com/stretchr/testify/assert" +) + +func TestConstructor(t *testing.T) { + env := New() + assert.Equal(t, len(env), 11) + fmt.Println("------") + fmt.Println(env["PWD"]) + fmt.Println("------") +} + + +func TestEnv(t *testing.T) { + env := New() + variables := map[string]string { + "111": "xxx", + "222": "yyy", + "333": "zzz", + } + for k, v := range variables { + env.Set(k, v) + } + for k, v := range variables { + if val, err := env.Get(k); err != nil { + assert.Error(t, err) + } else { + assert.Equal(t, v, val) + } + } +} \ No newline at end of file diff --git a/internal/executor/commands.go b/internal/executor/commands.go new file mode 100644 index 0000000..a1c9725 --- /dev/null +++ b/internal/executor/commands.go @@ -0,0 +1,105 @@ +package executor + +import ( + "CLI/internal/parseline" + "bytes" + "fmt" + "os" + "strings" + "errors" + "strconv" +) + +type commands map[string]func(parseline.Command, *bytes.Buffer) (*bytes.Buffer, error) + + +func newCommands() commands { + cmds := make(commands) + cmds["cat"] = cat + cmds["echo"] = echo + cmds["exit"] = exit + cmds["pwd"] = pwd + cmds["wc"] = wc + return cmds +} + +func cat(cmd parseline.Command, b *bytes.Buffer) (*bytes.Buffer, error) { + output := bytes.NewBuffer(nil) + if len(cmd.Args) == 0 { + if b != nil { + _, err := output.Write(b.Bytes()) + return output, err + } + return nil, errors.New("no input provided") + } + + for _, filename := range cmd.Args { + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("cat: %w", err) + } + output.Write(data) + } + + return output, nil +} + +func echo(cmd parseline.Command, b *bytes.Buffer) (*bytes.Buffer, error) { + if len(cmd.Args) == 0 { + return bytes.NewBufferString(""), nil + } + + content := strings.Join(cmd.Args, " ") + if content[0] == '"' { + content = content[1:len(content) - 1] + } + if content[0] == '\'' { + content = content[1:len(content) - 1] + content = strings.ReplaceAll(content, "\\n", "\n") + } + b.Reset() + b.WriteString(content) + return b, nil +} + +func exit(cmd parseline.Command, b *bytes.Buffer) (*bytes.Buffer, error) { + code := 0 + if len(cmd.Args) > 0 { + if c, err := strconv.Atoi(cmd.Args[0]); err == nil { + code = c + } + } + os.Exit(code) + return nil, nil +} + +func pwd(cmd parseline.Command, _ *bytes.Buffer) (*bytes.Buffer, error) { + dir, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("pwd: %w", err) + } + output := bytes.NewBufferString(dir) + return output, nil +} + +func wc(cmd parseline.Command, b *bytes.Buffer) (*bytes.Buffer, error) { + var input []byte + if b != nil { + input = b.Bytes() + } else if len(cmd.Args) > 0 { + data, err := os.ReadFile(cmd.Args[0]) + if err != nil { + return nil, fmt.Errorf("wc: %w", err) + } + input = data + } else { + return nil, errors.New("wc: no input provided") + } + + lines := bytes.Count(input, []byte{'\n'}) + words := len(bytes.Fields(input)) + chars := len(input) + + result := fmt.Sprintf("%d %d %d", lines, words, chars) + return bytes.NewBufferString(result), nil +} \ No newline at end of file diff --git a/internal/executor/commands_test.go b/internal/executor/commands_test.go new file mode 100644 index 0000000..f22c118 --- /dev/null +++ b/internal/executor/commands_test.go @@ -0,0 +1,149 @@ +package executor + +import ( + "CLI/internal/parseline" + "bytes" + "os" + "testing" +) + +func TestCat(t *testing.T) { + tmpfile, err := os.CreateTemp("", "testfile") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + tmpfile.WriteString("test data\n") + + tests := []struct { + name string + cmd parseline.Command + input *bytes.Buffer + want string + wantErr bool + }{ + { + name: "Read file", + cmd: parseline.Command{Name: "cat", Args: []string{tmpfile.Name()}}, + input: nil, + want: "test data\n", + wantErr: false, + }, + { + name: "No file", + cmd: parseline.Command{Name: "cat", Args: []string{"nonexistent.txt"}}, + input: nil, + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := cat(tt.cmd, tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("cat() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != nil && got.String() != tt.want { + t.Errorf("cat() = %v, want %v", got.String(), tt.want) + } + }) + } +} + +func TestEcho(t *testing.T) { + tests := []struct { + name string + cmd parseline.Command + input *bytes.Buffer + want string + wantErr bool + }{ + { + name: "Simple echo", + cmd: parseline.Command{Name: "echo", Args: []string{"hello"}}, + input: bytes.NewBufferString(""), + want: "hello", + wantErr: false, + }, + { + name: "Multiple args", + cmd: parseline.Command{Name: "echo", Args: []string{`"Hello\nWorld"`}}, + input: bytes.NewBufferString(""), + want: "Hello\\nWorld", + wantErr: false, + }, + { + name: "Multiple args2", + cmd: parseline.Command{Name: "echo", Args: []string{`'Hello\nWorld'`}}, + input: bytes.NewBufferString(""), + want: "Hello\nWorld", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := echo(tt.cmd, tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("echo() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got.String() != tt.want { + t.Errorf("echo() = %v, want %v", got.String(), tt.want) + } + }) + } +} + +func TestPwd(t *testing.T) { + want, _ := os.Getwd() + cmd := parseline.Command{Name: "pwd", Args: nil} + got, err := pwd(cmd, nil) + if err != nil { + t.Errorf("pwd() error = %v", err) + } + if got.String() != want { + t.Errorf("pwd() = %v, want %v", got.String(), want) + } +} + +func TestWc(t *testing.T) { + tests := []struct { + name string + cmd parseline.Command + input *bytes.Buffer + want string + wantErr bool + }{ + { + name: "Count lines/words/chars", + cmd: parseline.Command{Name: "wc", Args: nil}, + input: bytes.NewBufferString("hello world\n"), + want: "1 2 12", + wantErr: false, + }, + { + name: "Read from file", + cmd: parseline.Command{Name: "wc", Args: []string{"testfile.txt"}}, + input: nil, + want: "", + wantErr: true, // Файла нет + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := wc(tt.cmd, tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("wc() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != nil && got.String() != tt.want { + t.Errorf("wc() = %v, want %v", got.String(), tt.want) + } + }) + } +} + diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 0000000..1df0b9b --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,75 @@ +package executor + +import ( + "CLI/internal/environment" + "CLI/internal/parseline" + "bytes" + "fmt" + "os/exec" + "strings" +) + +// Executor stores a self-implemented functions and a reference to an object storing environment variables +type Executor struct { + cmds commands + env environment.Env +} + +// Constructor: creates a new Executor and initializes commands +// Parameters: +// - env: environment.Env +func New(env environment.Env) *Executor { + return &Executor{ + env: env, + cmds: newCommands(), + } +} + +func (executor *Executor) execute(command parseline.Command, b *bytes.Buffer) (*bytes.Buffer, error) { + if cmd, ok := executor.cmds[command.Name]; ok { + return cmd(command, b) + } else if strings.ContainsRune(command.Name, '=' ){ + split := strings.Split(command.Name, "=") + if len(split) != 2 { + return nil, fmt.Errorf("command %s: '=' is incorrect symbol for variable or value", command.Name) + } + if len(split[0]) == 0 || len(split[1]) == 0 { + return nil, fmt.Errorf("command %s: incorrect lenght of variable or value", command.Name) + } + executor.env.Set(split[0], split[1]) + return bytes.NewBufferString(""), nil + + } else { + var res *exec.Cmd + content := b.String() + if len(content) > 0 { + res = exec.Command(command.Name, append(command.Args, content)...) + } else { + res = exec.Command(command.Name, command.Args...) + } + output, err := res.Output() + if err != nil { + return nil, fmt.Errorf("command %s: %s", command.Name, err.Error()) + } + return bytes.NewBufferString(string(output)), nil + } +} + +// Execute: execute commands, and returns resulting buffer. +// Parameters: +// - commands: []parseline.Command +// Returns: +// - buffer: resulting buffer. +// - err: error of execute. +func (executor *Executor) Execute(commands []parseline.Command) (*bytes.Buffer, error) { + var err error + buffer := bytes.NewBufferString("") + + for _, cmd := range commands { + buffer, err = executor.execute(cmd, buffer) + if err != nil { + return nil, err + } + } + return buffer, nil +} \ No newline at end of file diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go new file mode 100644 index 0000000..a03b96f --- /dev/null +++ b/internal/executor/executor_test.go @@ -0,0 +1,73 @@ +package executor + +import ( + "CLI/internal/environment" + "CLI/internal/parseline" + "testing" + "os" +) + +func TestPipeline_EchoToWc(t *testing.T) { + env := environment.New() + executor := New(env) + commands := []parseline.Command{ + {Name: "echo", Args: []string{"hello\nworld\n"}}, + {Name: "wc", Args: []string{}}, + } + + result, err := executor.Execute(commands) + if err != nil { + t.Fatalf("ExecutePipeline failed: %v", err) + } + + expected := "2 2 12" + if result.String() != expected { + t.Errorf("Expected %q, got %q", expected, result.String()) + } +} + +func TestPipeline_CatToWc(t *testing.T) { + env := environment.New() + executor := New(env) + tmpfile, err := os.CreateTemp("", "testfile") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + tmpfile.WriteString("hello\nworld\n") + + commands := []parseline.Command{ + {Name: "cat", Args: []string{tmpfile.Name()}}, + {Name: "wc", Args: []string{}}, + } + + result, err := executor.Execute(commands) + if err != nil { + t.Fatalf("ExecutePipeline failed: %v", err) + } + + expected := "2 2 12" + if result.String() != expected { + t.Errorf("Expected %q, got %q", expected, result.String()) + } +} + +func TestPipeline_EchoCatWc(t *testing.T) { + env := environment.New() + executor := New(env) + commands := []parseline.Command{ + {Name: "echo", Args: []string{"hello\nworld\n"}}, + {Name: "cat", Args: []string{}}, + {Name: "wc", Args: []string{}}, + } + + result, err := executor.Execute(commands) + if err != nil { + t.Fatalf("ExecutePipeline failed: %v", err) + } + + expected := "2 2 12" + if result.String() != expected { + t.Errorf("Expected %q, got %q", expected, result.String()) + } +} \ No newline at end of file diff --git a/internal/handler/inputHandler.go b/internal/handler/inputHandler.go new file mode 100644 index 0000000..ec74462 --- /dev/null +++ b/internal/handler/inputHandler.go @@ -0,0 +1,54 @@ +package handler + +import ( + "CLI/internal/environment" + "CLI/internal/executor" + "CLI/internal/parseline" + "bufio" + "fmt" + "os" + "runtime" +) + +// TODO. InputHandler WILL store flags. +type InputHandler struct { +} + +func New() *InputHandler { + return &InputHandler{} +} + + + +// Start: starts Read-Execute-Print Loop +func (handler *InputHandler) Start() { + reader := bufio.NewReader(os.Stdin) + env := environment.New() + parser := parseline.New(env) + executor := executor.New(env) + + for { + fmt.Print("\n>>> ") + input, _ := reader.ReadString('\n') + + pipeline, err := parser.ParsePipeline(cropLine(input)) + if err != nil { + fmt.Print(err.Error()) + continue + } + result, err := executor.Execute(pipeline) + if err != nil { + fmt.Print(err.Error()) + continue + } + fmt.Print(result.String()) + } +} + +func cropLine(input string) string { + if runtime.GOOS == "windows" { + return input[: len(input) - 2] + } else { + return input[: len(input) - 1] + } +} \ No newline at end of file diff --git a/internal/parseline/parse_test.go b/internal/parseline/parse_test.go new file mode 100644 index 0000000..dbf63c0 --- /dev/null +++ b/internal/parseline/parse_test.go @@ -0,0 +1,209 @@ +package parseline + +import ( + "CLI/internal/environment" + "testing" + + "github.com/stretchr/testify/assert" +) + + +func TestSubstitution(t *testing.T) { + parser := newTestParser() + tests := []struct{ + name string + input string + expected string + wantErr error + }{ + { + name: "Simple variable ($VAR)", + input: "User: $USER", + expected: "User: alice", + wantErr: nil, + }, + { + name: "Braced variable (${VAR})", + input: "Home: ${HOME}", + expected: "Home: /home/alice", + wantErr: nil, + }, + + { + name: "Multiple variables", + input: "App: $APP_NAME v$VERSION", + expected: "App: testapp v1.0", + wantErr: nil, + }, + + { + name: "Undefined variable", + input: "Path: $TTTT", + expected: "Path: ", + wantErr: nil, + }, + + { + name: "Empty braces (${})", + input: "Test: ${}", + expected: "Test: ${}", + wantErr: nil, + }, + { + name: "Dollar sign only ($)", + input: "Just $", + expected: "Just $", + wantErr: nil, + }, + { + name: "Mixed content", + input: "Run $APP_NAME in ${HOME} with DEBUG=$DEBUG", + expected: "Run testapp in /home/alice with DEBUG=true", + wantErr: nil, + }, + + { + name: "Unclosed braces (${VAR)", + input: "Error: ${USER", + expected: "", + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parser.substitution(tt.input) + if err == nil { + assert.Equal(t, tt.expected, result) + } + }) + } + + + + +} + +func TestParsePipeline(t *testing.T) { + tests := []struct { + name string + input string + expected []Command + wantErr bool + }{ + { + name: "Single command", + input: "ls -l", + expected: []Command{ + {Name: "ls", Args: []string{"-l"}}, + }, + wantErr: false, + }, + { + name: "Pipe with two commands", + input: "cat file.txt | grep 'hello'", + expected: []Command{ + {Name: "cat", Args: []string{"file.txt"}}, + {Name: "grep", Args: []string{"'hello'"}}, + }, + wantErr: false, + }, + { + name: "Quoted arguments", + input: `echo "Hello, World!" | awk '{print 1}'`, + expected: []Command{ + {Name: "echo", Args: []string{`"Hello, World!"`}}, + {Name: "awk", Args: []string{`'{print 1}'`}}, + }, + wantErr: false, + }, + { + name: "Escaped pipe inside quotes", + input: `echo "Hello | World" | sed 's/|/PIPE/g'`, + expected: []Command{ + {Name: "echo", Args: []string{`"Hello | World"`}}, + {Name: "sed", Args: []string{`'s/|/PIPE/g'`}}, + }, + wantErr: false, + }, + { + name: "Escaped", + input: `echo "Hello"\n`, + expected: []Command{ + {Name: "echo", Args: []string{`"Hello"\n`}}, + }, + wantErr: false, + }, + { + name: "Unclosed quotes (error)", + input: `echo "Hello`, + expected: nil, + wantErr: true, + }, + { + name: "Enter variable", + input: `echo 123 | x=y`, + expected: []Command{ + {Name: "echo", Args: []string{"123"}}, + {Name: "x=y", Args: []string{}}, + }, + wantErr: false, + }, + } + parser := newTestParser() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parser.ParsePipeline(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParsePipeline() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !compareCommands(got, tt.expected) { + t.Errorf("ParsePipeline() = %v, want %v", got, tt.expected) + } + }) + } +} + +// compareCommands сравнивает два списка команд. +func compareCommands(a, b []Command) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Name != b[i].Name || !compareStringSlices(a[i].Args, b[i].Args) { + return false + } + } + return true +} + +// compareStringSlices сравнивает два слайса строк. +func compareStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// создает Parser и Env с переменными для тестирования +func newTestParser() *Parser { + env := environment.New() + parser := New(env) + variables := map[string]string { + "USER": "alice", + "HOME": "/home/alice", + "VERSION": "1.0", + "APP_NAME": "testapp", + "DEBUG": "true", + } + for k, v := range variables { + parser.env.Set(k, v) + } + + return parser +} \ No newline at end of file diff --git a/internal/parseline/parser.go b/internal/parseline/parser.go new file mode 100644 index 0000000..9b89fb7 --- /dev/null +++ b/internal/parseline/parser.go @@ -0,0 +1,181 @@ +package parseline + +import ( + "CLI/internal/environment" + "fmt" + "strings" + "errors" + "unicode" +) + +// TODO. Parser WILL store flags for parsing. +type Parser struct { + env environment.Env +} + +// Constructor of parser +// Parameters: env environment.Env +func New(env environment.Env) *Parser { + return &Parser{ + env: env, + } +} +// Command store name of command and it's flags and args +type Command struct { + Name string + Args []string +} + +// ParsePipeline: parses the received string into pipeline +// Parameters: +// - input: string +// Returns: +// - []Command: pipeline +// - error: +func (parser * Parser)ParsePipeline(input string) ([]Command, error) { + var commands []Command + var currentCmd strings.Builder + var inSingleQuote, inDoubleQuote, escaped bool + var currentArg strings.Builder + var args []string + expectingCommand := true + + input, err := parser.substitution(input) + if err != nil { + return nil, err + } + + for _, r := range input { + switch { + case escaped: + currentArg.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + currentArg.WriteRune(r) + case r == '\'' && !inDoubleQuote: + inSingleQuote = !inSingleQuote + currentArg.WriteRune(r) + case r == '"' && !inSingleQuote: + inDoubleQuote = !inDoubleQuote + currentArg.WriteRune(r) + case r == '|' && !inSingleQuote && !inDoubleQuote: + if currentArg.Len() > 0 { + args = append(args, currentArg.String()) + currentArg.Reset() + } + if len(args) > 0 || currentCmd.Len() > 0 { + if currentCmd.Len() == 0 && len(args) > 0 { + currentCmd.WriteString(args[0]) + args = args[1:] + } + commands = append(commands, Command{ + Name: currentCmd.String(), + Args: args, + }) + currentCmd.Reset() + args = nil + expectingCommand = true + } + case unicode.IsSpace(r) && !inSingleQuote && !inDoubleQuote: + // Конец аргумента (если не в кавычках) + if currentArg.Len() > 0 { + args = append(args, currentArg.String()) + currentArg.Reset() + } + default: + currentArg.WriteRune(r) + if expectingCommand && currentCmd.Len() == 0 && currentArg.Len() > 0 { + expectingCommand = false + } + } + } + + if currentArg.Len() > 0 { + args = append(args, currentArg.String()) + } + + if currentCmd.Len() == 0 && len(args) > 0 { + currentCmd.WriteString(args[0]) + args = args[1:] + } + + if currentCmd.Len() > 0 || len(args) > 0 { + commands = append(commands, Command{ + Name: currentCmd.String(), + Args: args, + }) + } + + if inSingleQuote || inDoubleQuote { + return nil, errors.New("unclosed quotes in input") + } + + if escaped { + return nil, errors.New("unfinished escape sequence") + } + + return commands, nil +} + +func (parser *Parser) substitution(s string) (string, error) { + var result strings.Builder + i := 0 + n := len(s) + + for i < n { + if s[i] == '$' { + i++ + if i < n && s[i] == '{' { + i++ + varNameStart := i + + for i < n && s[i] != '}' { + i++ + } + + if i >= n { + return "", errors.New("unclosed ${...} variable") + } + + varName := s[varNameStart:i] + i++ + + value, err := parser.env.Get(varName) + if err != nil { + return "", fmt.Errorf("error getting variable %s: %w", varName, err) + } + result.WriteString(value) + } else { + varNameStart := i + + for i < n && (isAlphaNum(s[i]) || s[i] == '_') { + i++ + } + + varName := s[varNameStart:i] + + if varName == "" { + result.WriteByte('$') + } else { + + value, err := parser.env.Get(varName) + if err != nil { + return "", fmt.Errorf("error getting variable %s: %w", varName, err) + } + + result.WriteString(value) + } + } + } else { + result.WriteByte(s[i]) + i++ + } + } + + return result.String(), nil +} + +func isAlphaNum(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') +} \ No newline at end of file diff --git a/scripts/build.bat b/scripts/build.bat new file mode 100644 index 0000000..4c65d32 --- /dev/null +++ b/scripts/build.bat @@ -0,0 +1,20 @@ +@echo off + +set APP_NAME=cli-app.exe +set GOOS=windows +set GOARCH=amd64 +set OUTPUT_DIR=.\bin + +if not exist %OUTPUT_DIR% ( + mkdir %OUTPUT_DIR% +) + +echo Building: %GOOS%/%GOARCH%... +go build -o %OUTPUT_DIR%\%APP_NAME% -buildvcs=false .\cmd\cli + +if exist "%OUTPUT_DIR%\%APP_NAME%" ( + echo Path: %OUTPUT_DIR%\%APP_NAME% +) else ( + echo Building error. + exit /b 1 +) \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..2e4b883 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +APP_NAME="cli-app" +GOOS="linux" +GOARCH="amd64" +OUTPUT_DIR="./bin" + +mkdir -p $OUTPUT_DIR + +echo "build for $GOOS/$GOARCH..." +GOOS=$GOOS GOARCH=$GOARCH go build -o $OUTPUT_DIR/$APP_NAME ./cmd/cli + +if [ -f "$OUTPUT_DIR/$APP_NAME" ]; then + echo "Build over. Path: $OUTPUT_DIR/$APP_NAME" +else + echo "Building error" + exit 1 +fi \ No newline at end of file diff --git a/scripts/run.bat b/scripts/run.bat new file mode 100644 index 0000000..669f263 --- /dev/null +++ b/scripts/run.bat @@ -0,0 +1,11 @@ +@echo off + +set BIN_PATH=.\bin\cli-app.exe + +if not exist %BIN_PATH% ( + echo File not found. Build app (scripts\build.bat). + exit /b 1 +) + +echo Running +%BIN_PATH% \ No newline at end of file diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100644 index 0000000..a718a2b --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +BIN_PATH="./bin/cli-app" + +if [ ! -f "$BIN_PATH" ]; then + echo "File not found. Build app (scripts/build.sh)." + exit 1 +fi + +echo "Running..." +$BIN_PATH \ No newline at end of file