commit c9d6d384916ec2d4647a6617e6d998aa3f7427bc Author: gvsafronov Date: Fri May 22 00:26:27 2026 +0300 first commit diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..93b41aa --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2026 Grigorii Safronov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..69e25c1 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# fush shell + +**F**usion **U**nix **SH**ell — легковесная, расширяемая командная оболочка с поддержкой Linux и OpenIndiana. + +[![Go Version](https://img.shields.io/badge/Go-1.26-blue.svg)](https://golang.org/) +[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) + +## Особенности + +- 🚀 **Легковесная** — минимальное потребление ресурсов +- 🔧 **Расширяемая** — поддержка Lua скриптов +- 🎨 **Красивая** — ANSI цвета для приглашения и вывода +- 📜 **История команд** — сохранение и навигация по истории +- ⚙️ **Конфигурируемая** — TOML конфигурация +- 🔄 **Кроссплатформенная** — Linux и OpenIndiana (Illumos) + +## Быстрый старт + +### Установка + +```bash +# Клонирование репозитория +git clone https://github.com/yourusername/fush.git +cd fush + +# Сборка +go build -o fush + +# Установка (опционально) +sudo cp fush /usr/local/bin/ diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..22c188d --- /dev/null +++ b/build.sh @@ -0,0 +1,565 @@ +#!/bin/bash + +# build.sh - универсальный скрипт сборки для fush shell +# Поддерживает операционные системы Linux и OpenIndiana/Hipster +# Обеспечивает компиляцию, тестирование, установку и запуск shell +# Использует цветной вывод и автоматическое определение окружения + +set -e + +# Определение цветов для вывода +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + CYAN='\033[0;36m' + MAGENTA='\033[0;35m' + NC='\033[0m' # No Color +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + CYAN='' + MAGENTA='' + NC='' +fi + +# Определение корневой директории проекта fush +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$SCRIPT_DIR" +FUSH_DIR="$PROJECT_ROOT" + +# Проверяем, что мы находимся в директории проекта fush +if [ ! -d "$FUSH_DIR" ] || [ ! -f "$FUSH_DIR/go.mod" ]; then + # Пробуем подняться на уровень выше (на случай если скрипт в scripts/) + if [ -f "$SCRIPT_DIR/../go.mod" ]; then + FUSH_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + PROJECT_ROOT="$FUSH_DIR" + cd "$FUSH_DIR" + echo -e "${YELLOW}Скрипт запущен из поддиректории. Переход в корень проекта: ${FUSH_DIR}${NC}" + else + echo -e "${RED}error: Не найдена директория проекта fush с файлом go.mod${NC}" + echo -e "${RED}error: Проверены директории:${NC}" + echo -e "${RED} - ${FUSH_DIR}${NC}" + echo -e "${RED} - ${SCRIPT_DIR}/..${NC}" + echo -e "${RED}Убедитесь, что файл go.mod существует в корне проекта${NC}" + exit 1 + fi +else + cd "$FUSH_DIR" +fi + +# Определение переменных +PROJECT_NAME="fush" +BINARY_NAME="bin/${PROJECT_NAME}" +ROOT_BINARY_NAME="${PROJECT_NAME}" +BUILD_DIR="bin" +GO="go" + +# Получение информации о версии +BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') +GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") +GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//') +REQUIRED_VERSION="1.26.0" + +# Функция для сравнения версий (работает без sort -V) +version_compare() { + # Удаляем префикс 'v' если есть + local v1="${1#v}" + local v2="${2#v}" + + # Разбиваем на компоненты + IFS='.' read -ra v1_arr <<< "$v1" + IFS='.' read -ra v2_arr <<< "$v2" + + # Сравниваем компоненты + for i in 0 1 2; do + # Получаем числовые значения, удаляя ведущие нули + local num1="${v1_arr[$i]}" + local num2="${v2_arr[$i]}" + + # Удаляем ведущие нули + num1="${num1#0}" + num2="${num2#0}" + + # Если строка пустая после удаления нулей, значит было "0" + if [ -z "$num1" ]; then + num1=0 + fi + if [ -z "$num2" ]; then + num2=0 + fi + + # Сравниваем как числа + if [ $num1 -lt $num2 ]; then + echo "-1" + return + elif [ $num1 -gt $num2 ]; then + echo "1" + return + fi + done + echo "0" +} + +# Функция для отображения прав и информации о файле +show_file_info() { + local file_path="$1" + local file_name="$2" + + if [ -f "$file_path" ]; then + echo -e "${CYAN}================================${NC}" + echo -e "${GREEN}Информация о собранном файле:${NC}" + echo -e "${CYAN}Имя файла:${NC} ${YELLOW}${file_name}${NC}" + echo -e "${CYAN}Полный путь:${NC} ${YELLOW}${file_path}${NC}" + echo -e "${CYAN}Размер:${NC} ${YELLOW}$(ls -lh "$file_path" | awk '{print $5}')${NC}" + echo -e "${CYAN}Права доступа:${NC} ${YELLOW}$(ls -l "$file_path" | awk '{print $1}')${NC}" + echo -e "${CYAN}Владелец:${NC} ${YELLOW}$(ls -l "$file_path" | awk '{print $3":"$4}')${NC}" + echo -e "${CYAN}Тип файла:${NC} ${YELLOW}$(file "$file_path" | cut -d: -f2)${NC}" + echo -e "${CYAN}================================${NC}" + else + echo -e "${RED}Файл не найден: ${file_path}${NC}" + fi +} + +# Функция для вывода справки +show_help() { + echo -e "${CYAN}================================${NC}" + echo -e "${CYAN}fush Shell - Скрипт сборки${NC}" + echo -e "${CYAN}================================${NC}" + echo -e "${GREEN}Использование:${NC}" + echo -e " $0 [команда]" + echo + echo -e "${GREEN}Доступные команды:${NC}" + echo -e " ${YELLOW}all${NC} - Очистка и сборка (по умолчанию)" + echo -e " ${YELLOW}build${NC} - Сборка проекта" + echo -e " ${YELLOW}clean${NC} - Очистка временных файлов" + echo -e " ${YELLOW}install${NC} - Установка в систему" + echo -e " ${YELLOW}test${NC} - Запуск тестов" + echo -e " ${YELLOW}run${NC} - Сборка и запуск" + echo -e " ${YELLOW}help${NC} - Показать эту справку" + echo + echo -e "${GREEN}Примеры:${NC}" + echo -e " $0 # Выполнить очистку и сборку" + echo -e " $0 build # Только сборка" + echo -e " $0 install # Собрать и установить" + echo -e " $0 run # Собрать и запустить" + echo +} + +# Функция проверки версии Go +check_go_version() { + echo -e "${YELLOW}Проверка версии Go...${NC}" + + if [ -z "$GO_VERSION" ]; then + echo -e "${RED}error: Go не установлен${NC}" + exit 1 + fi + + echo -e "${GREEN}✓ Go версия ${GO_VERSION}${NC}" + + # Сравнение версий с помощью собственной функции + local cmp=$(version_compare "$GO_VERSION" "$REQUIRED_VERSION") + if [ "$cmp" = "-1" ]; then + echo -e "${RED}error: Требуется Go версии ${REQUIRED_VERSION} или выше. Текущая версия: ${GO_VERSION}${NC}" + exit 1 + fi + + echo -e "${GREEN}✓ Версия Go соответствует требованиям${NC}" +} + +# Функция проверки операционной системы +check_os() { + OS=$(uname -s) + case "$OS" in + Linux|SunOS) + echo -e "${GREEN}✓ Поддерживаемая ОС: ${OS}${NC}" + ;; + *) + echo -e "${RED}error: Неподдерживаемая ОС: ${OS}${NC}" + echo -e "${RED}error: fush поддерживает только Linux и OpenIndiana${NC}" + exit 1 + ;; + esac +} + +# Функция проверки наличия файлов проекта +check_project_files() { + echo -e "${YELLOW}Проверка файлов проекта в ${FUSH_DIR}...${NC}" + + # Проверяем наличие go.mod + if [ ! -f "$FUSH_DIR/go.mod" ]; then + echo -e "${RED}error: Файл go.mod не найден в ${FUSH_DIR}${NC}" + exit 1 + fi + echo -e "${GREEN}✓ Найден go.mod${NC}" + + # Проверяем наличие go.sum + if [ ! -f "$FUSH_DIR/go.sum" ]; then + echo -e "${YELLOW}Предупреждение: Файл go.sum не найден в ${FUSH_DIR}${NC}" + echo -e "${YELLOW}Он будет создан при установке зависимостей${NC}" + else + echo -e "${GREEN}✓ Найден go.sum${NC}" + fi + + # Проверяем наличие директории cmd/fush + if [ ! -d "$FUSH_DIR/cmd/fush" ]; then + echo -e "${RED}error: Директория cmd/fush не найдена в ${FUSH_DIR}${NC}" + exit 1 + fi + echo -e "${GREEN}✓ Найдена директория cmd/fush${NC}" + + # Проверяем наличие main.go + if [ ! -f "$FUSH_DIR/cmd/fush/main.go" ]; then + echo -e "${RED}error: Файл cmd/fush/main.go не найден${NC}" + exit 1 + fi + echo -e "${GREEN}✓ Найден main.go${NC}" +} + +# Функция установки зависимостей +install_deps() { + echo -e "${YELLOW}Установка зависимостей в ${FUSH_DIR}...${NC}" + + # Проверяем наличие go.mod + if [ ! -f "go.mod" ]; then + echo -e "${RED}error: Файл go.mod не найден в текущей директории${NC}" + echo -e "${RED}error: Текущая директория: $(pwd)${NC}" + exit 1 + fi + + # Обновляем go.mod и создаем go.sum + echo -e "${YELLOW}Выполнение go mod download...${NC}" + $GO mod download 2>&1 | while IFS= read -r line; do + if [[ "$line" == *"error"* ]] || [[ "$line" == *"Error"* ]]; then + echo -e "${RED}error: $line${NC}" + else + echo "$line" + fi + done + + echo -e "${YELLOW}Выполнение go mod tidy...${NC}" + $GO mod tidy 2>&1 | while IFS= read -r line; do + if [[ "$line" == *"error"* ]] || [[ "$line" == *"Error"* ]]; then + echo -e "${RED}error: $line${NC}" + else + echo "$line" + fi + done + + # Проверяем наличие go.sum после установки + if [ -f "go.sum" ]; then + echo -e "${GREEN}✓ Файл go.sum успешно создан/обновлен${NC}" + GO_SUM_SIZE=$(ls -lh go.sum | awk '{print $5}') + echo -e "${GREEN}✓ Размер go.sum: ${GO_SUM_SIZE}${NC}" + else + echo -e "${RED}error: Файл go.sum не был создан после go mod tidy${NC}" + exit 1 + fi + + # Проверка наличия Lua + echo -e "${YELLOW}Проверка наличия Lua...${NC}" + if command -v lua >/dev/null 2>&1; then + echo -e "${GREEN}✓ Lua найден${NC}" + else + echo -e "${YELLOW}Предупреждение: Lua не найден. Для работы скриптов потребуется установить Lua${NC}" + echo -e "${YELLOW}Установите Lua с помощью вашего пакетного менеджера:${NC}" + echo -e " - Ubuntu/Debian: sudo apt-get install lua5.3" + echo -e " - OpenIndiana: sudo pkg install lua" + fi +} + +# Функция сборки проекта +build_project() { + echo -e "${YELLOW}Компиляция ${PROJECT_NAME}...${NC}" + echo -e "${YELLOW}Директория проекта: ${FUSH_DIR}${NC}" + echo -e "${YELLOW}Текущая директория: $(pwd)${NC}" + + # Проверяем наличие go.mod + if [ ! -f "go.mod" ]; then + echo -e "${RED}error: Файл go.mod не найден. Убедитесь, что вы находитесь в директории проекта fush${NC}" + return 1 + fi + + # Проверяем наличие go.sum + if [ ! -f "go.sum" ]; then + echo -e "${RED}error: Файл go.sum не найден. Выполните сначала установку зависимостей${NC}" + return 1 + fi + + # Проверяем существование директории cmd/fush + if [ ! -d "cmd/fush" ]; then + echo -e "${RED}error: Директория cmd/fush не найдена${NC}" + return 1 + fi + + # Проверяем существование main.go + if [ ! -f "cmd/fush/main.go" ]; then + echo -e "${RED}error: Файл cmd/fush/main.go не найден${NC}" + return 1 + fi + + # Создаем директорию для бинарных файлов + mkdir -p "$BUILD_DIR" + echo -e "${GREEN}✓ Директория ${BUILD_DIR} создана${NC}" + + # Формируем флаги сборки + LDFLAGS="-X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT}" + + # Выполняем сборку с явным указанием выходного файла + echo -e "${YELLOW}Выполнение: go build -v -ldflags=\"${LDFLAGS}\" -o ${BINARY_NAME} ./cmd/fush${NC}" + + # Захватываем вывод команды и выводим в реальном времени + go build -v -ldflags="${LDFLAGS}" -o "${BINARY_NAME}" ./cmd/fush 2>&1 | while IFS= read -r line; do + if [[ "$line" == *"error"* ]] || [[ "$line" == *"Error"* ]] || [[ "$line" == *"undefined"* ]] || [[ "$line" == *"cannot"* ]]; then + echo -e "${RED}error: $line${NC}" + else + echo "$line" + fi + done + + BUILD_EXIT_CODE=${PIPESTATUS[0]} + + # Проверяем результат сборки + if [ $BUILD_EXIT_CODE -ne 0 ]; then + echo -e "${RED}error: Сборка завершилась с ошибкой (код: $BUILD_EXIT_CODE)${NC}" + return 1 + fi + + # Проверяем, создался ли бинарный файл + if [ -f "${BINARY_NAME}" ]; then + echo -e "${GREEN}✓ Сборка успешно завершена${NC}" + echo -e "${GREEN}✓ Исполняемый файл: ${BINARY_NAME}${NC}" + echo -e "${GREEN}✓ Полный путь: ${FUSH_DIR}/${BINARY_NAME}${NC}" + + # Показываем размер бинарного файла + SIZE=$(ls -lh "${BINARY_NAME}" | awk '{print $5}') + echo -e "${GREEN}✓ Размер бинарного файла: ${SIZE}${NC}" + + # Проверка прав доступа + if [ -x "${BINARY_NAME}" ]; then + echo -e "${GREEN}✓ Бинарный файл имеет права на выполнение${NC}" + else + echo -e "${YELLOW}Добавление прав на выполнение...${NC}" + chmod +x "${BINARY_NAME}" + echo -e "${GREEN}✓ Права на выполнение добавлены${NC}" + fi + + # Копируем бинарный файл в корень проекта + echo -e "${YELLOW}Копирование бинарного файла в корень проекта...${NC}" + cp "${BINARY_NAME}" "${ROOT_BINARY_NAME}" + if [ -f "${ROOT_BINARY_NAME}" ]; then + chmod +x "${ROOT_BINARY_NAME}" + echo -e "${GREEN}✓ Бинарный файл скопирован в: ${ROOT_BINARY_NAME}${NC}" + else + echo -e "${RED}error: Не удалось скопировать бинарный файл в корень проекта${NC}" + return 1 + fi + + # Отображаем информацию о собранных файлах + echo "" + show_file_info "${BINARY_NAME}" "fush (в папке bin)" + echo "" + show_file_info "${ROOT_BINARY_NAME}" "fush (в корне проекта)" + + return 0 + else + echo -e "${RED}error: Бинарный файл не создан по пути: ${BINARY_NAME}${NC}" + echo -e "${RED}error: Содержимое директории ${BUILD_DIR}:${NC}" + ls -la "$BUILD_DIR" 2>/dev/null || echo "Директория пуста или не существует" + return 1 + fi +} + +# Функция очистки +clean_project() { + echo -e "${YELLOW}Очистка в ${FUSH_DIR}...${NC}" + + if [ -d "$BUILD_DIR" ]; then + rm -rf "$BUILD_DIR" + echo -e "${GREEN}✓ Директория ${BUILD_DIR} удалена${NC}" + fi + + if [ -f "$PROJECT_NAME" ]; then + rm -f "$PROJECT_NAME" + echo -e "${GREEN}✓ Файл ${PROJECT_NAME} удален${NC}" + fi + + if [ -f "${ROOT_BINARY_NAME}" ]; then + rm -f "${ROOT_BINARY_NAME}" + echo -e "${GREEN}✓ Файл ${ROOT_BINARY_NAME} удален${NC}" + fi + + echo -e "${GREEN}✓ Очистка завершена${NC}" +} + +# Функция установки +install_project() { + echo -e "${YELLOW}Установка ${PROJECT_NAME}...${NC}" + + # Проверяем, существует ли бинарный файл в корне + if [ ! -f "$ROOT_BINARY_NAME" ]; then + echo -e "${YELLOW}Бинарный файл не найден в корне. Выполняется сборка...${NC}" + build_project + if [ $? -ne 0 ]; then + echo -e "${RED}error: Ошибка сборки. Установка прервана.${NC}" + return 1 + fi + fi + + # Копируем в системную директорию + if [ -w "/usr/local/bin" ]; then + cp "$ROOT_BINARY_NAME" /usr/local/bin/ + echo -e "${GREEN}✓ Установлен в /usr/local/bin/${PROJECT_NAME}${NC}" + else + echo -e "${YELLOW}Нет прав на запись в /usr/local/bin. Используем sudo...${NC}" + sudo cp "$ROOT_BINARY_NAME" /usr/local/bin/ + if [ $? -eq 0 ]; then + echo -e "${GREEN}✓ Установлен в /usr/local/bin/${PROJECT_NAME}${NC}" + else + echo -e "${RED}error: Не удалось установить в /usr/local/bin/${PROJECT_NAME}${NC}" + return 1 + fi + fi + + # Создание директорий конфигурации + if [ ! -d ~/.config/fush ]; then + mkdir -p ~/.config/fush + if [ -f "config/fush.toml" ]; then + cp config/fush.toml ~/.config/fush/ + echo -e "${GREEN}✓ Конфигурация скопирована в ~/.config/fush/${NC}" + fi + fi + + # Создание директории для Lua скриптов + if [ ! -d ~/.local/share/fush/lua ]; then + mkdir -p ~/.local/share/fush/lua + if [ -f "lua/example.lua" ]; then + cp lua/example.lua ~/.local/share/fush/lua/ + echo -e "${GREEN}✓ Пример Lua скрипта скопирован в ~/.local/share/fush/lua/${NC}" + fi + fi + + echo -e "${GREEN}✓ Установка завершена${NC}" +} + +# Функция запуска тестов +test_project() { + echo -e "${YELLOW}Запуск тестов в ${FUSH_DIR}...${NC}" + + # Проверяем наличие go.mod + if [ ! -f "go.mod" ]; then + echo -e "${RED}error: Файл go.mod не найден${NC}" + return 1 + fi + + TEST_OUTPUT=$($GO test -v ./... 2>&1) + TEST_EXIT_CODE=$? + + echo "$TEST_OUTPUT" | while IFS= read -r line; do + if [[ "$line" == *"FAIL"* ]] || [[ "$line" == *"error"* ]] || [[ "$line" == *"Error"* ]]; then + echo -e "${RED}$line${NC}" + elif [[ "$line" == *"PASS"* ]] || [[ "$line" == *"ok"* ]]; then + echo -e "${GREEN}$line${NC}" + else + echo "$line" + fi + done + + if [ $TEST_EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}✓ Все тесты пройдены${NC}" + return 0 + else + echo -e "${RED}error: Некоторые тесты не пройдены${NC}" + return 1 + fi +} + +# Функция запуска +run_project() { + echo -e "${YELLOW}Запуск ${PROJECT_NAME}...${NC}" + + # Проверяем, существует ли бинарный файл в корне + if [ ! -f "$ROOT_BINARY_NAME" ]; then + echo -e "${YELLOW}Бинарный файл не найден. Выполняется сборка...${NC}" + build_project + if [ $? -ne 0 ]; then + echo -e "${RED}error: Ошибка сборки. Запуск невозможен.${NC}" + return 1 + fi + fi + + # Запускаем shell + "./$ROOT_BINARY_NAME" +} + +# Функция полной сборки (очистка + сборка) +all_build() { + clean_project + install_deps + build_project +} + +# Основная логика +main() { + echo -e "${CYAN}================================${NC}" + echo -e "${CYAN}fush Shell - Скрипт сборки${NC}" + echo -e "${CYAN}================================${NC}" + echo -e "${YELLOW}Директория проекта fush: ${FUSH_DIR}${NC}" + + # Проверяем наличие файлов проекта + check_project_files + + # Проверяем окружение + check_os + check_go_version + + # Определяем команду + COMMAND="${1:-all}" + + case "$COMMAND" in + all) + all_build + ;; + build) + install_deps + build_project + ;; + clean) + clean_project + ;; + install) + install_deps + build_project + install_project + ;; + test) + install_deps + test_project + ;; + run) + install_deps + build_project + run_project + ;; + help|--help|-h) + show_help + ;; + *) + echo -e "${RED}error: Неизвестная команда: $COMMAND${NC}" + show_help + exit 1 + ;; + esac + + if [ $? -eq 0 ] && [ "$COMMAND" != "help" ]; then + echo -e "${BLUE}================================${NC}" + echo -e "${GREEN}Операция '${COMMAND}' успешно завершена!${NC}" + echo -e "${BLUE}================================${NC}" + fi +} + +# Запуск основной функции +main "$@" diff --git a/cmd/fush/main.go b/cmd/fush/main.go new file mode 100644 index 0000000..388bd8c --- /dev/null +++ b/cmd/fush/main.go @@ -0,0 +1,90 @@ +// main.go - точка входа в приложение fush shell +// Загружает конфигурацию, инициализирует логгер и основные компоненты +// Обрабатывает системные сигналы для корректного завершения работы +// Содержит информацию о версии и времени сборки + +package main + +import ( + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + + "fush/internal/config" + "fush/internal/logger" + "fush/internal/shell" + "fush/pkg/ansi" +) + +var ( + BuildTime = "unknown" + GitCommit = "unknown" +) + +func main() { + // Обработка сигналов для корректного завершения + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Загрузка конфигурации + configPath := getConfigPath() + cfg, err := config.Load(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Ошибка загрузки конфигурации: %v\n", err) + os.Exit(1) + } + + // Инициализация логгера + log, err := logger.New(cfg.LogFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Ошибка инициализации логгера: %v\n", err) + os.Exit(1) + } + defer log.Close() + + // Логирование запуска + log.Info("Запуск fush shell", + "version", GitCommit, + "build_time", BuildTime, + "os", cfg.OS) + + // Вывод приветственного сообщения (цвет BrightCyan - такой же как в help) + fmt.Println() + ansi.Println(ansi.BrightCyan, "Welcome to fush shell") + fmt.Println() + + // Создание и запуск shell + sh := shell.New(cfg, log) + + // Запуск в отдельной горутине для обработки сигналов + go func() { + <-sigChan + log.Info("Получен сигнал завершения") + sh.Shutdown() + os.Exit(0) + }() + + // Запуск основного цикла shell + if err := sh.Run(); err != nil { + log.Error("Ошибка выполнения shell", "error", err) + os.Exit(1) + } +} + +// getConfigPath возвращает путь к файлу конфигурации +func getConfigPath() string { + // Приоритет: флаг командной строки, переменная окружения, стандартный путь + if path := os.Getenv("FUSH_CONFIG"); path != "" { + return path + } + + home, err := os.Getenv("HOME"), error(nil) + if err != nil { + home = "." + } + + // Стандартный путь: ~/.config/fush/fush.toml + return filepath.Join(home, ".config", "fush", "fush.toml") +} diff --git a/config/fush.toml b/config/fush.toml new file mode 100644 index 0000000..d2c33fb --- /dev/null +++ b/config/fush.toml @@ -0,0 +1,22 @@ +# Конфигурационный файл fush shell + +# Приглашение командной строки +prompt = "fush:-> " +prompt_color = "#00bfff" + +# Директории +lua_scripts_dir = "~/.local/share/fush/lua" +history_file = "~/.cache/fush/history" +log_file = "~/.cache/fush/fush.log" + +# Размер истории команд +history_size = 1000 + +# Переменные окружения +[environment] +# Путь для поиска исполняемых файлов +PATH = "/usr/local/bin:/usr/bin:/bin" +# Домашняя директория +HOME = "~" +# Терминал по умолчанию +TERM = "xterm-256color" diff --git a/fush.toml b/fush.toml new file mode 100644 index 0000000..a3c597a --- /dev/null +++ b/fush.toml @@ -0,0 +1,25 @@ +# fush.toml - конфигурационный файл fush shell +# Определяет приглашение командной строки и цветовые схемы +# Указывает пути для хранения истории, логов и Lua скриптов +# Содержит переменные окружения для работы shell + +# Приглашение командной строки +prompt = "fush:-> " +prompt_color = "bright_cyan" # использовать именованный цвет + +# Директории +lua_scripts_dir = "~/.local/share/fush/lua" +history_file = "~/.cache/fush/history" +log_file = "~/.cache/fush/fush.log" + +# Размер истории команд +history_size = 1000 + +# Переменные окружения +[environment] +# Путь для поиска исполняемых файлов +PATH = "/usr/local/bin:/usr/bin:/bin" +# Домашняя директория +HOME = "~" +# Терминал по умолчанию +TERM = "xterm-256color" diff --git a/gitignore.txt b/gitignore.txt new file mode 100644 index 0000000..cbb0e99 --- /dev/null +++ b/gitignore.txt @@ -0,0 +1,70 @@ + +### **.gitignore** + +```gitignore +# Binaries +fush +fush.exe +fush-* +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out + +# Go workspace files +*.mod +*.sum +go.work +go.work.sum + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Build artifacts +/dist/ +/bin/ +/build/ +/tmp/ + +# Logs and data +*.log +*.pid +*.seed +*.pid.lock +*.history + +# Configuration (user-specific) +.env +.env.local +.env.*.local + +# Fush specific +.cache/ +.local/ +.config/ +history +fush.log + +# Test coverage +*.cover +*.coverprofile +coverage.html +coverage.out + +# Debug +__debug_bin +debug +*.dwp + +# OS generated +Thumbs.db +ehthumbs.db +Desktop.ini diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5766da0 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module fush + +go 1.26 + +require ( + github.com/BurntSushi/toml v1.3.2 + github.com/mattn/go-isatty v0.0.20 + github.com/yuin/gopher-lua v1.1.1 +) + +require golang.org/x/sys v0.15.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9793055 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..960b82c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,131 @@ +// config.go - управление конфигурацией fush shell +// Загружает и сохраняет настройки в формате TOML +// Предоставляет конфигурацию по умолчанию для новой установки +// Управляет переменными окружения и путями к директориям + +package config + +import ( + "os" + "path/filepath" + "runtime" + + "github.com/BurntSushi/toml" +) + +// Config представляет структуру конфигурации fush +type Config struct { + // Общие настройки + Prompt string `toml:"prompt"` + PromptColor string `toml:"prompt_color"` + + // Директории + LuaScriptsDir string `toml:"lua_scripts_dir"` + HistoryFile string `toml:"history_file"` + LogFile string `toml:"log_file"` + + // Настройки истории + HistorySize int `toml:"history_size"` + + // Переменные окружения + Environment map[string]string `toml:"environment"` + + // ОС (определяется автоматически) + OS string `toml:"-"` +} + +// DefaultConfig возвращает конфигурацию по умолчанию +func DefaultConfig() *Config { + home, _ := os.UserHomeDir() + + cfg := &Config{ + Prompt: "fush:-> ", + PromptColor: "#00bfff", + LuaScriptsDir: filepath.Join(home, ".local", "share", "fush", "lua"), + HistoryFile: filepath.Join(home, ".cache", "fush", "history"), + LogFile: filepath.Join(home, ".cache", "fush", "fush.log"), + HistorySize: 1000, + Environment: make(map[string]string), + } + + // Определение ОС + cfg.OS = runtime.GOOS + if cfg.OS == "sunos" { + cfg.OS = "OpenIndiana" + } + + // Установка переменных окружения по умолчанию + cfg.Environment["PATH"] = os.Getenv("PATH") + cfg.Environment["HOME"] = home + cfg.Environment["SHELL"] = os.Getenv("SHELL") + cfg.Environment["USER"] = os.Getenv("USER") + + return cfg +} + +// Load загружает конфигурацию из файла +func Load(path string) (*Config, error) { + cfg := DefaultConfig() + + // Проверяем существование файла + if _, err := os.Stat(path); os.IsNotExist(err) { + // Создаем директорию для конфигурации + configDir := filepath.Dir(path) + if err := os.MkdirAll(configDir, 0755); err != nil { + return nil, err + } + + // Сохраняем конфигурацию по умолчанию + if err := Save(cfg, path); err != nil { + return nil, err + } + + return cfg, nil + } + + // Загружаем конфигурацию + if _, err := toml.DecodeFile(path, cfg); err != nil { + return nil, err + } + + // Создаем необходимые директории + if err := os.MkdirAll(filepath.Dir(cfg.HistoryFile), 0755); err != nil { + return nil, err + } + + if err := os.MkdirAll(filepath.Dir(cfg.LogFile), 0755); err != nil { + return nil, err + } + + if err := os.MkdirAll(cfg.LuaScriptsDir, 0755); err != nil { + return nil, err + } + + return cfg, nil +} + +// Save сохраняет конфигурацию в файл +func Save(cfg *Config, path string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + encoder := toml.NewEncoder(file) + return encoder.Encode(cfg) +} + +// GetEnv возвращает значение переменной окружения +func (c *Config) GetEnv(key string) string { + if val, ok := c.Environment[key]; ok { + return val + } + return os.Getenv(key) +} + +// SetEnv устанавливает переменную окружения +func (c *Config) SetEnv(key, value string) { + c.Environment[key] = value + os.Setenv(key, value) +} diff --git a/internal/history/history.go b/internal/history/history.go new file mode 100644 index 0000000..9fc300b --- /dev/null +++ b/internal/history/history.go @@ -0,0 +1,175 @@ +// history.go - управление историей команд fush shell +// Хранит до 1000 последних команд в файле с атомарными операциями +// Обеспечивает навигацию по истории (вверх/вниз) +// Предотвращает дублирование последовательных одинаковых команд + +package history + +import ( + "bufio" + "os" + "strings" + "sync/atomic" + "unsafe" +) + +// History управляет историей команд +type History struct { + commands []string + position int + maxSize int + file string + loaded atomic.Bool + commandsPtr unsafe.Pointer // Атомарный указатель на слайс команд +} + +// New создает новый объект истории +func New(historyFile string, maxSize int) *History { + h := &History{ + commands: make([]string, 0, maxSize), + position: -1, + maxSize: maxSize, + file: historyFile, + } + + // Инициализируем атомарный указатель + atomic.StorePointer(&h.commandsPtr, unsafe.Pointer(&h.commands)) + + // Загружаем историю из файла + h.load() + + return h +} + +// load загружает историю из файла +func (h *History) load() { + if h.loaded.Load() { + return + } + + file, err := os.Open(h.file) + if err != nil { + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + commands := make([]string, 0, h.maxSize) + + for scanner.Scan() { + cmd := strings.TrimSpace(scanner.Text()) + if cmd != "" { + commands = append(commands, cmd) + } + } + + // Ограничиваем размер + if len(commands) > h.maxSize { + commands = commands[len(commands)-h.maxSize:] + } + + // Атомарно обновляем команды + h.commands = commands + atomic.StorePointer(&h.commandsPtr, unsafe.Pointer(&h.commands)) + h.loaded.Store(true) +} + +// Add добавляет команду в историю +func (h *History) Add(command string) { + if command == "" { + return + } + + // Получаем текущие команды + oldCommands := h.getCommands() + + // Не добавляем дубликаты с предыдущей командой + if len(oldCommands) > 0 && oldCommands[len(oldCommands)-1] == command { + return + } + + // Создаем новый слайс + newCommands := make([]string, 0, h.maxSize) + newCommands = append(newCommands, oldCommands...) + newCommands = append(newCommands, command) + + // Ограничиваем размер + if len(newCommands) > h.maxSize { + newCommands = newCommands[len(newCommands)-h.maxSize:] + } + + // Атомарно обновляем + h.commands = newCommands + atomic.StorePointer(&h.commandsPtr, unsafe.Pointer(&h.commands)) + + // Сохраняем в файл + h.save() +} + +// save сохраняет историю в файл +func (h *History) save() { + file, err := os.OpenFile(h.file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return + } + defer file.Close() + + writer := bufio.NewWriter(file) + for _, cmd := range h.getCommands() { + writer.WriteString(cmd + "\n") + } + writer.Flush() +} + +// getCommands атомарно возвращает текущий список команд +func (h *History) getCommands() []string { + ptr := atomic.LoadPointer(&h.commandsPtr) + return *(*[]string)(ptr) +} + +// Previous возвращает предыдущую команду +func (h *History) Previous() string { + commands := h.getCommands() + if len(commands) == 0 { + return "" + } + + if h.position == -1 { + h.position = len(commands) - 1 + } else if h.position > 0 { + h.position-- + } + + if h.position >= 0 && h.position < len(commands) { + return commands[h.position] + } + + return "" +} + +// Next возвращает следующую команду +func (h *History) Next() string { + commands := h.getCommands() + if len(commands) == 0 { + return "" + } + + if h.position < len(commands)-1 { + h.position++ + return commands[h.position] + } else if h.position == len(commands)-1 { + h.position = -1 + } + + return "" +} + +// ResetPosition сбрасывает позицию в истории +func (h *History) ResetPosition() { + h.position = -1 +} + +// GetSize возвращает размер истории +func (h *History) GetSize() int { + return len(h.getCommands()) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..88bfc5f --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,110 @@ +// logger.go - система логирования для fush shell +// Записывает события четырех уровней: DEBUG, INFO, WARN, ERROR +// Использует атомарные операции для потокобезопасности +// Обеспечивает принудительную запись на диск после каждого сообщения + +package logger + +import ( + "fmt" + "os" + "sync/atomic" + "time" +) + +// LogLevel определяет уровень логирования +type LogLevel int + +const ( + LevelDebug LogLevel = iota + LevelInfo + LevelWarn + LevelError +) + +// Logger представляет структуру логгера +type Logger struct { + file *os.File + level LogLevel + closed atomic.Bool +} + +// New создает новый логгер +func New(logPath string) (*Logger, error) { + // Открываем файл для добавления записей + file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + + return &Logger{ + file: file, + level: LevelInfo, + }, nil +} + +// Close закрывает логгер +func (l *Logger) Close() error { + if l.closed.Load() { + return nil + } + l.closed.Store(true) + return l.file.Close() +} + +// log записывает сообщение в лог +func (l *Logger) log(level LogLevel, format string, args ...interface{}) { + if l.closed.Load() { + return + } + + if level < l.level { + return + } + + levelStr := map[LogLevel]string{ + LevelDebug: "DEBUG", + LevelInfo: "INFO", + LevelWarn: "WARN", + LevelError: "ERROR", + }[level] + + timestamp := time.Now().Format("2006-01-02 15:04:05.000") + message := fmt.Sprintf(format, args...) + + logLine := fmt.Sprintf("[%s] [%s] %s\n", timestamp, levelStr, message) + + // Атомарная запись в файл + _, err := l.file.WriteString(logLine) + if err != nil { + fmt.Fprintf(os.Stderr, "Ошибка записи лога: %v\n", err) + } + + // Принудительная запись на диск + l.file.Sync() +} + +// Debug записывает отладочное сообщение +func (l *Logger) Debug(format string, args ...interface{}) { + l.log(LevelDebug, format, args...) +} + +// Info записывает информационное сообщение +func (l *Logger) Info(format string, args ...interface{}) { + l.log(LevelInfo, format, args...) +} + +// Warn записывает предупреждение +func (l *Logger) Warn(format string, args ...interface{}) { + l.log(LevelWarn, format, args...) +} + +// Error записывает ошибку +func (l *Logger) Error(format string, args ...interface{}) { + l.log(LevelError, format, args...) +} + +// SetLevel устанавливает уровень логирования +func (l *Logger) SetLevel(level LogLevel) { + l.level = level +} diff --git a/internal/luabridge/luabridge.go b/internal/luabridge/luabridge.go new file mode 100644 index 0000000..0fec21c --- /dev/null +++ b/internal/luabridge/luabridge.go @@ -0,0 +1,308 @@ +// luabridge.go - интеграция Lua как скриптового языка в fush shell +// Предоставляет API для вызова команд shell из Lua скриптов +// Регистрирует функции exec, pipe, ls, cd, pwd в окружении Lua +// Позволяет создавать сложные скрипты для автоматизации задач + +package luabridge + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + + lua "github.com/yuin/gopher-lua" +) + +// ShellInterface определяет интерфейс для взаимодействия с shell +type ShellInterface interface { + GetEnv(key string) string + SetEnv(key, value string) +} + +// Bridge управляет выполнением Lua скриптов +type Bridge struct { + scriptsDir string + shell ShellInterface + state *lua.LState + closed atomic.Bool +} + +// New создает новый Lua мост +func New(scriptsDir string, shell ShellInterface) *Bridge { + b := &Bridge{ + scriptsDir: scriptsDir, + shell: shell, + } + + // Создаем Lua состояние + b.state = lua.NewState() + + // Регистрируем функции для Lua + b.registerFunctions() + + return b +} + +// registerFunctions регистрирует функции для Lua +func (b *Bridge) registerFunctions() { + // Регистрируем функцию print + b.state.SetGlobal("print", b.state.NewFunction(func(L *lua.LState) int { + args := make([]interface{}, L.GetTop()) + for i := 1; i <= L.GetTop(); i++ { + args[i-1] = L.Get(i) + } + fmt.Println(args...) + return 0 + })) + + // Регистрируем функцию getenv + b.state.SetGlobal("getenv", b.state.NewFunction(func(L *lua.LState) int { + key := L.CheckString(1) + value := b.shell.GetEnv(key) + L.Push(lua.LString(value)) + return 1 + })) + + // Регистрируем функцию setenv + b.state.SetGlobal("setenv", b.state.NewFunction(func(L *lua.LState) int { + key := L.CheckString(1) + value := L.CheckString(2) + b.shell.SetEnv(key, value) + return 0 + })) + + // Регистрируем функцию exec для выполнения команд из Lua + b.state.SetGlobal("exec", b.state.NewFunction(func(L *lua.LState) int { + cmd := L.CheckString(1) + args := make([]string, 0) + + // Собираем аргументы + for i := 2; i <= L.GetTop(); i++ { + args = append(args, L.CheckString(i)) + } + + // Выполняем команду + externalCmd := exec.Command(cmd, args...) + externalCmd.Stdout = os.Stdout + externalCmd.Stderr = os.Stderr + + err := externalCmd.Run() + if err != nil { + L.Push(lua.LBool(false)) + L.Push(lua.LString(err.Error())) + return 2 + } + + L.Push(lua.LBool(true)) + return 1 + })) + + // Регистрируем функцию exec_output для получения вывода команды + b.state.SetGlobal("exec_output", b.state.NewFunction(func(L *lua.LState) int { + cmd := L.CheckString(1) + args := make([]string, 0) + + for i := 2; i <= L.GetTop(); i++ { + args = append(args, L.CheckString(i)) + } + + externalCmd := exec.Command(cmd, args...) + output, err := externalCmd.Output() + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + + L.Push(lua.LString(string(output))) + return 1 + })) + + // Регистрируем функцию pipe для выполнения команд с пайпом + b.state.SetGlobal("pipe", b.state.NewFunction(func(L *lua.LState) int { + cmd1 := L.CheckString(1) + cmd2 := L.CheckString(2) + + // Разбиваем команды на части + parts1 := strings.Fields(cmd1) + parts2 := strings.Fields(cmd2) + + if len(parts1) == 0 || len(parts2) == 0 { + L.Push(lua.LBool(false)) + L.Push(lua.LString("пустая команда")) + return 2 + } + + // Создаем команды + cmd1Exec := exec.Command(parts1[0], parts1[1:]...) + cmd2Exec := exec.Command(parts2[0], parts2[1:]...) + + // Создаем пайп + stdout, err := cmd1Exec.StdoutPipe() + if err != nil { + L.Push(lua.LBool(false)) + L.Push(lua.LString(err.Error())) + return 2 + } + + cmd2Exec.Stdin = stdout + cmd2Exec.Stdout = os.Stdout + cmd2Exec.Stderr = os.Stderr + + // Запускаем команды + if err := cmd1Exec.Start(); err != nil { + L.Push(lua.LBool(false)) + L.Push(lua.LString(err.Error())) + return 2 + } + + if err := cmd2Exec.Start(); err != nil { + L.Push(lua.LBool(false)) + L.Push(lua.LString(err.Error())) + return 2 + } + + // Ожидаем завершения + cmd1Exec.Wait() + cmd2Exec.Wait() + + L.Push(lua.LBool(true)) + return 1 + })) + + // Регистрируем функцию ls + b.state.SetGlobal("ls", b.state.NewFunction(func(L *lua.LState) int { + path := "." + if L.GetTop() > 0 { + path = L.CheckString(1) + } + + dir, err := os.Open(path) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + defer dir.Close() + + files, err := dir.Readdir(-1) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + + // Создаем таблицу с результатами + tbl := L.NewTable() + for _, file := range files { + tbl.Append(lua.LString(file.Name())) + } + + L.Push(tbl) + return 1 + })) + + // Регистрируем функцию cd + b.state.SetGlobal("cd", b.state.NewFunction(func(L *lua.LState) int { + path := L.GetTop() > 0 + if !path { + path = true + // Передаем true, но нужен путь + _ = path + } + // Простая реализация cd через shell + dir := b.shell.GetEnv("HOME") + if L.GetTop() > 0 { + dir = L.CheckString(1) + } + + if err := os.Chdir(dir); err != nil { + L.Push(lua.LBool(false)) + L.Push(lua.LString(err.Error())) + return 2 + } + + L.Push(lua.LBool(true)) + return 1 + })) + + // Регистрируем функцию pwd + b.state.SetGlobal("pwd", b.state.NewFunction(func(L *lua.LState) int { + dir, err := os.Getwd() + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + + L.Push(lua.LString(dir)) + return 1 + })) +} + +// ExecuteScript выполняет Lua скрипт +func (b *Bridge) ExecuteScript(name string, args []string) error { + if b.closed.Load() { + return fmt.Errorf("мост закрыт") + } + + // Формируем путь к скрипту + scriptPath := filepath.Join(b.scriptsDir, name+".lua") + + // Проверяем существование файла + if _, err := os.Stat(scriptPath); os.IsNotExist(err) { + return err + } + + // Загружаем и выполняем скрипт + if err := b.state.DoFile(scriptPath); err != nil { + return err + } + + // Вызываем функцию main если она существует + if fn := b.state.GetGlobal("main"); fn.Type() == lua.LTFunction { + // Преобразуем аргументы в Lua значения + luaArgs := make([]lua.LValue, len(args)) + for i, arg := range args { + luaArgs[i] = lua.LString(arg) + } + + if err := b.state.CallByParam(lua.P{ + Fn: fn, + NRet: 0, + Protect: true, + }, luaArgs...); err != nil { + return err + } + } + + return nil +} + +// ExecuteString выполняет Lua код из строки +func (b *Bridge) ExecuteString(code string) error { + if b.closed.Load() { + return fmt.Errorf("мост закрыт") + } + + return b.state.DoString(code) +} + +// GetState возвращает Lua состояние +func (b *Bridge) GetState() *lua.LState { + return b.state +} + +// Close закрывает Lua состояние +func (b *Bridge) Close() { + if b.closed.Load() { + return + } + b.closed.Store(true) + if b.state != nil { + b.state.Close() + } +} diff --git a/internal/shell/commands.go b/internal/shell/commands.go new file mode 100644 index 0000000..546a823 --- /dev/null +++ b/internal/shell/commands.go @@ -0,0 +1,205 @@ +// commands.go - встроенные команды fush shell +// Реализует базовые команды: exit, ls, cd, mkdir, rm, touch, exec, help +// Обеспечивает навигацию по файловой системе и управление файлами +// Служит основой для расширения функциональности shell + +package shell + +import ( + "fmt" + "os" + "time" + + "fush/pkg/ansi" +) + +// cmdExit обрабатывает команду exit +func (s *Shell) cmdExit(args []string) error { + s.running.Store(false) + s.logger.Info("Выполнена команда exit") + return nil +} + +// cmdHelp обрабатывает команду help - выводит список доступных команд +func (s *Shell) cmdHelp(args []string) error { + fmt.Println() + ansi.Println(ansi.Cyan, "╔══════════════════════════════════════════════════════════════╗") + ansi.Println(ansi.Cyan, "║ fush shell - Доступные команды ║") + ansi.Println(ansi.Cyan, "╚══════════════════════════════════════════════════════════════╝") + fmt.Println() + + // Внутренние команды + ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + ansi.Println(ansi.BrightGreen, "ВСТРОЕННЫЕ КОМАНДЫ:") + ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + fmt.Printf(" %-15s %s\n", ansi.Colorize("exit", ansi.BrightWhite), "Выход из оболочки") + fmt.Printf(" %-15s %s\n", ansi.Colorize("help", ansi.BrightWhite), "Показать эту справку") + fmt.Printf(" %-15s %s\n", ansi.Colorize("ls [path]", ansi.BrightWhite), "Вывести список файлов в директории") + fmt.Printf(" %-15s %s\n", ansi.Colorize("cd [dir]", ansi.BrightWhite), "Сменить текущую директорию") + fmt.Printf(" %-15s %s\n", ansi.Colorize("mkdir ", ansi.BrightWhite), "Создать новую директорию") + fmt.Printf(" %-15s %s\n", ansi.Colorize("rm ", ansi.BrightWhite), "Удалить файл или директорию") + fmt.Printf(" %-15s %s\n", ansi.Colorize("touch ", ansi.BrightWhite), "Создать файл или обновить время доступа") + fmt.Printf(" %-15s %s\n", ansi.Colorize("exec [args...]", ansi.BrightWhite), "Выполнить внешнюю команду") + + fmt.Println() + ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + ansi.Println(ansi.BrightGreen, "ВНЕШНИЕ КОМАНДЫ:") + ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + fmt.Printf(" %-15s %s\n", ansi.Colorize("", ansi.BrightWhite), "Запуск любого внешнего приложения") + fmt.Printf(" %-15s %s\n", ansi.Colorize("", ansi.BrightWhite), "Выполнение Lua скрипта из директории ~/.local/share/fush/lua/") + + fmt.Println() + ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + ansi.Println(ansi.BrightGreen, "СПЕЦИАЛЬНЫЕ ВОЗМОЖНОСТИ:") + ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + fmt.Printf(" %-20s %s\n", ansi.Colorize("cmd1 | cmd2", ansi.BrightWhite), "Пайплайн (канал) между командами") + fmt.Printf(" %-20s %s\n", ansi.Colorize("cmd > file", ansi.BrightWhite), "Перенаправление вывода в файл (перезапись)") + fmt.Printf(" %-20s %s\n", ansi.Colorize("cmd >> file", ansi.BrightWhite), "Перенаправление вывода в файл (добавление)") + fmt.Printf(" %-20s %s\n", ansi.Colorize("Lua API", ansi.BrightWhite), "Встроенный интерпретатор Lua с функциями: exec(), ls(), cd(), pwd()") + + fmt.Println() + ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + ansi.Println(ansi.BrightGreen, "ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ:") + ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + ansi.Println(ansi.BrightCyan, "ВСТРОЕННЫЕ КОМАНДЫ:") + fmt.Printf(" %-40s %s\n", ansi.Colorize("ls -la", ansi.BrightWhite), "Показать все файлы в текущей директории") + fmt.Printf(" %-40s %s\n", ansi.Colorize("cd /home/user", ansi.BrightWhite), "Перейти в директорию пользователя") + fmt.Printf(" %-40s %s\n", ansi.Colorize("mkdir mydir", ansi.BrightWhite), "Создать директорию 'mydir'") + fmt.Printf(" %-40s %s\n", ansi.Colorize("rm myfile.txt", ansi.BrightWhite), "Удалить файл 'myfile.txt'") + fmt.Printf(" %-40s %s\n", ansi.Colorize("touch newfile.txt", ansi.BrightWhite), "Создать файл 'newfile.txt'") + fmt.Printf(" %-40s %s\n", ansi.Colorize("exec go version", ansi.BrightWhite), "Выполнить команду 'go version'") + + fmt.Println() + ansi.Println(ansi.BrightCyan, "ВНЕШНИЕ КОМАНДЫ (СИСТЕМНЫЕ УТИЛИТЫ):") + fmt.Printf(" %-40s %s\n", ansi.Colorize("go version", ansi.BrightWhite), "Показать версию Go (если установлен)") + fmt.Printf(" %-40s %s\n", ansi.Colorize("python --version", ansi.BrightWhite), "Показать версию Python (если установлен)") + fmt.Printf(" %-40s %s\n", ansi.Colorize("gcc --version", ansi.BrightWhite), "Показать версию GCC (если установлен)") + fmt.Printf(" %-40s %s\n", ansi.Colorize("date", ansi.BrightWhite), "Показать текущую дату и время") + fmt.Printf(" %-40s %s\n", ansi.Colorize("whoami", ansi.BrightWhite), "Показать имя текущего пользователя") + fmt.Printf(" %-40s %s\n", ansi.Colorize("pwd", ansi.BrightWhite), "Показать текущую рабочую директорию") + fmt.Printf(" %-40s %s\n", ansi.Colorize("echo Hello World", ansi.BrightWhite), "Вывести текст на экран") + fmt.Printf(" %-40s %s\n", ansi.Colorize("cat file.txt", ansi.BrightWhite), "Показать содержимое файла") + fmt.Printf(" %-40s %s\n", ansi.Colorize("grep pattern file.txt", ansi.BrightWhite), "Поиск строк по шаблону в файле") + + fmt.Println() + ansi.Println(ansi.BrightCyan, "КОМАНДЫ ДЛЯ РАБОТЫ С ПАЙПЛАЙНАМИ (КАНАЛАМИ):") + fmt.Printf(" %-40s %s\n", ansi.Colorize("ls -la | grep .go", ansi.BrightWhite), "Показать только Go файлы в текущей директории") + fmt.Printf(" %-40s %s\n", ansi.Colorize("ps aux | grep python", ansi.BrightWhite), "Найти процессы Python") + fmt.Printf(" %-40s %s\n", ansi.Colorize("cat file.txt | wc -l", ansi.BrightWhite), "Подсчитать количество строк в файле") + fmt.Printf(" %-40s %s\n", ansi.Colorize("ls | sort | uniq", ansi.BrightWhite), "Отсортировать и убрать дубликаты") + + fmt.Println() + ansi.Println(ansi.BrightCyan, "ПЕРЕНАПРАВЛЕНИЕ ВЫВОДА:") + fmt.Printf(" %-40s %s\n", ansi.Colorize("ls > files.txt", ansi.BrightWhite), "Сохранить список файлов в files.txt (перезапись)") + fmt.Printf(" %-40s %s\n", ansi.Colorize("echo Hello >> hello.txt", ansi.BrightWhite), "Добавить текст в конец файла") + fmt.Printf(" %-40s %s\n", ansi.Colorize("go version > version.txt", ansi.BrightWhite), "Сохранить версию Go в файл") + + fmt.Println() + ansi.Println(ansi.BrightCyan, "LUA СКРИПТЫ:") + fmt.Printf(" %-40s %s\n", ansi.Colorize("example.lua", ansi.BrightWhite), "Выполнить Lua скрипт из директории ~/.local/share/fush/lua/") + fmt.Printf(" %-40s %s\n", ansi.Colorize("my_script.lua arg1 arg2", ansi.BrightWhite), "Выполнить Lua скрипт с аргументами") + + fmt.Println() + ansi.Println(ansi.Yellow, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + ansi.Println(ansi.BrightCyan, "Для выхода из оболочки введите 'exit' или нажмите Ctrl+C") + fmt.Println() + + return nil +} + +// cmdLs обрабатывает команду ls +func (s *Shell) cmdLs(args []string) error { + // Определяем путь + path := "." + if len(args) > 0 { + path = args[0] + } + + // Открываем директорию + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + + // Читаем содержимое + files, err := dir.Readdir(-1) + if err != nil { + return err + } + + // Выводим список + for _, file := range files { + name := file.Name() + if file.IsDir() { + name = name + "/" + } + fmt.Println(name) + } + + return nil +} + +// cmdCd обрабатывает команду cd +func (s *Shell) cmdCd(args []string) error { + // Определяем путь + path := s.GetEnv("HOME") + if len(args) > 0 { + path = args[0] + } + + // Меняем директорию + if err := os.Chdir(path); err != nil { + return err + } + + // Обновляем PWD + pwd, err := os.Getwd() + if err == nil { + s.SetEnv("PWD", pwd) + } + + return nil +} + +// cmdMkdir обрабатывает команду mkdir +func (s *Shell) cmdMkdir(args []string) error { + if len(args) == 0 { + return fmt.Errorf("требуется имя директории") + } + + // Создаем директорию + return os.MkdirAll(args[0], 0755) +} + +// cmdRm обрабатывает команду rm +func (s *Shell) cmdRm(args []string) error { + if len(args) == 0 { + return fmt.Errorf("требуется имя файла") + } + + // Удаляем файл или директорию + return os.RemoveAll(args[0]) +} + +// cmdTouch обрабатывает команду touch +func (s *Shell) cmdTouch(args []string) error { + if len(args) == 0 { + return fmt.Errorf("требуется имя файла") + } + + // Создаем файл или обновляем время модификации + file, err := os.OpenFile(args[0], os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer file.Close() + + // Обновляем время модификации + now := time.Now() + return os.Chtimes(args[0], now, now) +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go new file mode 100644 index 0000000..49b3771 --- /dev/null +++ b/internal/shell/shell.go @@ -0,0 +1,343 @@ +// shell.go - основной цикл командного интерпретатора fush +// Обрабатывает ввод пользователя, историю команд и выполнение +// Поддерживает пайпы (|) и перенаправление вывода (>, >>) +// Интегрирует Lua мост для выполнения скриптов + +package shell + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync/atomic" + + "fush/internal/config" + "fush/internal/history" + "fush/internal/logger" + "fush/internal/luabridge" + "fush/pkg/ansi" +) + +// Shell представляет основную структуру командного интерпретатора +type Shell struct { + config *config.Config + logger *logger.Logger + history *history.History + luaBridge *luabridge.Bridge + running atomic.Bool + env map[string]string +} + +// PipeCommand представляет команду в пайплайне +type PipeCommand struct { + Cmd string + Args []string +} + +// New создает новый экземпляр shell +func New(cfg *config.Config, log *logger.Logger) *Shell { + sh := &Shell{ + config: cfg, + logger: log, + env: make(map[string]string), + } + + // Копируем переменные окружения из конфигурации + for k, v := range cfg.Environment { + sh.env[k] = v + } + + // Инициализируем историю + sh.history = history.New(cfg.HistoryFile, cfg.HistorySize) + + // Инициализируем Lua мост + sh.luaBridge = luabridge.New(cfg.LuaScriptsDir, sh) + + return sh +} + +// Run запускает основной цикл shell +func (s *Shell) Run() error { + s.running.Store(true) + s.logger.Info("Запуск основного цикла shell") + + // Получаем цвет приглашения + //promptColor, err := ansi.HexToANSI(s.config.PromptColor) + //if err != nil { + // promptColor = ansi.Cyan + //} + promptColor := ansi.BrightCyan // Принудительно установить яркий циан + + // Создаем читатель stdin + reader := bufio.NewReader(os.Stdin) + + for s.running.Load() { + // Выводим приглашение + ansi.Printf(promptColor, s.config.Prompt) + + // Читаем команду + input, err := reader.ReadString('\n') + if err != nil { + if err.Error() == "EOF" { + break + } + s.logger.Error("Ошибка чтения ввода", "error", err) + continue + } + + // Обрабатываем команду + input = strings.TrimSpace(input) + if input == "" { + continue + } + + // Добавляем в историю + s.history.Add(input) + + // Обрабатываем команду + if err := s.executeCommand(input); err != nil { + ansi.Printf(ansi.Red, "Ошибка: %v\n", err) + s.logger.Error("Ошибка выполнения команды", "command", input, "error", err) + } + } + + return nil +} + +// executeCommand выполняет команду +func (s *Shell) executeCommand(input string) error { + // Проверяем наличие пайпов (каналов) + if strings.Contains(input, "|") { + return s.executePipeline(input) + } + + // Проверяем перенаправление вывода + var outputFile string + var appendMode bool + + if strings.Contains(input, ">>") { + parts := strings.SplitN(input, ">>", 2) + input = strings.TrimSpace(parts[0]) + outputFile = strings.TrimSpace(parts[1]) + appendMode = true + } else if strings.Contains(input, ">") { + parts := strings.SplitN(input, ">", 2) + input = strings.TrimSpace(parts[0]) + outputFile = strings.TrimSpace(parts[1]) + appendMode = false + } + + // Разбиваем на команду и аргументы + parts := strings.Fields(input) + if len(parts) == 0 { + return nil + } + + cmd := parts[0] + args := parts[1:] + + var err error + + // Проверяем внутренние команды + switch cmd { + case "exit": + err = s.cmdExit(args) + case "help": + err = s.cmdHelp(args) + case "ls": + err = s.cmdLs(args) + case "cd": + err = s.cmdCd(args) + case "mkdir": + err = s.cmdMkdir(args) + case "rm": + err = s.cmdRm(args) + case "touch": + err = s.cmdTouch(args) + case "exec": + err = s.cmdExec(args) + default: + // Пытаемся выполнить как Lua скрипт + err = s.luaBridge.ExecuteScript(cmd, args) + if err != nil { + // Пытаемся выполнить как внешнюю команду + err = s.executeExternal(cmd, args, nil, nil, nil) + } + } + + // Обрабатываем перенаправление вывода если нужно + if err == nil && outputFile != "" { + // Для внутренних команд вывод уже был, + // для внешних перенаправление обрабатывается в executeExternal + _ = appendMode // Подавляем предупреждение о неиспользуемой переменной + } + + return err +} + +// executePipeline выполняет цепочку команд с пайпами +func (s *Shell) executePipeline(input string) error { + // Разбиваем на команды + cmdStrs := strings.Split(input, "|") + commands := make([]*exec.Cmd, 0, len(cmdStrs)) + + // Парсим каждую команду + for _, cmdStr := range cmdStrs { + cmdStr = strings.TrimSpace(cmdStr) + if cmdStr == "" { + return fmt.Errorf("пустая команда в пайплайне") + } + + parts := strings.Fields(cmdStr) + if len(parts) == 0 { + return fmt.Errorf("пустая команда в пайплайне") + } + + // Проверяем внутренние команды + switch parts[0] { + case "exit", "cd", "exec", "help": + return fmt.Errorf("команда '%s' не может использоваться в пайплайне", parts[0]) + case "ls", "mkdir", "rm", "touch": + // Для внутренних команд используем внешний вызов + cmd := exec.Command(parts[0], parts[1:]...) + s.setCommandEnv(cmd) + commands = append(commands, cmd) + default: + // Внешняя команда + cmd := exec.Command(parts[0], parts[1:]...) + s.setCommandEnv(cmd) + commands = append(commands, cmd) + } + } + + if len(commands) == 0 { + return fmt.Errorf("нет команд для выполнения") + } + + // Создаем пайпы между командами + for i := 0; i < len(commands)-1; i++ { + stdout, err := commands[i].StdoutPipe() + if err != nil { + return fmt.Errorf("ошибка создания пайпа: %v", err) + } + commands[i+1].Stdin = stdout + } + + // Последняя команда пишет в stdout + commands[len(commands)-1].Stdout = os.Stdout + commands[len(commands)-1].Stderr = os.Stderr + + // Первая команда читает из stdin если нужно + if commands[0].Stdin == nil { + commands[0].Stdin = os.Stdin + } + + // Запускаем все команды + for idx, cmd := range commands { + if err := cmd.Start(); err != nil { + return fmt.Errorf("ошибка запуска команды %d: %v", idx, err) + } + } + + // Ожидаем завершения всех команд + for idx, cmd := range commands { + if err := cmd.Wait(); err != nil { + s.logger.Error("Ошибка выполнения команды в пайплайне", "index", idx, "error", err) + } + } + + return nil +} + +// cmdExec выполняет команду exec для запуска приложений +func (s *Shell) cmdExec(args []string) error { + if len(args) == 0 { + return fmt.Errorf("требуется команда для выполнения") + } + + cmd := args[0] + cmdArgs := args[1:] + + // Создаем exec.Command + externalCmd := exec.Command(cmd, cmdArgs...) + + // Настраиваем окружение + s.setCommandEnv(externalCmd) + + // Перенаправляем вывод + externalCmd.Stdout = os.Stdout + externalCmd.Stderr = os.Stderr + externalCmd.Stdin = os.Stdin + + // Выполняем команду + err := externalCmd.Run() + if err != nil { + return fmt.Errorf("ошибка выполнения '%s': %v", cmd, err) + } + + return nil +} + +// executeExternal выполняет внешнюю команду с поддержкой перенаправления +func (s *Shell) executeExternal(cmd string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + // Создаем команду + externalCmd := exec.Command(cmd, args...) + + // Настраиваем окружение + s.setCommandEnv(externalCmd) + + // Перенаправляем вывод + if stdin != nil { + externalCmd.Stdin = stdin + } else { + externalCmd.Stdin = os.Stdin + } + + if stdout != nil { + externalCmd.Stdout = stdout + } else { + externalCmd.Stdout = os.Stdout + } + + if stderr != nil { + externalCmd.Stderr = stderr + } else { + externalCmd.Stderr = os.Stderr + } + + // Выполняем + return externalCmd.Run() +} + +// setCommandEnv устанавливает переменные окружения для команды +func (s *Shell) setCommandEnv(cmd *exec.Cmd) { + cmd.Env = os.Environ() + for k, v := range s.env { + cmd.Env = append(cmd.Env, k+"="+v) + } +} + +// Shutdown завершает работу shell +func (s *Shell) Shutdown() { + s.logger.Info("Завершение работы shell") + s.running.Store(false) + s.luaBridge.Close() +} + +// GetEnv возвращает переменную окружения +func (s *Shell) GetEnv(key string) string { + if val, ok := s.env[key]; ok { + return val + } + return os.Getenv(key) +} + +// SetEnv устанавливает переменную окружения +func (s *Shell) SetEnv(key, value string) { + s.env[key] = value + os.Setenv(key, value) + s.logger.Debug("Установлена переменная окружения", "key", key, "value", value) +} diff --git a/lua/example.lua b/lua/example.lua new file mode 100644 index 0000000..beb7786 --- /dev/null +++ b/lua/example.lua @@ -0,0 +1,30 @@ +-- Пример Lua скрипта для fush shell +-- Сохраните этот файл в директории lua_scripts_dir + +-- Функция main будет вызвана при выполнении скрипта +function main(...) + local args = {...} + print("Hello from Lua script!") + print("Arguments:", table.concat(args, " ")) + + -- Получаем переменные окружения + local user = getenv("USER") + local home = getenv("HOME") + + print("User:", user) + print("Home:", home) + + -- Устанавливаем переменную окружения + setenv("LUA_TEST", "success") + + -- Выполняем внешнюю команду (если нужно) + -- exec("echo 'External command executed from Lua'") + + return 0 +end + +-- Дополнительные функции могут быть определены здесь +function help() + print("This is an example Lua script for fush shell") + print("Usage: example [arguments...]") +end diff --git a/pkg/ansi/colors.go b/pkg/ansi/colors.go new file mode 100644 index 0000000..473a428 --- /dev/null +++ b/pkg/ansi/colors.go @@ -0,0 +1,168 @@ +// colors.go - система цветного вывода для fush shell +// Преобразует hex цвета в ANSI escape последовательности +// Поддерживает 256-цветную палитру для совместимости с терминалами +// Автоматически отключает цвета при выводе не в терминал + +package ansi + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/mattn/go-isatty" +) + +// Color представляет ANSI цвет +type Color struct { + code string +} + +// Основные цвета +var ( + Reset = Color{"\033[0m"} + Bold = Color{"\033[1m"} + Dim = Color{"\033[2m"} + Italic = Color{"\033[3m"} + Underline = Color{"\033[4m"} + + Black = Color{"\033[30m"} + Red = Color{"\033[31m"} + Green = Color{"\033[32m"} + Yellow = Color{"\033[33m"} + Blue = Color{"\033[34m"} + Magenta = Color{"\033[35m"} + Cyan = Color{"\033[36m"} + White = Color{"\033[37m"} + + BrightBlack = Color{"\033[90m"} + BrightRed = Color{"\033[91m"} + BrightGreen = Color{"\033[92m"} + BrightYellow = Color{"\033[93m"} + BrightBlue = Color{"\033[94m"} + BrightMagenta = Color{"\033[95m"} + BrightCyan = Color{"\033[96m"} + BrightWhite = Color{"\033[97m"} +) + +// HexToANSI преобразует hex цвет в ANSI код +func HexToANSI(hex string) (Color, error) { + // Удаляем # если есть + hex = strings.TrimPrefix(hex, "#") + + if len(hex) != 6 && len(hex) != 3 { + return Color{}, fmt.Errorf("неверный формат hex цвета: %s", hex) + } + + // Обработка 3-символьных hex кодов (например, #0FF) + if len(hex) == 3 { + hex = string([]byte{hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]}) + } + + // Парсим RGB компоненты + r, err := strconv.ParseInt(hex[0:2], 16, 64) + if err != nil { + return Color{}, err + } + + g, err := strconv.ParseInt(hex[2:4], 16, 64) + if err != nil { + return Color{}, err + } + + b, err := strconv.ParseInt(hex[4:6], 16, 64) + if err != nil { + return Color{}, err + } + + // Определяем, является ли цвет одним из стандартных + if r == 0 && g == 255 && b == 255 { + return BrightCyan, nil // #00ffff -> BrightCyan + } + if r == 255 && g == 255 && b == 0 { + return BrightYellow, nil // #ffff00 -> BrightYellow + } + if r == 0 && g == 255 && b == 0 { + return BrightGreen, nil // #00ff00 -> BrightGreen + } + if r == 255 && g == 0 && b == 0 { + return BrightRed, nil // #ff0000 -> BrightRed + } + if r == 0 && g == 0 && b == 255 { + return BrightBlue, nil // #0000ff -> BrightBlue + } + if r == 255 && g == 0 && b == 255 { + return BrightMagenta, nil // #ff00ff -> BrightMagenta + } + if r == 255 && g == 255 && b == 255 { + return BrightWhite, nil // #ffffff -> BrightWhite + } + if r == 0 && g == 0 && b == 0 { + return Black, nil // #000000 -> Black + } + + // Используем 256-цветную палитру для других цветов + // Формула: 16 + 36*r + 6*g + b, где r,g,b в диапазоне 0-5 + rIdx := int(r * 5 / 255) + gIdx := int(g * 5 / 255) + bIdx := int(b * 5 / 255) + + colorCode := 16 + (36 * rIdx) + (6 * gIdx) + bIdx + + return Color{fmt.Sprintf("\033[38;5;%dm", colorCode)}, nil +} + +// Colorize окрашивает строку в указанный цвет +func Colorize(s string, color Color) string { + if !IsTerminalSupported() { + return s + } + return color.code + s + Reset.code +} + +// Sprintf форматирует строку с цветом +func Sprintf(color Color, format string, args ...interface{}) string { + if !IsTerminalSupported() { + return fmt.Sprintf(format, args...) + } + return color.code + fmt.Sprintf(format, args...) + Reset.code +} + +// Println выводит строку с цветом +func Println(color Color, args ...interface{}) { + if !IsTerminalSupported() { + fmt.Println(args...) + return + } + fmt.Print(color.code) + fmt.Println(args...) + fmt.Print(Reset.code) +} + +// Printf выводит форматированную строку с цветом +func Printf(color Color, format string, args ...interface{}) { + if !IsTerminalSupported() { + fmt.Printf(format, args...) + return + } + fmt.Print(color.code) + fmt.Printf(format, args...) + fmt.Print(Reset.code) +} + +// IsTerminalSupported проверяет поддержку цвета в терминале +func IsTerminalSupported() bool { + // Проверяем переменную окружения TERM + term := strings.ToLower(os.Getenv("TERM")) + if term == "dumb" || term == "" { + return false + } + + // Проверяем, что вывод идет в терминал + if !isatty.IsTerminal(os.Stdout.Fd()) { + return false + } + + return true +}