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..89d40c7 100644 --- a/README.md +++ b/README.md @@ -1 +1,93 @@ -# 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) + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +Простой интерпретатор командной строки, поддерживающий [самореализованные команды](#поддерживаемые-команды), вызов внешних программ, а также поддержку переменных, своих и окружения. + +## Поддерживаемые команды +Данный интерпретатор уже поддерживает многие команды, такие как cat, echo, wc, grep и многие другие. +Подробнее о них можете узнать в [Commands.md](docs/Commands.md) + +## Запуск под macOS/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", "_*" + +## Тестирование + +Для запуска всех unit-test программы, запустите: +```bash +go test -v ./... +``` + +## Выбор библиотеки для работы с флагами + +### Почему `pflag`? + +Основные причины выбора `pflag` для вашего CLI-проекта: + +1. **Совместимость с POSIX/GNU стилем флагов** + - Поддержка коротких (`-v`) и длинных (`--verbose`) флагов + - Возможность комбинирования коротких флагов (`-a -b -c` → `-abc`) + +2. **Интеграция с Go-экосистемой** + - Разработана создателями Cobra (популярного фреймворка для CLI) + - Широко используется в известных Go-проектах (Kubernetes, Docker CLI) + +3. **Богатый функционал** + ```go + flagSet.IntVarP(&port, "port", "p", 8080, "server port") + ``` + - Поддержка типизированных флагов (int, bool, string и др.) + - Автоматическая генерация help-сообщений + + +### Сравнительная таблица + +| Библиотека | POSIX флаги | Подкоманды | Типизация | Сложность | Размер | +|------------|------------|------------|-----------|-----------|--------| +| `flag` | ❌ | ❌ | ✅ | Low | 0MB | +| `pflag` | ✅ | ❌ | ✅ | Medium | 0.5MB | +| `cli` | ✅ | ✅ | ✅ | High | 2MB | +| `cobra` | ✅ | ✅ | ✅ | High | 3MB | +| `kong` | ✅ | ✅ | ✅ | Very High | 1MB | + +### Итоговый выбор + +`pflag` идеально подходит для вашего проекта потому что: +1. Обеспечивает нужную функциональность для `grep` (поддержка `-i`, `-w`, `-A`) +2. Не вводит избыточных зависимостей +3. Сохраняет совместимость с Unix-традициями +4. Позволяет легко расширять функционал в будущем + + +## Contribution + +Если вам так понравился наш продукт, что у вас появилось желание его доработать, вы всегда можете добавить в него свой функционал. + +[Как мне это сделать?](docs/Contribution.md) + diff --git a/bin/cli-app b/bin/cli-app new file mode 100755 index 0000000..cdee0de Binary files /dev/null and b/bin/cli-app differ diff --git a/bin/cli-app-darwin-arm64 b/bin/cli-app-darwin-arm64 new file mode 100755 index 0000000..a856c4d Binary files /dev/null and b/bin/cli-app-darwin-arm64 differ diff --git a/bin/cli-app-linux-amd64 b/bin/cli-app-linux-amd64 new file mode 100644 index 0000000..68c4905 Binary files /dev/null and b/bin/cli-app-linux-amd64 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..e73f7a7 --- /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/Commands.md b/docs/Commands.md new file mode 100644 index 0000000..fba0639 --- /dev/null +++ b/docs/Commands.md @@ -0,0 +1,142 @@ +## CLI Commands Documentation + +### `cat` +**Description**: Concatenate and print file contents or stdin +**Usage**: `cat [FILE]...` +**Arguments**: +- `FILE`: One or more files to display (optional, reads from stdin if omitted) + +**Behavior**: +- With files: Outputs contents of all specified files concatenated +- Without files: Outputs contents from stdin (if provided) +- Returns error if no input source is available + +**Examples**: +```bash +> cat file.txt +> echo "text" | cat +``` + +--- + +### `echo` +**Description**: Print arguments to stdout +**Usage**: `echo [STRING]...` +**Arguments**: +- `STRING`: Text to output (supports quoted strings and `\n` escapes) + +**Features**: +- Removes outer quotes (`"` or `'`) if present +- Converts `\n` to newlines in single-quoted strings +- Joins multiple arguments with spaces + +**Examples**: +```bash +> echo hello world +> echo '"quoted"' → quoted +> echo -e 'line1\nline2' → line1[newline]line2 +``` + +--- + +### `exit` +**Description**: Terminate the shell +**Usage**: `exit [STATUS]` +**Arguments**: +- `STATUS`: Exit code (default: 0) + +**Behavior**: +- Accepts numeric exit code (0-255) +- Immediately terminates process with specified code + +**Examples**: +```bash +> exit +> exit 1 +``` + +--- + +### `pwd` +**Description**: Print working directory +**Usage**: `pwd` +**Output**: Absolute path of current directory + +**Error Cases**: +- Fails if directory cannot be determined + +**Example**: +```bash +> pwd +/home/user/project +``` + +--- + +### `wc` +**Description**: Count lines, words, and characters +**Usage**: `wc [FILE]` +**Arguments**: +- `FILE`: File to analyze (reads from stdin if omitted) + +**Output Format**: `lines words bytes` + +**Behavior**: +- Counts: + - Lines (`\n` separated) + - Words (whitespace-separated) + - Bytes (raw length) +- Requires either file or stdin input + +**Examples**: +```bash +> wc file.txt +3 15 102 +> echo "hello world" | wc +1 2 12 +``` + +--- + +### `grep` +**Description**: Pattern search in text +**Usage**: `grep [OPTIONS] PATTERN` +**Arguments**: +- `PATTERN`: Regular expression to search + +**Options**: +| Flag | Description | +|------|--------------------------------------| +| `-i` | Case-insensitive search | +| `-w` | Match whole words only | +| `-A N` | Print N lines after each match | + +**Behavior**: +- Processes stdin only +- Supports PCRE regex syntax +- Handles overlapping `-A` contexts +- Returns error for invalid regex + +**Examples**: +```bash +> grep "error" log.txt +> grep -i -A 1 "warning" < input.txt +> echo "test" | grep -w "test" +``` + +--- + +### Notes +1. All commands: + - Accept input via stdin when piped + - Return `*bytes.Buffer` with output + - Propagate errors with context + +2. Common patterns: + - Empty args → use stdin (where applicable) + - File operations → relative to current `pwd` + - String parsing → handles basic quoting + +3. Error handling: + - Commands return descriptive errors + - Exit codes follow Unix conventions (where applicable) \ No newline at end of file diff --git a/docs/Contribution.md b/docs/Contribution.md new file mode 100644 index 0000000..066c329 --- /dev/null +++ b/docs/Contribution.md @@ -0,0 +1,97 @@ +# Contribution + +Для того, чтобы добавить обработку новых команд в CLI, вам необходимо: +- Создать fork этого проекта +- В файле [commands.go](../internal/executor/commands.go) добавить функцию со следующей сигнатурой, [требования/советы](#требованиясоветы-к-реализации): +```go +func name_cmd(cmd parseline.Command, b *bytes.Buffer) (*bytes.Buffer, error) {} +``` +- Напиши тесты для новой функции в фаил [commands_test.go](../internal/executor/commands_test.go) +```go +//Напишите название новой функции +func TestNameCommand(t *testing.T) { + tests := []struct { + name string // название теста + cmd parseline.Command // команда, которую хотите выполнить + input *bytes.Buffer // буффер от прошлой функции в pipeline + want string // ожидаемый результат + wantErr bool // должена ли функция возвращать ошибку + }{ + // Задайте параметры тестов + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Вместо NAME_CMD напишите название новой функции + got, err := NAME_CMD(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) + } + }) + } +} +``` + +- После прохождения тестов, добавьте новый функционал в систему. Для этого вернемся в фаил [commands.go](../internal/executor/commands.go) и в методе New() добавьте инициализацию вашей команды. +```go +func newCommands() commands { + cmds := make(commands) + + // Here you can add new command in CLI + // cmd["name_command"] = name_command + // below you need to implement a command with the following signature: + // func(parseline.Command, *bytes.Buffer) (*bytes.Buffer, error) + cmds["cat"] = cat + cmds["echo"] = echo + cmds["exit"] = exit + cmds["pwd"] = pwd + cmds["wc"] = wc + cmds["grep"] = grep + + + return cmds +} +``` +- Дополните документацию по новой команде в [Commands.md](Commands.md) + +## Требования/советы к реализации: +- На вход подается структура parseline.Command, которая задана в файле [parser.go](../internal/parseline/parser.go) +```go +// Command store name of command and it's flags and args +type Command struct { + Name string + Args []string +} +``` + +- Парсер разделяет получаемую команду на command и args, в буффере будет записана только возвращаемое значение предыдущей команды в pipeline. Буффер всегда будет инициализован. + +- В случае если функция завершилась без ошибки, но ничего не возвращает, возвращать не nil буффер!!! +```go +return bytes.NewBufferString(""), nil // так + + +return nil, nil // не так +``` +- В случае если во время испольнения произошла какая-то ошибка, вы вольны создавать ее как через пакет errors, так и через пакет fmt +```go +return nil, errors.New("no input provided") // так, в случае создания ошибки + +return nil, fmt.Errorf("cat: %w", err) // так, в случае обертки надо полученной ошибкой +``` +- Parser соберет command без удаление скобок и ковычек, примеры: +``` +>>> echo "111" +``` +На испольнение будет передана команда: +```go +Command { + name: "echo", + args: [`"111"`], +} +``` + + 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..dc2bfde --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +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/spf13/pflag v1.0.6 // 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..d2d1bb9 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +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/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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..45c2b9e --- /dev/null +++ b/internal/environment/environment.go @@ -0,0 +1,47 @@ +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 +} + +// Updates environment variables +// Recommended to use if your command changes the working directory. +func (env Env) Reset() { + 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) + } +} +// 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..ee2b829 --- /dev/null +++ b/internal/executor/commands.go @@ -0,0 +1,193 @@ +package executor + +import ( + "CLI/internal/parseline" + "bytes" + "fmt" + "os" + "strings" + "errors" + "strconv" + "regexp" + + "github.com/spf13/pflag" +) + +type commands map[string]func(parseline.Command, *bytes.Buffer) (*bytes.Buffer, error) + + +func newCommands() commands { + cmds := make(commands) + + // Here you can add new command in CLI + // cmd["name_command"] = name_command + // below you need to implement a command with the following signature: + // func(parseline.Command, *bytes.Buffer) (*bytes.Buffer, error) + cmds["cat"] = cat + cmds["echo"] = echo + cmds["exit"] = exit + cmds["pwd"] = pwd + cmds["wc"] = wc + cmds["grep"] = grep + + + return cmds +} + +//Here you need implement a command with the following signature: +// func name_command(parseline.Command, *bytes.Buffer) (*bytes.Buffer, error) {} + +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 string + if len(cmd.Args) == 0 { + input = b.String() + } else if len(cmd.Args) > 0 { + data, err := os.ReadFile(cmd.Args[0]) + if err != nil { + return nil, fmt.Errorf("wc: %w", err) + } + input = string(data) + } else { + return nil, errors.New("wc: no input provided") + } + lines := strings.Count(input, "\n") + words := len(strings.Fields(input)) + chars := len(input) + + result := fmt.Sprintf("%d %d %d", lines, words, chars) + return bytes.NewBufferString(result), nil +} + +func grep(cmd parseline.Command, input *bytes.Buffer) (*bytes.Buffer, error) { + var ( + caseInsensitive bool + wordRegexp bool + afterContext int + ) + + + flagSet := pflag.NewFlagSet("grep", pflag.ContinueOnError) + flagSet.BoolVarP(&caseInsensitive, "ignore-case", "i", false, "Case-insensitive search") + flagSet.BoolVarP(&wordRegexp, "word-regexp", "w", false, "Match whole word") + flagSet.IntVarP(&afterContext, "after-context", "A", 0, "Number of trailing context lines to print") + + if err := flagSet.Parse(cmd.Args); err != nil { + return nil, err + } + + args := flagSet.Args() + if len(args) < 1 { + return nil, errors.New("pattern is required") + } + pattern := args[0] + + var reBuilder strings.Builder + if caseInsensitive { + reBuilder.WriteString("(?i)") + } + if wordRegexp { + reBuilder.WriteString(`\b`) + } + reBuilder.WriteString(pattern) + if wordRegexp { + reBuilder.WriteString(`\b`) + } + + re, err := regexp.Compile(reBuilder.String()) + if err != nil { + return nil, fmt.Errorf("invalid regex pattern: %v", err) + } + + inputData := input.String() + if inputData == "" { + data, err := os.ReadFile(cmd.Args[len(cmd.Args) - 1]) + if err != nil { + inputData = "" + } + inputData = string(data) + } + lines := strings.Split(inputData, "\n") + printed := make(map[int]struct{}) + + for i, line := range lines { + if re.MatchString(line) { + end := i + afterContext + if end >= len(lines) { + end = len(lines) - 1 + } + for j := i; j <= end; j++ { + printed[j] = struct{}{} + } + } + } + + var resultBuffer bytes.Buffer + for i := 0; i < len(lines); i++ { + if _, ok := printed[i]; ok { + resultBuffer.WriteString(lines[i]) + resultBuffer.WriteByte('\n') + } + } + + return &resultBuffer, 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..ec81d77 --- /dev/null +++ b/internal/executor/commands_test.go @@ -0,0 +1,285 @@ +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) + } + }) + } +} + +var testInput = []byte(`Lorem ipsum dolor sit amet, +consectetur adipiscing elit. +Lorem IPSUM dolor sit amet, +Praesent non Word_WORD word. +Word at start. +end Word +`) + +func TestGrepBasic(t *testing.T) { + cmd := parseline.Command{ + Name: "grep", + Args: []string{"Lorem"}, + } + input := bytes.NewBuffer(testInput) + expected := `Lorem ipsum dolor sit amet, +Lorem IPSUM dolor sit amet, +` + + output, err := grep(cmd, input) + if err != nil { + t.Fatalf("Grep failed: %v", err) + } + if output.String() != expected { + t.Errorf("Expected:\n%s\nGot:\n%s", expected, output.String()) + } +} + +func TestGrepCaseInsensitive(t *testing.T) { + cmd := parseline.Command{ + Name: "grep", + Args: []string{"-i", "ipsum"}, + } + input := bytes.NewBuffer(testInput) + expected := `Lorem ipsum dolor sit amet, +Lorem IPSUM dolor sit amet, +` + + output, err := grep(cmd, input) + if err != nil { + t.Fatalf("Grep failed: %v", err) + } + if output.String() != expected { + t.Errorf("Expected:\n%s\nGot:\n%s", expected, output.String()) + } +} + +func TestGrepWholeWord(t *testing.T) { + cmd := parseline.Command{ + Name: "grep", + Args: []string{"-w", "Word"}, + } + input := bytes.NewBuffer(testInput) + expected := `Word at start. +end Word +` + + output, err := grep(cmd, input) + if err != nil { + t.Fatalf("Grep failed: %v", err) + } + if output.String() != expected { + t.Errorf("Expected:\n%s\nGot:\n%s", expected, output.String()) + } +} + +func TestGrepAfterContext(t *testing.T) { + cmd := parseline.Command{ + Name: "grep", + Args: []string{"-A", "1", "elit"}, + } + input := bytes.NewBuffer(testInput) + expected := `consectetur adipiscing elit. +Lorem IPSUM dolor sit amet, +` + + output, err := grep(cmd, input) + if err != nil { + t.Fatalf("Grep failed: %v", err) + } + if output.String() != expected { + t.Errorf("Expected:\n%s\nGot:\n%s", expected, output.String()) + } +} + +func TestGrepOverlappingContext(t *testing.T) { + cmd := parseline.Command{ + Name: "grep", + Args: []string{"-A", "2", "Lorem"}, + } + input := bytes.NewBuffer(testInput) + expected := `Lorem ipsum dolor sit amet, +consectetur adipiscing elit. +Lorem IPSUM dolor sit amet, +Praesent non Word_WORD word. +Word at start. +` + + output, err := grep(cmd, input) + if err != nil { + t.Fatalf("Grep failed: %v", err) + } + if output.String() != expected { + t.Errorf("Expected:\n%s\nGot:\n%s", expected, output.String()) + } +} + +func TestGrepEmptyInput(t *testing.T) { + cmd := parseline.Command{ + Name: "grep", + Args: []string{"pattern"}, + } + input := bytes.NewBuffer([]byte{}) + expected := "" + + output, err := grep(cmd, input) + if err != nil { + t.Fatalf("Grep failed: %v", err) + } + if output.String() != expected { + t.Errorf("Expected empty output, got: %s", output.String()) + } +} + +func TestGrepInvalidRegex(t *testing.T) { + cmd := parseline.Command{ + Name: "grep", + Args: []string{"*invalid["}, + } + input := bytes.NewBuffer(testInput) + + _, err := grep(cmd, input) + if err == nil { + t.Error("Expected error for invalid regex, got nil") + } +} + diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 0000000..984ab69 --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,74 @@ +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..3643d30 --- /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.Println(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..15d28f6 --- /dev/null +++ b/internal/parseline/parser.go @@ -0,0 +1,196 @@ +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) + inQuotes := false + quoteChar := byte(0) + + for i < n { + switch { + case s[i] == '"': + if !inQuotes { + + inQuotes = true + quoteChar = s[i] + } else if s[i] == quoteChar { + + inQuotes = false + quoteChar = 0 + } + result.WriteByte(s[i]) + i++ + + case s[i] == '$' && !inQuotes: + 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) + } + } + + default: + 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 100755 index 0000000..aa94284 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +APP_NAME="cli-app" +OUTPUT_DIR="./bin" + +GOOS="$(uname -s | tr '[:upper:]' '[:lower:]')" # linux/darwin +GOARCH="$(uname -m)" # x86_64/arm64 + + +case "$GOARCH" in + x86_64) GOARCH="amd64" ;; + aarch64) GOARCH="arm64" ;; +esac + +mkdir -p "$OUTPUT_DIR" + +echo "Building for $GOOS/$GOARCH..." +GOOS="$GOOS" GOARCH="$GOARCH" go build -o "$OUTPUT_DIR/$APP_NAME-$GOOS-$GOARCH" ./cmd/cli + +if [ -f "$OUTPUT_DIR/$APP_NAME-$GOOS-$GOARCH" ]; then + echo "Build successful. Binary: $OUTPUT_DIR/$APP_NAME-$GOOS-$GOARCH" +else + echo "Build failed!" + 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 100755 index 0000000..77be7e9 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +APP_NAME="cli-app" +OUTPUT_DIR="./bin" + + +GOOS="$(uname -s | tr '[:upper:]' '[:lower:]')" +GOARCH="$(uname -m)" +case "$GOARCH" in + x86_64) GOARCH="amd64" ;; + aarch64) GOARCH="arm64" ;; +esac + +BINARY="$OUTPUT_DIR/$APP_NAME-$GOOS-$GOARCH" + +if [ ! -f "$BINARY" ]; then + echo "Error: Binary not found. Run ./build.sh first." + exit 1 +fi + +"$BINARY" "$@" \ No newline at end of file