diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test.c | 540 | ||||
| -rw-r--r-- | tests/test.go | 4513 | ||||
| -rw-r--r-- | tests/test.php | 406 | ||||
| -rw-r--r-- | tests/test.py | 32 | ||||
| -rw-r--r-- | tests/test.rs | 31 |
5 files changed, 5522 insertions, 0 deletions
diff --git a/tests/test.c b/tests/test.c new file mode 100644 index 0000000..8e57604 --- /dev/null +++ b/tests/test.c @@ -0,0 +1,540 @@ +#include <stdlib.h> +#include <string.h> +#include "cmdline.h" +#include "command/args.h" +#include "command/macro.h" +#include "commands.h" +#include "completion.h" +#include "copy.h" +#include "editor.h" +#include "history.h" +#include "options.h" +#include "search.h" +#include "terminal/osc52.h" +#include "util/ascii.h" +#include "util/bsearch.h" +#include "util/debug.h" +#include "util/log.h" +#include "util/utf8.h" + +static void cmdline_delete(CommandLine *c) +{ + size_t pos = c->pos; + size_t len = 1; + + if (pos == c->buf.len) { + return; + } + + u_get_char(c->buf.buffer, c->buf.len, &pos); + len = pos - c->pos; + string_remove(&c->buf, c->pos, len); +} + +void cmdline_clear(CommandLine *c) +{ + string_clear(&c->buf); + c->pos = 0; + c->search_pos = NULL; +} + +void cmdline_free(CommandLine *c) +{ + cmdline_clear(c); + string_free(&c->buf); + free(c->search_text); + reset_completion(c); +} + +static void set_text(CommandLine *c, const char *text) +{ + string_clear(&c->buf); + const size_t text_len = strlen(text); + c->pos = text_len; + string_append_buf(&c->buf, text, text_len); +} + +void cmdline_set_text(CommandLine *c, const char *text) +{ + c->search_pos = NULL; + set_text(c, text); +} + +static bool cmd_bol(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + e->cmdline.pos = 0; + reset_completion(&e->cmdline); + return true; +} + +static bool cmd_cancel(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + cmdline_clear(c); + set_input_mode(e, INPUT_NORMAL); + reset_completion(c); + return true; +} + +static bool cmd_clear(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + cmdline_clear(&e->cmdline); + return true; +} + +static bool cmd_copy(EditorState *e, const CommandArgs *a) +{ + bool internal = cmdargs_has_flag(a, 'i') || a->flag_set == 0; + bool clipboard = cmdargs_has_flag(a, 'b'); + bool primary = cmdargs_has_flag(a, 'p'); + + String *buf = &e->cmdline.buf; + size_t len = buf->len; + if (internal) { + char *str = string_clone_cstring(buf); + record_copy(&e->clipboard, str, len, false); + } + + Terminal *term = &e->terminal; + if ((clipboard || primary) && term->features & TFLAG_OSC52_COPY) { + const char *str = string_borrow_cstring(buf); + if (!term_osc52_copy(&term->obuf, str, len, clipboard, primary)) { + LOG_ERRNO("term_osc52_copy"); + // TODO: return false ? + } + } + + return true; +} + +static bool cmd_delete(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + cmdline_delete(c); + c->search_pos = NULL; + reset_completion(c); + return true; +} + +static bool cmd_delete_eol(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + c->buf.len = c->pos; + c->search_pos = NULL; + reset_completion(c); + return true; +} + +static bool cmd_delete_word(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + const unsigned char *buf = c->buf.buffer; + const size_t len = c->buf.len; + size_t i = c->pos; + + if (i == len) { + return true; + } + + while (i < len && is_word_byte(buf[i])) { + i++; + } + + while (i < len && !is_word_byte(buf[i])) { + i++; + } + + string_remove(&c->buf, c->pos, i - c->pos); + + c->search_pos = NULL; + reset_completion(c); + return true; +} + +static bool cmd_eol(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + c->pos = c->buf.len; + reset_completion(c); + return true; +} + +static bool cmd_erase(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + if (c->pos > 0) { + u_prev_char(c->buf.buffer, &c->pos); + cmdline_delete(c); + } + c->search_pos = NULL; + reset_completion(c); + return true; +} + +static bool cmd_erase_bol(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + string_remove(&c->buf, 0, c->pos); + c->pos = 0; + c->search_pos = NULL; + reset_completion(c); + return true; +} + +static bool cmd_erase_word(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + size_t i = c->pos; + if (i == 0) { + return true; + } + + // open /path/to/file^W => open /path/to/ + + // erase whitespace + while (i && ascii_isspace(c->buf.buffer[i - 1])) { + i--; + } + + // erase non-word bytes + while (i && !is_word_byte(c->buf.buffer[i - 1])) { + i--; + } + + // erase word bytes + while (i && is_word_byte(c->buf.buffer[i - 1])) { + i--; + } + + string_remove(&c->buf, i, c->pos - i); + c->pos = i; + c->search_pos = NULL; + reset_completion(c); + return true; +} + +static bool do_history_prev(const History *hist, CommandLine *c) +{ + if (!c->search_pos) { + free(c->search_text); + c->search_text = string_clone_cstring(&c->buf); + } + + if (history_search_forward(hist, &c->search_pos, c->search_text)) { + BUG_ON(!c->search_pos); + set_text(c, c->search_pos->text); + } + + reset_completion(c); + return true; +} + +static bool do_history_next(const History *hist, CommandLine *c) +{ + if (!c->search_pos) { + goto out; + } + + if (history_search_backward(hist, &c->search_pos, c->search_text)) { + BUG_ON(!c->search_pos); + set_text(c, c->search_pos->text); + } else { + set_text(c, c->search_text); + c->search_pos = NULL; + } + +out: + reset_completion(c); + return true; +} + +static bool cmd_search_history_next(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + return do_history_next(&e->search_history, &e->cmdline); +} + +static bool cmd_search_history_prev(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + return do_history_prev(&e->search_history, &e->cmdline); +} + +static bool cmd_command_history_next(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + return do_history_next(&e->command_history, &e->cmdline); +} + +static bool cmd_command_history_prev(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + return do_history_prev(&e->command_history, &e->cmdline); +} + +static bool cmd_left(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + if (c->pos) { + u_prev_char(c->buf.buffer, &c->pos); + } + reset_completion(c); + return true; +} + +static bool cmd_paste(EditorState *e, const CommandArgs *a) +{ + CommandLine *c = &e->cmdline; + const Clipboard *clip = &e->clipboard; + string_insert_buf(&c->buf, c->pos, clip->buf, clip->len); + if (cmdargs_has_flag(a, 'm')) { + c->pos += clip->len; + } + c->search_pos = NULL; + reset_completion(c); + return true; +} + +static bool cmd_right(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + if (c->pos < c->buf.len) { + u_get_char(c->buf.buffer, c->buf.len, &c->pos); + } + reset_completion(c); + return true; +} + +static bool cmd_toggle(EditorState *e, const CommandArgs *a) +{ + const char *option_name = a->args[0]; + bool global = cmdargs_has_flag(a, 'g'); + size_t nr_values = a->nr_args - 1; + if (nr_values == 0) { + return toggle_option(e, option_name, global, false); + } + + char **values = a->args + 1; + return toggle_option_values(e, option_name, global, false, values, nr_values); +} + +static bool cmd_word_bwd(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + if (c->pos <= 1) { + c->pos = 0; + return true; + } + + const unsigned char *const buf = c->buf.buffer; + size_t i = c->pos - 1; + + while (i > 0 && !is_word_byte(buf[i])) { + i--; + } + + while (i > 0 && is_word_byte(buf[i])) { + i--; + } + + if (i > 0) { + i++; + } + + c->pos = i; + reset_completion(c); + return true; +} + +static bool cmd_word_fwd(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + const unsigned char *buf = c->buf.buffer; + const size_t len = c->buf.len; + size_t i = c->pos; + + while (i < len && is_word_byte(buf[i])) { + i++; + } + + while (i < len && !is_word_byte(buf[i])) { + i++; + } + + c->pos = i; + reset_completion(c); + return true; +} + +static bool cmd_complete_next(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + complete_command_next(e); + return true; +} + +static bool cmd_complete_prev(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + complete_command_prev(e); + return true; +} + +static bool cmd_direction(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + toggle_search_direction(&e->search); + return true; +} + +static bool cmd_command_mode_accept(EditorState *e, const CommandArgs *a) +{ + BUG_ON(a->nr_args); + CommandLine *c = &e->cmdline; + reset_completion(c); + set_input_mode(e, INPUT_NORMAL); + + const char *str = string_borrow_cstring(&c->buf); + cmdline_clear(c); + if (!cmdargs_has_flag(a, 'H') && str[0] != ' ') { + // This is done before handle_command() because "command [text]" + // can modify the contents of the command-line + history_add(&e->command_history, str); + } + + current_command = NULL; + return handle_normal_command(e, str, true); +} + +static bool cmd_search_mode_accept(EditorState *e, const CommandArgs *a) +{ + CommandLine *c = &e->cmdline; + if (cmdargs_has_flag(a, 'e')) { + if (c->buf.len == 0) { + return true; + } + // Escape the regex; to match as plain text + char *original = string_clone_cstring(&c->buf); + size_t len = c->buf.len; + string_clear(&c->buf); + for (size_t i = 0; i < len; i++) { + char ch = original[i]; + if (is_regex_special_char(ch)) { + string_append_byte(&c->buf, '\\'); + } + string_append_byte(&c->buf, ch); + } + free(original); + } + + const char *str = NULL; + bool add_to_history = !cmdargs_has_flag(a, 'H'); + if (c->buf.len > 0) { + str = string_borrow_cstring(&c->buf); + BUG_ON(!str); + search_set_regexp(&e->search, str); + if (add_to_history) { + history_add(&e->search_history, str); + } + } + + if (e->macro.recording) { + const char *args[5]; + size_t i = 0; + if (str) { + if (e->search.reverse) { + args[i++] = "-r"; + } + if (!add_to_history) { + args[i++] = "-H"; + } + if (unlikely(str[0] == '-')) { + args[i++] = "--"; + } + args[i++] = str; + } else { + args[i++] = e->search.reverse ? "-p" : "-n"; + } + args[i] = NULL; + macro_command_hook(&e->macro, "search", (char**)args); + } + + current_command = NULL; + bool found = search_next(e->view, &e->search, e->options.case_sensitive_search); + cmdline_clear(c); + set_input_mode(e, INPUT_NORMAL); + return found; +} + +IGNORE_WARNING("-Wincompatible-pointer-types") + +static const Command common_cmds[] = { + {"bol", "", false, 0, 0, cmd_bol}, + {"cancel", "", false, 0, 0, cmd_cancel}, + {"clear", "", false, 0, 0, cmd_clear}, + {"copy", "bip", false, 0, 0, cmd_copy}, + {"delete", "", false, 0, 0, cmd_delete}, + {"delete-eol", "", false, 0, 0, cmd_delete_eol}, + {"delete-word", "", false, 0, 0, cmd_delete_word}, + {"eol", "", false, 0, 0, cmd_eol}, + {"erase", "", false, 0, 0, cmd_erase}, + {"erase-bol", "", false, 0, 0, cmd_erase_bol}, + {"erase-word", "", false, 0, 0, cmd_erase_word}, + {"left", "", false, 0, 0, cmd_left}, + {"paste", "m", false, 0, 0, cmd_paste}, + {"right", "", false, 0, 0, cmd_right}, + {"toggle", "g", false, 1, -1, cmd_toggle}, + {"word-bwd", "", false, 0, 0, cmd_word_bwd}, + {"word-fwd", "", false, 0, 0, cmd_word_fwd}, +}; + +static const Command search_cmds[] = { + {"accept", "eH", false, 0, 0, cmd_search_mode_accept}, + {"direction", "", false, 0, 0, cmd_direction}, + {"history-next", "", false, 0, 0, cmd_search_history_next}, + {"history-prev", "", false, 0, 0, cmd_search_history_prev}, +}; + +static const Command command_cmds[] = { + {"accept", "H", false, 0, 0, cmd_command_mode_accept}, + {"complete-next", "", false, 0, 0, cmd_complete_next}, + {"complete-prev", "", false, 0, 0, cmd_complete_prev}, + {"history-next", "", false, 0, 0, cmd_command_history_next}, + {"history-prev", "", false, 0, 0, cmd_command_history_prev}, +}; + +UNIGNORE_WARNINGS + +static const Command *find_cmd_mode_command(const char *name) +{ + const Command *cmd = BSEARCH(name, common_cmds, command_cmp); + return cmd ? cmd : BSEARCH(name, command_cmds, command_cmp); +} + +static const Command *find_search_mode_command(const char *name) +{ + const Command *cmd = BSEARCH(name, common_cmds, command_cmp); + return cmd ? cmd : BSEARCH(name, search_cmds, command_cmp); +} + +const CommandSet cmd_mode_commands = { + .lookup = find_cmd_mode_command +}; + +const CommandSet search_mode_commands = { + .lookup = find_search_mode_command +}; diff --git a/tests/test.go b/tests/test.go new file mode 100644 index 0000000..f889232 --- /dev/null +++ b/tests/test.go @@ -0,0 +1,4513 @@ +package main + +// Monolithic core of the application. Manages the global editor state, buffer +// lifecycle, UI rendering, and coordination between different components like +// LSP, Ollama, and Syntax. + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "time" + "unicode" + + "github.com/nsf/termbox-go" +) + +// Mode represents the current operational state of the editor. +type Mode int + +const ( + ModeNormal Mode = iota + ModeInsert + ModeCommand // Colon command line mode + ModeFuzzy // File/buffer fuzzy finder mode + ModeVisual // Character-wise selection + ModeVisualLine // Line-wise selection + ModeFind // In-file search mode (/) + ModeReplace // Pattern replacement mode + ModeVisualBlock // Columnar selection + ModeConfirm // Yes/No confirmation prompt +) + +type FuzzyType int + +const ( + FuzzyModeFile FuzzyType = iota + FuzzyModeBuffer + FuzzyModeWarning +) + +type Jump struct { + filename string + cursorX int + cursorY int +} + +type DiagnosticItem struct { + filename string + line int + character int + message string + severity int +} + +// MatchRange represents a span of text matched by search or replace. +type MatchRange struct { + startLine int + startCol int + endLine int + endCol int +} + +// Editor is the main controller struct that holds all global state. +type Editor struct { + buffers []*Buffer // All open file buffers. + activeBufferIndex int // Currently visible buffer. + mode Mode // Current editor mode. + clipboard []rune // Basic internal clipboard. + pendingKey rune // Stores the first character of a multi-key command (e.g., 'g'). + commandBuffer []rune // Input for the : command line. + commandCursorX int // Cursor position within commandBuffer. + commandHistory []string // History of executed commands. + commandHistoryIdx int // Current position in command history (-1 = not navigating). + findBuffer []rune // Input for the / find line. + findSavedSearch string // Search term before incremental search started. + lastSearch string // The last searched term (for 'n'/'N'). + fuzzyBuffer []rune // Filter pattern in fuzzy finder. + fuzzyResults []string // Filtered items shown to the user. + fuzzyResultIndices []int // Map from displayed results back to original candidates. + fuzzyIndex int // Highlighted item in the result list. + fuzzyScroll int // Viewport offset for the result list. + fuzzyCandidates []string // Raw list of all possible items (files/buffers/etc.). + fuzzyType FuzzyType // What the fuzzy finder is searching for. + fuzzyDiagnostics []DiagnosticItem // Diagnostics from all buffers (accessible via finder). + mouseEnabled bool // Toggle for mouse support. + visualStartX int // Starting anchor for visual selection. + visualStartY int // Starting anchor for visual selection. + logMessages []string // Internal debug logs shown in the Log window. + maxLogMessages int // Maximum capacity of the log ring buffer. + showDebugLog bool // Visibility toggle for the log window. + jumplist []Jump // History of cursor locations (for Ctrl-O/Ctrl-I). + jumpIndex int // Current position in the jumplist. + message string // Status message shown at the bottom. + commands *Command // Command handler instance. + devMode bool // Internal developer mode toggle. + ollamaClient *OllamaClient // Client for local AI features. + introDismissed bool // Whether the splash screen was hidden. + + // Replace mode state (regex replacement UI) + replaceInput []rune + replaceSelStartX int + replaceSelStartY int + replaceSelEndX int + replaceSelEndY int + replaceMatches []MatchRange + pendingConfirm func() // Callback for the confirmation mode. + hoverContent string // Text content for the LSP hover popup. + showHover bool // Visibility toggle for the hover popup. + + // Autocomplete state + showAutocomplete bool // Visibility toggle for the autocomplete popup. + autocompleteItems []CompletionItem // List of completion suggestions from LSP. + autocompleteIndex int // Currently selected item in the autocomplete list. + autocompleteScroll int // Scroll offset for autocomplete popup. +} + +// activeBuffer returns the Buffer currently being edited. +func (e *Editor) activeBuffer() *Buffer { + if len(e.buffers) == 0 { + return nil + } + return e.buffers[e.activeBufferIndex] +} + +func (e *Editor) useTabs() bool { + b := e.activeBuffer() + if b == nil || b.fileType == nil { + return false + } + return b.fileType.UseTabs +} + +func (e *Editor) markModified() { + b := e.activeBuffer() + if b != nil { + b.modified = true + } +} + +func (e *Editor) visualWidth(r rune, currentX int) int { + if r == '\t' { + b := e.activeBuffer() + tabWidth := Config.DefaultTabWidth + if b != nil && b.fileType != nil { + tabWidth = b.fileType.TabWidth + } + return tabWidth - (currentX % tabWidth) + } + return 1 +} + +// bufferToVisual converts a buffer column index to its visual column index (explaining tabs). +func (e *Editor) bufferToVisual(line []rune, bufferX int) int { + visualX := 0 + for i := 0; i < bufferX && i < len(line); i++ { + visualX += e.visualWidth(line[i], visualX) + } + return visualX +} + +func (e *Editor) bufferToString(buffer [][]rune) string { + var result strings.Builder + for i, line := range buffer { + result.WriteString(string(line)) + if i < len(buffer)-1 { + result.WriteString("\n") + } + } + return result.String() +} + +// NewEditor creates a new editor instance with a default empty buffer. +func NewEditor(devMode bool) *Editor { + e := &Editor{ + buffers: []*Buffer{}, + activeBufferIndex: 0, + mode: ModeNormal, + pendingKey: 0, + commandBuffer: []rune{}, + commandHistory: []string{}, + commandHistoryIdx: -1, + findBuffer: []rune{}, + lastSearch: "", + fuzzyBuffer: []rune{}, + fuzzyResults: []string{}, + fuzzyIndex: 0, + fuzzyScroll: 0, + fuzzyCandidates: []string{}, + mouseEnabled: true, + logMessages: []string{}, + maxLogMessages: 50, + showDebugLog: false, + jumplist: []Jump{}, + jumpIndex: -1, + devMode: devMode, + ollamaClient: NewOllamaClient(), + } + e.addLog("Editor", "Editor initialized") + // Add an initial empty buffer with default file type + defaultType := fileTypes[len(fileTypes)-1] + e.buffers = append(e.buffers, &Buffer{ + buffer: [][]rune{{}}, + undoStack: []HistoryState{}, + redoStack: []HistoryState{}, + fileType: defaultType, + }) + e.commands = &Command{e: e} + return e +} + +func (e *Editor) addLog(group, msg string) { + t := time.Now() + timestamp := fmt.Sprintf("[%02d:%01d:%02d]", t.Hour(), t.Minute(), t.Second()) + logMsg := fmt.Sprintf("%s [%s] %s", timestamp, group, msg) + e.logMessages = append(e.logMessages, logMsg) + + if len(e.logMessages) > e.maxLogMessages { + e.logMessages = e.logMessages[len(e.logMessages)-e.maxLogMessages:] + } + + if Config.UseLogFile { + f, err := os.OpenFile(Config.LogFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err == nil { + defer f.Close() + f.WriteString(logMsg + "\n") + } + } +} + +func (e *Editor) toggleDebugWindow() { + e.showDebugLog = !e.showDebugLog +} + +// LoadFile reads a file from disk into the active buffer. +func (e *Editor) LoadFile(filename string) error { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + // Create subfolders if they don't exist + if dir := filepath.Dir(filename); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directories: %v", err) + } + } + // Create the file + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file: %v", err) + } + file.Close() + // Get info for the newly created file + info, err = os.Stat(filename) + if err != nil { + return err + } + } else if err != nil { + return err + } + + file, err := os.Open(filename) + if err != nil { + return err + } + defer file.Close() + err = e.LoadFromReader(filename, file) + if err == nil { + if info != nil { + e.activeBuffer().lastModTime = info.ModTime() + } + } + return err +} + +func (e *Editor) LoadFromReader(filename string, r io.Reader) error { + ft := getFileType(filename) + + var bufferLines [][]rune + reader := bufio.NewReader(r) + for { + line, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return err + } + + if err == io.EOF && line == "" { + break + } + + // Remove trailing newline + trimmedLine := strings.TrimSuffix(line, "\n") + trimmedLine = strings.TrimSuffix(trimmedLine, "\r") + + if !ft.UseTabs { + trimmedLine = strings.ReplaceAll(trimmedLine, "\t", strings.Repeat(" ", ft.TabWidth)) + } + bufferLines = append(bufferLines, []rune(trimmedLine)) + + if err == io.EOF { + break + } + } + + // Ensure buffer is never empty + if len(bufferLines) == 0 { + bufferLines = [][]rune{{}} + } + + // Check if we should update current buffer or add a new one + b := e.activeBuffer() + if b != nil && b.filename == "" && len(b.buffer) == 1 && len(b.buffer[0]) == 0 { + // reuse current empty buffer + b.filename = filename + b.buffer = bufferLines + b.PrimaryCursor().X = 0 + b.PrimaryCursor().Y = 0 + b.scrollX = 0 + b.scrollY = 0 + b.undoStack = []HistoryState{} + b.redoStack = []HistoryState{} + b.redoStack = []HistoryState{} + b.fileType = ft + + // Initialize Syntax Highlighter + syntax := NewSyntaxHighlighter(ft.Name, e.addLog) + if syntax != nil { + content := e.bufferToString(bufferLines) + syntax.Parse([]byte(content)) + b.syntax = syntax + } + + // Initialize LSP if enabled for this file type + if ft.EnableLSP && ft.LSPCommand != "" { + e.addLog("LSP", fmt.Sprintf("Starting LSP for %s", filepath.Base(filename))) + content := e.bufferToString(bufferLines) + lspClient, err := NewLSPClient(filename, content, e.addLog, ft) + if err == nil { + b.lspClient = lspClient + e.addLog("LSP", "LSP client initialized successfully") + } else { + e.addLog("LSP", fmt.Sprintf("LSP init failed: %v", err)) + } + } + } else { + // add new buffer + newB := &Buffer{ + buffer: bufferLines, + filename: filename, + undoStack: []HistoryState{}, + redoStack: []HistoryState{}, + fileType: ft, + } + + // Initialize Syntax Highlighter + syntax := NewSyntaxHighlighter(ft.Name, e.addLog) + if syntax != nil { + content := e.bufferToString(bufferLines) + syntax.Parse([]byte(content)) + newB.syntax = syntax + } + + // Initialize LSP if enabled for this file type + if ft.EnableLSP && ft.LSPCommand != "" { + e.addLog("LSP", fmt.Sprintf("Starting LSP for %s", filepath.Base(filename))) + content := e.bufferToString(bufferLines) + lspClient, err := NewLSPClient(filename, content, e.addLog, ft) + if err == nil { + newB.lspClient = lspClient + e.addLog("LSP", "LSP client initialized successfully") + } else { + e.addLog("LSP", fmt.Sprintf("LSP init failed: %v", err)) + } + } + + e.buffers = append(e.buffers, newB) + e.activeBufferIndex = len(e.buffers) - 1 + } + + return nil +} + +// SaveFile writes the active buffer content back to disk. +func (e *Editor) SaveFile(force bool) error { + b := e.activeBuffer() + if b == nil || b.filename == "" { + return fmt.Errorf("no filename") + } + + // Check for external modifications unless forced. + if !force { + info, err := os.Stat(b.filename) + if err == nil && info.ModTime().After(b.lastModTime) { + return fmt.Errorf("file changed on disk") + } + } + + file, err := os.Create(b.filename) + if err != nil { + return err + } + defer file.Close() + + writer := bufio.NewWriter(file) + for i, line := range b.buffer { + _, err := writer.WriteString(string(line)) + if err != nil { + return err + } + // Write newline if not the last line (or if buffer should end with newline). + if i < len(b.buffer)-1 || (len(b.buffer) > 0 && (len(b.buffer) > 1 || len(b.buffer[0]) > 0)) { + _, err = writer.WriteString("\n") + if err != nil { + return err + } + } + } + err = writer.Flush() + if err == nil { + b.modified = false + info, err := os.Stat(b.filename) + if err == nil { + b.lastModTime = info.ModTime() + } + } + return err +} + +func (e *Editor) nextBuffer() { + if len(e.buffers) > 0 { + e.activeBufferIndex = (e.activeBufferIndex + 1) % len(e.buffers) + } +} + +func (e *Editor) prevBuffer() { + if len(e.buffers) > 0 { + e.activeBufferIndex = (e.activeBufferIndex - 1 + len(e.buffers)) % len(e.buffers) + } +} + +func (e *Editor) ReloadBuffer(b *Buffer) error { + if b == nil || b.filename == "" { + return fmt.Errorf("no filename") + } + + info, err := os.Stat(b.filename) + if err != nil { + return err + } + + file, err := os.Open(b.filename) + if err != nil { + return err + } + defer file.Close() + + ft := getFileType(b.filename) + + var bufferLines [][]rune + reader := bufio.NewReader(file) + for { + line, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return err + } + + if err == io.EOF && line == "" { + break + } + + trimmedLine := strings.TrimSuffix(line, "\n") + trimmedLine = strings.TrimSuffix(trimmedLine, "\r") + + if !ft.UseTabs { + trimmedLine = strings.ReplaceAll(trimmedLine, "\t", strings.Repeat(" ", ft.TabWidth)) + } + bufferLines = append(bufferLines, []rune(trimmedLine)) + + if err == io.EOF { + break + } + } + + if len(bufferLines) == 0 { + bufferLines = [][]rune{{}} + } + + b.buffer = bufferLines + b.lastModTime = info.ModTime() + b.modified = false + + // Adjust cursors if they are out of bounds + for i := range b.cursors { + c := &b.cursors[i] + if c.Y >= len(b.buffer) { + c.Y = len(b.buffer) - 1 + } + if c.Y < 0 { + c.Y = 0 + } + if c.X > len(b.buffer[c.Y]) { + c.X = len(b.buffer[c.Y]) + } + } + + // Reinitialize Syntax Highlighter + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } else { + syntax := NewSyntaxHighlighter(ft.Name, e.addLog) + if syntax != nil { + syntax.Parse([]byte(b.toString())) + b.syntax = syntax + } + } + + // Update LSP if active + if b.lspClient != nil { + b.lspClient.SendDidChange(b.toString()) + } + + return nil +} + +func (e *Editor) CheckFilesOnDisk() { + for _, b := range e.buffers { + if b.filename == "" { + continue + } + + info, err := os.Stat(b.filename) + if err != nil { + continue + } + + if info.ModTime().After(b.lastModTime) { + isActive := b == e.activeBuffer() + if !b.modified { + // Auto reload if not dirty + err := e.ReloadBuffer(b) + if err == nil { + e.addLog("Editor", fmt.Sprintf("Auto-reloaded \"%s\" (changed on disk)", filepath.Base(b.filename))) + if isActive { + e.message = fmt.Sprintf("\"%s\" reloaded from disk", filepath.Base(b.filename)) + } + } else { + e.addLog("Editor", fmt.Sprintf("Failed to auto-reload \"%s\": %v", b.filename, err)) + } + } else if isActive { + // Buffer is dirty, just notify the user (only if active) + e.message = fmt.Sprintf("WARNING: \"%s\" changed on disk. Use :reload to update.", filepath.Base(b.filename)) + e.addLog("Editor", fmt.Sprintf("\"%s\" changed on disk but buffer is modified", b.filename)) + // Update lastModTime so we don't spam the message? + // Actually, better to keep it so they realize it's still different. + // But we should probably only message if it's the active buffer. + } + } + } +} + +func (e *Editor) PeriodicFileChangesCheck() { + go func() { + for { + time.Sleep(Config.FileCheckInterval) + termbox.Interrupt() + } + }() +} + +func (e *Editor) startFileFuzzyFinder() { + e.fuzzyCandidates = []string{} + filepath.Walk(".", func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + if info.Name() == ".git" || info.Name() == "node_modules" { + return filepath.SkipDir + } + return nil + } + e.fuzzyCandidates = append(e.fuzzyCandidates, path) + return nil + }) + e.fuzzyBuffer = []rune{} + e.fuzzyIndex = 0 + e.fuzzyType = FuzzyModeFile + e.updateFuzzyResults() + e.mode = ModeFuzzy +} + +func (e *Editor) startBufferFuzzyFinder() { + e.fuzzyCandidates = []string{} + for _, b := range e.buffers { + name := b.filename + if name == "" { + name = "[No Name]" + } + e.fuzzyCandidates = append(e.fuzzyCandidates, name) + } + e.fuzzyBuffer = []rune{} + e.fuzzyIndex = 0 + e.fuzzyType = FuzzyModeBuffer + e.updateFuzzyResults() + e.mode = ModeFuzzy +} + +func (e *Editor) startWarningsFuzzyFinder() { + e.fuzzyCandidates = []string{} + e.fuzzyDiagnostics = []DiagnosticItem{} + + // Collect diagnostics from all buffers + for _, b := range e.buffers { + if len(b.diagnostics) == 0 { + continue + } + + filename := b.filename + if filename == "" { + filename = "[No Name]" + } else { + filename = filepath.Base(filename) + } + + for _, diag := range b.diagnostics { + // Format: [E] filename:line message + severityStr := "?" + switch diag.Severity { + case 1: + severityStr = "E" + case 2: + severityStr = "W" + case 3: + severityStr = "I" + case 4: + severityStr = "H" + } + + formattedDiag := fmt.Sprintf("[%s] %s:%d %s", + severityStr, + filename, + diag.Range.Start.Line+1, // Convert to 1-indexed + diag.Message) + + e.fuzzyCandidates = append(e.fuzzyCandidates, formattedDiag) + e.fuzzyDiagnostics = append(e.fuzzyDiagnostics, DiagnosticItem{ + filename: b.filename, + line: diag.Range.Start.Line, + character: diag.Range.Start.Character, + message: diag.Message, + severity: diag.Severity, + }) + } + } + + e.fuzzyBuffer = []rune{} + e.fuzzyIndex = 0 + e.fuzzyType = FuzzyModeWarning + e.updateFuzzyResults() + e.mode = ModeFuzzy +} + +func fuzzyMatch(query, target string) (int, bool) { + if query == "" { + return 0, true + } + + query = strings.ToLower(query) + targetLower := strings.ToLower(target) + + score := 0 + targetIdx := 0 + lastMatchIdx := -1 + + for _, qRune := range query { + found := false + for i := targetIdx; i < len(targetLower); i++ { + if rune(targetLower[i]) == qRune { + // Bonus for consecutive matches + if lastMatchIdx != -1 && i == lastMatchIdx+1 { + score += 10 + } + + // Bonus for matches after separators + if i == 0 || targetLower[i-1] == '/' || targetLower[i-1] == '_' || targetLower[i-1] == '.' || targetLower[i-1] == '-' { + score += 20 + } + + // Penalty for gaps + if lastMatchIdx != -1 { + score -= (i - lastMatchIdx - 1) + } + + score += 5 // Base match score + lastMatchIdx = i + targetIdx = i + 1 + found = true + break + } + } + if !found { + return 0, false + } + } + + // Substring match bonus + if strings.Contains(targetLower, query) { + score += 50 + } + + // Exact match bonus + if targetLower == query { + score += 100 + } + + return score, true +} + +func (e *Editor) updateFuzzyResults() { + query := string(e.fuzzyBuffer) + if query == "" { + e.fuzzyResults = make([]string, len(e.fuzzyCandidates)) + e.fuzzyResultIndices = make([]int, len(e.fuzzyCandidates)) + copy(e.fuzzyResults, e.fuzzyCandidates) + for i := range e.fuzzyResultIndices { + e.fuzzyResultIndices[i] = i + } + } else { + type result struct { + path string + index int + score int + } + var results []result + for i, candidate := range e.fuzzyCandidates { + if score, ok := fuzzyMatch(query, candidate); ok { + results = append(results, result{candidate, i, score}) + } + } + + sort.Slice(results, func(i, j int) bool { + return results[i].score > results[j].score + }) + + e.fuzzyResults = make([]string, len(results)) + e.fuzzyResultIndices = make([]int, len(results)) + for i, res := range results { + e.fuzzyResults[i] = res.path + e.fuzzyResultIndices[i] = res.index + } + } + if e.fuzzyIndex >= len(e.fuzzyResults) { + e.fuzzyIndex = 0 + } + e.fuzzyScroll = 0 +} + +func (e *Editor) openSelectedFile() { + if len(e.fuzzyResults) == 0 { + return + } + selection := e.fuzzyResults[e.fuzzyIndex] + + if e.fuzzyType == FuzzyModeFile { + err := e.LoadFile(selection) + if err == nil { + e.mode = ModeNormal + } + } else if e.fuzzyType == FuzzyModeBuffer { + for i, b := range e.buffers { + name := b.filename + if name == "" { + name = "[No Name]" + } + if name == selection { + e.activeBufferIndex = i + e.mode = ModeNormal + break + } + } + } else if e.fuzzyType == FuzzyModeWarning { + if e.fuzzyIndex >= len(e.fuzzyResults) || e.fuzzyIndex >= len(e.fuzzyResultIndices) { + return + } + + // Get the original candidate index + diagIndex := e.fuzzyResultIndices[e.fuzzyIndex] + + if diagIndex < 0 || diagIndex >= len(e.fuzzyDiagnostics) { + return + } + + diagItem := e.fuzzyDiagnostics[diagIndex] + + // Find or load the buffer with this file + bufferIndex := -1 + for i, b := range e.buffers { + if b.filename == diagItem.filename { + bufferIndex = i + break + } + } + + // If buffer not found, try to load it + if bufferIndex == -1 && diagItem.filename != "" { + err := e.LoadFile(diagItem.filename) + if err == nil { + bufferIndex = e.activeBufferIndex + } + } + + // Navigate to the diagnostic location + if bufferIndex != -1 { + e.activeBufferIndex = bufferIndex + b := e.activeBuffer() + if b != nil { + // Set cursor to diagnostic line and character + if diagItem.line < len(b.buffer) { + b.PrimaryCursor().Y = diagItem.line + if diagItem.character < len(b.buffer[diagItem.line]) { + b.PrimaryCursor().X = diagItem.character + } else { + b.PrimaryCursor().X = 0 + } + } + // Center the screen on the diagnostic line + e.centerScreen() + } + e.mode = ModeNormal + } + } +} + +func (e *Editor) fuzzyMove(dir int) { + if len(e.fuzzyResults) == 0 { + return + } + e.fuzzyIndex += dir + if e.fuzzyIndex < 0 { + e.fuzzyIndex = len(e.fuzzyResults) - 1 + } else if e.fuzzyIndex >= len(e.fuzzyResults) { + e.fuzzyIndex = 0 + } + + // Adjust scroll + if e.fuzzyIndex < e.fuzzyScroll { + e.fuzzyScroll = e.fuzzyIndex + } else if e.fuzzyIndex >= e.fuzzyScroll+Config.FuzzyFinderHeight { + e.fuzzyScroll = e.fuzzyIndex - Config.FuzzyFinderHeight + 1 + } + + // Special case for wrapping + if e.fuzzyIndex == len(e.fuzzyResults)-1 && e.fuzzyScroll == 0 && len(e.fuzzyResults) > Config.FuzzyFinderHeight { + e.fuzzyScroll = len(e.fuzzyResults) - Config.FuzzyFinderHeight + } + if e.fuzzyIndex == 0 && e.fuzzyScroll > 0 { + e.fuzzyScroll = 0 + } +} + +// insertTab inserts either a literal tab character or an equivalent number of spaces. +func (e *Editor) insertTab() { + b := e.activeBuffer() + if b == nil { + return + } + if e.useTabs() { + e.insertRune('\t') + } else { + tabWidth := Config.DefaultTabWidth + if b.fileType != nil { + tabWidth = b.fileType.TabWidth + } + for i := 0; i < tabWidth; i++ { + e.insertRune(' ') + } + } +} + +// getSortedCursorsDesc returns a list of cursor pointers sorted by position (bottom-to-top, right-to-left). +// This sorting is CRITICAL for concurrent text edits to avoid offset corruption. +func (e *Editor) getSortedCursorsDesc() []*Cursor { + b := e.activeBuffer() + if b == nil { + return nil + } + cursors := make([]*Cursor, len(b.cursors)) + for i := range b.cursors { + cursors[i] = &b.cursors[i] + } + sort.Slice(cursors, func(i, j int) bool { + if cursors[i].Y != cursors[j].Y { + return cursors[i].Y > cursors[j].Y // Lower rows first. + } + return cursors[i].X > cursors[j].X // Later characters in row first. + }) + return cursors +} + +func (e *Editor) insertRune(r rune) { + b := e.activeBuffer() + if b == nil { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + cursors := e.getSortedCursorsDesc() + for _, c := range cursors { + line := b.buffer[c.Y] + newLine := make([]rune, len(line)+1) + copy(newLine[:c.X], line[:c.X]) + newLine[c.X] = r + copy(newLine[c.X+1:], line[c.X:]) + b.buffer[c.Y] = newLine + c.X++ + + // Handle syntax update + if b.syntax != nil { + insertedBytes := uint32(len(string(r))) + b.handleEdit(c.Y, c.X-1, 0, insertedBytes, c.Y, b.getLineByteOffset(line, c.X-1), c.Y, b.getLineByteOffset(newLine, c.X)) + } + } + + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() + + // Notify LSP of the change + if b.lspClient != nil { + b.lspClient.SendDidChange(b.toString()) + } +} + +// DeleteChar removes the character directly under the cursor. +func (e *Editor) DeleteChar() { + b := e.activeBuffer() + if b == nil { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + cursors := e.getSortedCursorsDesc() + for _, c := range cursors { + if c.Y >= len(b.buffer) || c.X >= len(b.buffer[c.Y]) { + continue + } + + line := b.buffer[c.Y] + // Store deleted character in clipboard (primary cursor only). + if c == b.PrimaryCursor() { + e.clipboard = []rune{line[c.X]} + } + + deletedBytes := uint32(len(string(line[c.X]))) + newLine := append(line[:c.X], line[c.X+1:]...) + b.buffer[c.Y] = newLine + + // Ensure cursor doesn't drift past the new end of line. + if c.X > 0 && c.X >= len(newLine) { + c.X = len(newLine) - 1 + if c.X < 0 { + c.X = 0 + } + } + + if b.syntax != nil { + oldColBytes := b.getLineByteOffset(line, c.X) + newColBytes := b.getLineByteOffset(newLine, c.X) + b.handleEdit(c.Y, c.X, deletedBytes, 0, c.Y, oldColBytes+deletedBytes, c.Y, newColBytes) + } + } + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + +func (e *Editor) backspace() { + b := e.activeBuffer() + if b == nil { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + cursors := e.getSortedCursorsDesc() + for _, c := range cursors { + if c.X > 0 { + line := b.buffer[c.Y] + deletedChar := line[c.X-1] + newLine := append(line[:c.X-1], line[c.X:]...) + b.buffer[c.Y] = newLine + c.X-- + + if b.syntax != nil { + deletedBytes := uint32(len(string(deletedChar))) + oldColBytes := b.getLineByteOffset(line, c.X+1) + newColBytes := b.getLineByteOffset(newLine, c.X) + + b.handleEdit(c.Y, c.X, deletedBytes, 0, c.Y, oldColBytes, c.Y, newColBytes) + } + } else if c.Y > 0 { + // Merge with previous line + prevLine := b.buffer[c.Y-1] + c.X = len(prevLine) + b.buffer[c.Y-1] = append(prevLine, b.buffer[c.Y]...) + b.buffer = append(b.buffer[:c.Y], b.buffer[c.Y+1:]...) + // We need to shift cursors that are 'below' the current merge point. + for j := range b.cursors { + if b.cursors[j].Y > c.Y { + b.cursors[j].Y-- + } + } + + c.Y-- + + // So I need to find other cursors on the same line that haven't been processed? + // Or just all cursors on the same line. + for j := range b.cursors { + if &b.cursors[j] != c && b.cursors[j].Y == c.Y+1 { // c.Y was decremented + // This cursor was on the line we just merged + b.cursors[j].Y-- + b.cursors[j].X += len(prevLine) + } + } + + if b.syntax != nil { + b.handleEdit(c.Y, c.X, 1, 0, c.Y+1, 0, c.Y, b.getLineByteOffset(b.buffer[c.Y], c.X)) + } + } + } + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + +func (e *Editor) getIndentation(line []rune) []rune { + var indent []rune + for _, r := range line { + if r == ' ' || r == '\t' { + indent = append(indent, r) + } else { + break + } + } + return indent +} + +// insertNewline breaks the line at cursor and handles auto-indentation. +func (e *Editor) insertNewline() { + b := e.activeBuffer() + if b == nil { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + cursors := e.getSortedCursorsDesc() + for _, c := range cursors { + line := b.buffer[c.Y] + + // Inherit indentation from the current line. + indent := e.getIndentation(line[:c.X]) + + // Auto-indent after opening braces. + if c.X > 0 && line[c.X-1] == '{' { + if e.useTabs() { + indent = append(indent, '\t') + } else { + tabWidth := Config.DefaultTabWidth + if b.fileType != nil { + tabWidth = b.fileType.TabWidth + } + indent = append(indent, []rune(strings.Repeat(" ", tabWidth))...) + } + } + + remaining := make([]rune, len(line)-c.X) + copy(remaining, line[c.X:]) + + newLine := append(indent, remaining...) + b.buffer[c.Y] = line[:c.X] + + // Insert the new line into the buffer. + newBuffer := make([][]rune, len(b.buffer)+1) + copy(newBuffer[:c.Y+1], b.buffer[:c.Y+1]) + newBuffer[c.Y+1] = newLine + copy(newBuffer[c.Y+2:], b.buffer[c.Y+1:]) + b.buffer = newBuffer + + // Shift all cursors below this point, or later on this same line. + for j := range b.cursors { + if b.cursors[j].Y > c.Y { + b.cursors[j].Y++ + } else if b.cursors[j].Y == c.Y && b.cursors[j].X >= c.X && &b.cursors[j] != c { + b.cursors[j].Y++ + b.cursors[j].X = len(indent) + (b.cursors[j].X - c.X) + } + } + + oldCursorX := c.X + c.Y++ + c.X = len(indent) + + if b.syntax != nil { + insertedBytes := uint32(1 + len(string(indent))) + b.handleEdit(c.Y-1, oldCursorX, 0, insertedBytes, c.Y-1, b.getLineByteOffset(b.buffer[c.Y-1], oldCursorX), c.Y, b.getLineByteOffset(b.buffer[c.Y], c.X)) + } + } + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + +func (e *Editor) insertLineBelow() { + b := e.activeBuffer() + if b == nil { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + line := b.buffer[b.PrimaryCursor().Y] + indent := e.getIndentation(line) + + // Check if the current line ends with '{' to increase indent + trimmedLine := strings.TrimRight(string(line), " ") + if len(trimmedLine) > 0 && trimmedLine[len(trimmedLine)-1] == '{' { + if e.useTabs() { + indent = append(indent, '\t') + } else { + tabWidth := Config.DefaultTabWidth + if b.fileType != nil { + tabWidth = b.fileType.TabWidth + } + indent = append(indent, []rune(strings.Repeat(" ", tabWidth))...) + } + } + + newBuffer := make([][]rune, len(b.buffer)+1) + copy(newBuffer[:b.PrimaryCursor().Y+1], b.buffer[:b.PrimaryCursor().Y+1]) + newBuffer[b.PrimaryCursor().Y+1] = indent + copy(newBuffer[b.PrimaryCursor().Y+2:], b.buffer[b.PrimaryCursor().Y+1:]) + b.buffer = newBuffer + + b.PrimaryCursor().Y++ + b.PrimaryCursor().X = len(indent) + + if b.syntax != nil { + insertedBytes := uint32(1 + len(string(indent))) + oldLineLen := b.getLineByteOffset(line, len(line)) + b.handleEdit(b.PrimaryCursor().Y-1, len(line), 0, insertedBytes, b.PrimaryCursor().Y-1, oldLineLen, b.PrimaryCursor().Y, b.getLineByteOffset(b.buffer[b.PrimaryCursor().Y], b.PrimaryCursor().X)) + } + + e.mode = ModeInsert + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + +func (e *Editor) insertLineAbove() { + b := e.activeBuffer() + if b == nil { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + line := b.buffer[b.PrimaryCursor().Y] + indent := e.getIndentation(line) + + newBuffer := make([][]rune, len(b.buffer)+1) + copy(newBuffer[:b.PrimaryCursor().Y], b.buffer[:b.PrimaryCursor().Y]) + newBuffer[b.PrimaryCursor().Y] = indent + copy(newBuffer[b.PrimaryCursor().Y+1:], b.buffer[b.PrimaryCursor().Y:]) + b.buffer = newBuffer + + b.PrimaryCursor().X = len(indent) + + if b.syntax != nil { + insertedBytes := uint32(1 + len(string(indent))) + b.handleEdit(b.PrimaryCursor().Y, 0, 0, insertedBytes, b.PrimaryCursor().Y, 0, b.PrimaryCursor().Y+1, 0) + } + + e.mode = ModeInsert + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + +func (e *Editor) moveCursor(dx int, dy int) { + b := e.activeBuffer() + if b == nil { + return + } + + for i := range b.cursors { + c := &b.cursors[i] + if dy != 0 { + newY := c.Y + dy + if newY >= 0 && newY < len(b.buffer) { + c.Y = newY + // Snap cursorX to the end of the new line if it's currently further + // Or restore to preferred column if moving vertically + if c.PreferredCol > len(b.buffer[c.Y]) { + c.X = len(b.buffer[c.Y]) + } else { + c.X = c.PreferredCol + } + } + } + + if dx != 0 { + newX := c.X + dx + if newX < 0 { + if c.Y > 0 { + c.Y-- + c.X = len(b.buffer[c.Y]) + } + } else if newX > len(b.buffer[c.Y]) { + if c.Y < len(b.buffer)-1 { + c.Y++ + c.X = 0 + } + } else { + c.X = newX + } + // Update preferred column when moving horizontally + c.PreferredCol = c.X + } + } + // TODO: Merge overlapping cursors +} + +func (e *Editor) mergeCursors() { + b := e.activeBuffer() + if b == nil || len(b.cursors) <= 1 { + return + } + + // Sort cursors by Y, then X + sort.Slice(b.cursors, func(i, j int) bool { + if b.cursors[i].Y != b.cursors[j].Y { + return b.cursors[i].Y < b.cursors[j].Y + } + return b.cursors[i].X < b.cursors[j].X + }) + + // Remove duplicates + uniqueCursors := []Cursor{b.cursors[0]} + for i := 1; i < len(b.cursors); i++ { + current := b.cursors[i] + last := uniqueCursors[len(uniqueCursors)-1] + + if current.Y == last.Y && current.X == last.X { + continue + } + uniqueCursors = append(uniqueCursors, current) + } + b.cursors = uniqueCursors +} + +func (e *Editor) isWordChar(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' +} + +func (e *Editor) getWordUnderCursor() string { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return "" + } + line := b.buffer[b.PrimaryCursor().Y] + if len(line) == 0 || b.PrimaryCursor().X >= len(line) { + return "" + } + + if !e.isWordChar(line[b.PrimaryCursor().X]) { + return "" + } + + start := b.PrimaryCursor().X + for start > 0 && e.isWordChar(line[start-1]) { + start-- + } + + end := b.PrimaryCursor().X + for end < len(line) && e.isWordChar(line[end]) { + end++ + } + + return string(line[start:end]) +} + +func (e *Editor) isPathChar(r rune) bool { + return e.isWordChar(r) || r == '/' || r == '.' || r == '-' || r == '_' || r == '~' || r == '\\' || r == ':' +} + +func (e *Editor) getPathUnderCursor() string { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return "" + } + line := b.buffer[b.PrimaryCursor().Y] + if len(line) == 0 || b.PrimaryCursor().X >= len(line) { + return "" + } + + if !e.isPathChar(line[b.PrimaryCursor().X]) { + return "" + } + + // Start searching from the current cursor position + start := b.PrimaryCursor().X + for start > 0 && e.isPathChar(line[start-1]) { + start-- + } + + end := b.PrimaryCursor().X + for end < len(line) && e.isPathChar(line[end]) { + end++ + } + + return string(line[start:end]) +} + +func (e *Editor) gotoFile() { + path := e.getPathUnderCursor() + if path == "" { + e.message = "No path under cursor" + return + } + + // Check if the path is a URL + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + e.openURL(path) + return + } + + b := e.activeBuffer() + if b == nil { + return + } + + // Try relative to current file + dir := filepath.Dir(b.filename) + targetPath := filepath.Join(dir, path) + + if _, err := os.Stat(targetPath); os.IsNotExist(err) { + // Try relative to CWD + targetPath = path + if _, err := os.Stat(targetPath); os.IsNotExist(err) { + e.message = "File not found: " + path + return + } + } + + // Resolve absolute path for comparison + absPath, err := filepath.Abs(targetPath) + if err != nil { + e.message = "Error resolving path: " + err.Error() + return + } + + // Check if already open + for i, buf := range e.buffers { + bufAbs, _ := filepath.Abs(buf.filename) + if absPath == bufAbs { + e.pushJump() + e.activeBufferIndex = i + return + } + } + + // Open new file + e.pushJump() + if err := e.LoadFile(targetPath); err != nil { + e.message = "Error opening file: " + err.Error() + } +} + +func (e *Editor) openURL(url string) { + var cmd string + var args []string + + // Detect OS and set appropriate command + switch runtime.GOOS { + case "darwin": + // macOS + cmd = "open" + args = []string{url} + default: + // Linux and others + cmd = "xdg-open" + args = []string{url} + } + + // Execute the command + exec := exec.Command(cmd, args...) + if err := exec.Start(); err != nil { + e.message = "Error opening URL: " + err.Error() + } else { + e.message = "Opening URL in browser..." + } +} + +// centerCursor scrolls the viewport so the cursor is in the middle of the screen. +func (e *Editor) centerCursor() { + b := e.activeBuffer() + if b == nil { + return + } + + _, h := termbox.Size() + visibleHeight := h - 2 // Status bar and message line. + if visibleHeight < 1 { + visibleHeight = 1 + } + + targetScrollY := b.PrimaryCursor().Y - (visibleHeight / 2) + if targetScrollY < 0 { + targetScrollY = 0 + } + + // Clamp to legitimate buffer range. + if targetScrollY > len(b.buffer)-visibleHeight { + targetScrollY = len(b.buffer) - visibleHeight + } + if targetScrollY < 0 { + targetScrollY = 0 + } + + b.scrollY = targetScrollY +} + +func (e *Editor) gotoDefinition() { + b := e.activeBuffer() + if b == nil || b.lspClient == nil { + return + } + + e.pushJump() + + locs, err := b.lspClient.Definition(b.PrimaryCursor().Y, b.PrimaryCursor().X) + if err != nil { + e.addLog("Editor", fmt.Sprintf("gotoDefinition error: %v", err)) + return + } + + if len(locs) == 0 { + e.addLog("Editor", "gotoDefinition: No definition found") + return + } + + loc := locs[0] + targetPath := strings.TrimPrefix(loc.URI, "file://") + + // Find if buffer is already open + found := false + for i, buf := range e.buffers { + absT, _ := filepath.Abs(targetPath) + absB, _ := filepath.Abs(buf.filename) + if absT == absB { + e.activeBufferIndex = i + found = true + break + } + } + + if !found { + if err := e.LoadFile(targetPath); err != nil { + e.addLog("Editor", fmt.Sprintf("gotoDefinition: Failed to load %s: %v", targetPath, err)) + return + } + } + + b = e.activeBuffer() + b.PrimaryCursor().Y = loc.Range.Start.Line + b.PrimaryCursor().X = loc.Range.Start.Character + + // Ensure cursor is within bounds + if b.PrimaryCursor().Y < 0 { + b.PrimaryCursor().Y = 0 + } + if b.PrimaryCursor().Y >= len(b.buffer) { + b.PrimaryCursor().Y = len(b.buffer) - 1 + } + if b.PrimaryCursor().X < 0 { + b.PrimaryCursor().X = 0 + } + if b.PrimaryCursor().X > len(b.buffer[b.PrimaryCursor().Y]) { + b.PrimaryCursor().X = len(b.buffer[b.PrimaryCursor().Y]) + } + e.centerCursor() +} + +func (e *Editor) pushJump() { + b := e.activeBuffer() + if b == nil { + return + } + + jump := Jump{ + filename: b.filename, + cursorX: b.PrimaryCursor().X, + cursorY: b.PrimaryCursor().Y, + } + + // If we're not at the end of the jumplist, truncate it + if e.jumpIndex < len(e.jumplist)-1 { + e.jumplist = e.jumplist[:e.jumpIndex+1] + } + + // Don't push if the last jump is the same position + if len(e.jumplist) > 0 { + last := e.jumplist[len(e.jumplist)-1] + if last.filename == jump.filename && last.cursorX == jump.cursorX && last.cursorY == jump.cursorY { + return + } + } + + e.jumplist = append(e.jumplist, jump) + if len(e.jumplist) > 100 { + e.jumplist = e.jumplist[1:] + } + e.jumpIndex = len(e.jumplist) - 1 +} + +func (e *Editor) jumpBack() { + if e.jumpIndex < 0 { + return + } + + // If we are at the latest jump, push the CURRENT position so we can return to it + if e.jumpIndex == len(e.jumplist)-1 { + b := e.activeBuffer() + if b != nil { + curr := Jump{filename: b.filename, cursorX: b.PrimaryCursor().X, cursorY: b.PrimaryCursor().Y} + last := e.jumplist[e.jumpIndex] + if curr != last { + e.jumplist = append(e.jumplist, curr) + e.jumpIndex = len(e.jumplist) - 2 // Point to the one before the one we just added + } else { + e.jumpIndex-- + } + } else { + e.jumpIndex-- + } + } else { + e.jumpIndex-- + } + + if e.jumpIndex < 0 { + return + } + + e.performJump(e.jumplist[e.jumpIndex]) +} + +func (e *Editor) jumpForward() { + if e.jumpIndex >= len(e.jumplist)-1 { + return + } + + e.jumpIndex++ + e.performJump(e.jumplist[e.jumpIndex]) +} + +func (e *Editor) performJump(jump Jump) { + // Find if buffer is already open + found := false + for i, buf := range e.buffers { + absT, _ := filepath.Abs(jump.filename) + absB, _ := filepath.Abs(buf.filename) + if absT == absB { + e.activeBufferIndex = i + found = true + break + } + } + + if !found { + if err := e.LoadFile(jump.filename); err != nil { + e.addLog("Editor", fmt.Sprintf("performJump: Failed to load %s: %v", jump.filename, err)) + return + } + } + + b := e.activeBuffer() + b.PrimaryCursor().Y = jump.cursorY + b.PrimaryCursor().X = jump.cursorX + + // Ensure cursor is within bounds + if b.PrimaryCursor().Y < 0 { + b.PrimaryCursor().Y = 0 + } + if b.PrimaryCursor().Y >= len(b.buffer) { + b.PrimaryCursor().Y = len(b.buffer) - 1 + } + if b.PrimaryCursor().X < 0 { + b.PrimaryCursor().X = 0 + } + if b.PrimaryCursor().X > len(b.buffer[b.PrimaryCursor().Y]) { + b.PrimaryCursor().X = len(b.buffer[b.PrimaryCursor().Y]) + } +} + +// deleteWord removes a word-clump from the current cursor position. +func (e *Editor) deleteWord(includeSpaces bool) { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + cursors := e.getSortedCursorsDesc() + for _, c := range cursors { + if c.Y >= len(b.buffer) { + continue + } + line := b.buffer[c.Y] + if len(line) == 0 || c.X >= len(line) { + continue + } + + start := c.X + end := start + + // Determine the boundary of the deletion based on character type. + if e.isWordChar(line[end]) { + // On a word character: skip word characters, then skip trailing spaces + for end < len(line) && e.isWordChar(line[end]) { + end++ + } + if includeSpaces { + for end < len(line) && (line[end] == ' ' || line[end] == '\t') { + end++ + } + } + } else if line[end] == ' ' || line[end] == '\t' { + // On whitespace: skip all leading whitespace + for end < len(line) && (line[end] == ' ' || line[end] == '\t') { + end++ + } + } else { + // On punctuation/other: skip those, then skip trailing spaces + for end < len(line) && !e.isWordChar(line[end]) && line[end] != ' ' && line[end] != '\t' { + end++ + } + if includeSpaces { + for end < len(line) && (line[end] == ' ' || line[end] == '\t') { + end++ + } + } + } + + // Copy to clipboard (only for primary cursor) + if c == b.PrimaryCursor() { + e.clipboard = make([]rune, end-start) + copy(e.clipboard, line[start:end]) + } + + // Delete from start to end + newLine := append(line[:start], line[end:]...) + b.buffer[c.Y] = newLine + + // Ensure cursor is within bounds + if c.X >= len(b.buffer[c.Y]) { + c.X = len(b.buffer[c.Y]) + if c.X < 0 { + c.X = 0 + } + } + + // Handle syntax update + if b.syntax != nil { + deletedBytes := uint32(len(string(line[start:end]))) + oldColBytes := b.getLineByteOffset(line, start) + newColBytes := b.getLineByteOffset(newLine, start) + b.handleEdit(c.Y, start, deletedBytes, 0, c.Y, oldColBytes+deletedBytes, c.Y, newColBytes) + } + } + + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + +func (e *Editor) deleteWordBackward() { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 || b.PrimaryCursor().Y >= len(b.buffer) { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + line := b.buffer[b.PrimaryCursor().Y] + if len(line) == 0 || b.PrimaryCursor().X == 0 { + return + } + + end := b.PrimaryCursor().X + start := end + + // 1. Skip whitespace going back + for start > 0 && (line[start-1] == ' ' || line[start-1] == '\t') { + start-- + } + + // 2. Determine type of character before whitespace (or at cursor if no whitespace) + if start > 0 { + r := line[start-1] + if e.isWordChar(r) { + // On word characters: skip word characters + for start > 0 && e.isWordChar(line[start-1]) { + start-- + } + } else { + // On punctuation/other: skip those + for start > 0 && !e.isWordChar(line[start-1]) && line[start-1] != ' ' && line[start-1] != '\t' { + start-- + } + } + } + + // Delete from start to end + newLine := append(line[:start], line[end:]...) + b.buffer[b.PrimaryCursor().Y] = newLine + b.PrimaryCursor().X = start + + // Handle syntax update + if b.syntax != nil { + deletedBytes := uint32(len(string(line[start:end]))) + oldColBytes := b.getLineByteOffset(line, start) + newColBytes := b.getLineByteOffset(newLine, start) + b.handleEdit(b.PrimaryCursor().Y, start, deletedBytes, 0, b.PrimaryCursor().Y, oldColBytes+deletedBytes, b.PrimaryCursor().Y, newColBytes) + } + + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + +// deleteWordBackwardFromBuffer removes the last word from the commandBuffer at cursor position. +func (e *Editor) deleteWordBackwardFromBuffer() { + if e.commandCursorX == 0 { + return + } + + start := e.commandCursorX + + // Skip trailing whitespace. + for start > 0 && (e.commandBuffer[start-1] == ' ' || e.commandBuffer[start-1] == '\t') { + start-- + } + + // Delete word characters or punctuation. + if start > 0 { + r := e.commandBuffer[start-1] + if e.isWordChar(r) { + // Delete word characters. + for start > 0 && e.isWordChar(e.commandBuffer[start-1]) { + start-- + } + } else { + // Delete punctuation/other characters. + for start > 0 && !e.isWordChar(e.commandBuffer[start-1]) && e.commandBuffer[start-1] != ' ' && e.commandBuffer[start-1] != '\t' { + start-- + } + } + } + + // Remove the word from the buffer + e.commandBuffer = append(e.commandBuffer[:start], e.commandBuffer[e.commandCursorX:]...) + e.commandCursorX = start +} + +func (e *Editor) changeWord() { + b := e.activeBuffer() + if b != nil && b.readOnly { + e.message = "File is read-only" + return + } + e.deleteWord(false) + e.mode = ModeInsert +} + +func (e *Editor) changeCharacter() { + b := e.activeBuffer() + if b != nil && b.readOnly { + e.message = "File is read-only" + return + } + e.DeleteChar() + e.mode = ModeInsert +} + +func (e *Editor) deleteToEndOfLine() { + b := e.activeBuffer() + if b == nil { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + cursors := e.getSortedCursorsDesc() + for _, c := range cursors { + if c.Y >= len(b.buffer) { + continue + } + + line := b.buffer[c.Y] + if c.X >= len(line) { + continue + } + + // Save deleted text of the primary cursor to the clipboard + if c == b.PrimaryCursor() { + deletedText := line[c.X:] + e.clipboard = make([]rune, len(deletedText)) + copy(e.clipboard, deletedText) + } + + // Truncate the line at the cursor position + deletedBytes := uint32(len(string(line[c.X:]))) + newLine := line[:c.X] + b.buffer[c.Y] = newLine + + // Handle syntax update + if b.syntax != nil { + oldColBytes := b.getLineByteOffset(line, c.X) + newColBytes := b.getLineByteOffset(newLine, c.X) + b.handleEdit(c.Y, c.X, deletedBytes, 0, c.Y, oldColBytes+deletedBytes, c.Y, newColBytes) + } + } + + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + +func (e *Editor) changeToEndOfLine() { + b := e.activeBuffer() + if b != nil && b.readOnly { + e.message = "File is read-only" + return + } + e.deleteToEndOfLine() + e.mode = ModeInsert +} + +// deleteInside removes text within a pair of delimiters (e.g., "", (), {}). +func (e *Editor) deleteInside(open, close rune) bool { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return false + } + if b.readOnly { + e.message = "File is read-only" + return false + } + line := b.buffer[b.PrimaryCursor().Y] + if len(line) == 0 { + return false + } + + type pair struct { + start, end int + } + var pairs []pair + + // Find all candidate delimiter pairs on the current line. + if open == close { + var indices []int + for i, r := range line { + if r == open { + indices = append(indices, i) + } + } + for i := 0; i+1 < len(indices); i += 2 { + pairs = append(pairs, pair{indices[i], indices[i+1]}) + } + } else { + var stack []int + for i, r := range line { + if r == open { + stack = append(stack, i) + } else if r == close { + if len(stack) > 0 { + start := stack[len(stack)-1] + stack = stack[:len(stack)-1] + pairs = append(pairs, pair{start, i}) + } + } + } + } + + // Find the smallest pair that strictly contains the cursor. + var bestPair *pair + for i := range pairs { + p := &pairs[i] + if b.PrimaryCursor().X >= p.start && b.PrimaryCursor().X <= p.end { + if bestPair == nil || (p.start > bestPair.start) { + bestPair = p + } + } + } + + if bestPair == nil { + for i := range pairs { + p := &pairs[i] + if p.start >= b.PrimaryCursor().X { + if bestPair == nil || p.start < bestPair.start { + bestPair = p + } + } + } + } + + if bestPair != nil && bestPair.end > bestPair.start+1 { + start := bestPair.start + end := bestPair.end + deletedChars := line[start+1 : end] + deletedBytes := uint32(len(string(deletedChars))) + + newLine := append(line[:start+1], line[end:]...) + b.buffer[b.PrimaryCursor().Y] = newLine + b.PrimaryCursor().X = start + 1 + + if b.syntax != nil { + oldColBytes := b.getLineByteOffset(line, start+1) + newColBytes := b.getLineByteOffset(newLine, start+1) + b.handleEdit(b.PrimaryCursor().Y, start+1, deletedBytes, 0, b.PrimaryCursor().Y, oldColBytes+deletedBytes, b.PrimaryCursor().Y, newColBytes) + } + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() + return true + } + return false +} + +func (e *Editor) changeInside(open, close rune) { + if e.deleteInside(open, close) { + e.mode = ModeInsert + } +} + +func (e *Editor) moveWordForward() { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return + } + + // Helper to get char type: 0=space, 1=word, 2=punct + getType := func(r rune) int { + if r == ' ' || r == '\t' { + return 0 + } + if e.isWordChar(r) { + return 1 + } + return 2 + } + + // Process each cursor independently + for i := range b.cursors { + cursor := &b.cursors[i] + + if cursor.Y >= len(b.buffer) { + cursor.Y = len(b.buffer) - 1 + } + + currentLine := b.buffer[cursor.Y] + + // 1. Skip current word/punct clump + if cursor.X < len(currentLine) { + startType := getType(currentLine[cursor.X]) + if startType != 0 { + for cursor.X < len(currentLine) { + if getType(currentLine[cursor.X]) != startType { + break + } + cursor.X++ + } + } + } + + // 2. Skip whitespace + for { + // If at end of line, move to next line + if cursor.X >= len(b.buffer[cursor.Y]) { + if cursor.Y < len(b.buffer)-1 { + cursor.Y++ + cursor.X = 0 + // Continue loop to check new line content + } else { + break // End of file + } + } + + line := b.buffer[cursor.Y] + if len(line) == 0 { + // Empty line, continue to next + if cursor.Y < len(b.buffer)-1 { + // We need to advance line manually here if we are on empty line + // but only if we haven't just moved to it (which is handled by loop re-entry) + // Actually, the check at top of loop handles line length check. + // If line is empty, len is 0. + // We just need to check if we are stuck. + // If we are at X=0 on empty line, we should move to next line. + + // Let's rely on the loop condition: + // if X >= len, it moves to next line. + // if line is empty, len is 0. So X=0 >= 0 is true. + // So it moves to next line immediately. + // But we need to break if we found a word? No, empty line is not a word start. + // So we continue. + } else { + break // EOF + } + } else { + c := line[cursor.X] + if c == ' ' || c == '\t' { + cursor.X++ + continue + } + + // Found start of next word + break + } + } + + // Update preferred column + cursor.PreferredCol = cursor.X + } + + e.mergeCursors() +} + +func (e *Editor) moveWordBackward() { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return + } + + for i := range b.cursors { + cursor := &b.cursors[i] + + // Helper to step back one char, wrapping lines + stepBack := func() bool { + if cursor.X > 0 { + cursor.X-- + return true + } + if cursor.Y > 0 { + cursor.Y-- + cursor.X = len(b.buffer[cursor.Y]) + if cursor.X > 0 { + cursor.X-- + } + return true + } + return false // Start of file + } + + // 1. Move back 1 char initially + if !stepBack() { + continue + } + + // 2. Skip whitespace going back + for { + line := b.buffer[cursor.Y] + if len(line) == 0 { + if !stepBack() { + break + } + continue + } + + c := line[cursor.X] + if c == ' ' || c == '\t' { + if !stepBack() { + break + } + continue + } + break + } + + // 3. We are on last char of a "word". Go to its start. + line := b.buffer[cursor.Y] + getType := func(r rune) int { + if e.isWordChar(r) { + return 1 + } + return 2 + } + + if cursor.X < len(line) { + targetType := getType(line[cursor.X]) + + for cursor.X > 0 { + prev := line[cursor.X-1] + if prev == ' ' || prev == '\t' { + break + } + if getType(prev) != targetType { + break + } + cursor.X-- + } + } + + cursor.PreferredCol = cursor.X + } + + e.mergeCursors() +} + +// deleteLine removes the current line and saves it to the clipboard. +func (e *Editor) deleteLine() { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + line := b.buffer[b.PrimaryCursor().Y] + e.clipboard = make([]rune, len(line)+1) + copy(e.clipboard, line) + e.clipboard[len(line)] = '\n' + + if len(b.buffer) == 1 { + lineLen := uint32(len(string(b.buffer[0]))) + b.buffer[0] = []rune{} + b.PrimaryCursor().X = 0 + + if b.syntax != nil { + b.handleEdit(0, 0, lineLen, 0, 0, lineLen, 0, 0) + } + } else { + lineLen := uint32(len(string(b.buffer[b.PrimaryCursor().Y]))) + 1 + b.buffer = append(b.buffer[:b.PrimaryCursor().Y], b.buffer[b.PrimaryCursor().Y+1:]...) + + if b.syntax != nil { + b.handleEdit(b.PrimaryCursor().Y, 0, lineLen, 0, b.PrimaryCursor().Y+1, 0, b.PrimaryCursor().Y, 0) + } + + if b.PrimaryCursor().Y >= len(b.buffer) { + b.PrimaryCursor().Y = len(b.buffer) - 1 + } + b.PrimaryCursor().X = 0 + } + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + +func (e *Editor) yankLine() { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return + } + line := b.buffer[b.PrimaryCursor().Y] + e.clipboard = make([]rune, len(line)+1) + copy(e.clipboard, line) + e.clipboard[len(line)] = '\n' +} + +func (e *Editor) pasteLine() { + b := e.activeBuffer() + if b == nil || len(e.clipboard) == 0 { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + isLineWise := e.clipboard[len(e.clipboard)-1] == '\n' + + if isLineWise { + content := e.clipboard[:len(e.clipboard)-1] + parts := strings.Split(string(content), "\n") + count := len(parts) + + newBuffer := make([][]rune, len(b.buffer)+count) + copy(newBuffer[:b.PrimaryCursor().Y+1], b.buffer[:b.PrimaryCursor().Y+1]) + + for i, part := range parts { + newBuffer[b.PrimaryCursor().Y+1+i] = []rune(part) + } + + copy(newBuffer[b.PrimaryCursor().Y+1+count:], b.buffer[b.PrimaryCursor().Y+1:]) + b.buffer = newBuffer + + b.PrimaryCursor().Y += count + b.PrimaryCursor().X = 0 + } else { + // Character-wise: paste after cursor + fullText := string(e.clipboard) + parts := strings.Split(fullText, "\n") + + if len(parts) == 1 { + line := b.buffer[b.PrimaryCursor().Y] + at := b.PrimaryCursor().X + if len(line) > 0 { + at++ + } + if at > len(line) { + at = len(line) + } + + newLine := make([]rune, len(line)+len(e.clipboard)) + copy(newLine[:at], line[:at]) + copy(newLine[at:], e.clipboard) + copy(newLine[at+len(e.clipboard):], line[at:]) + b.buffer[b.PrimaryCursor().Y] = newLine + b.PrimaryCursor().X = at + len(e.clipboard) - 1 + if b.PrimaryCursor().X < 0 { + b.PrimaryCursor().X = 0 + } + } else { + // Multi-line character-wise paste after cursor + line := b.buffer[b.PrimaryCursor().Y] + at := b.PrimaryCursor().X + if len(line) > 0 { + at++ + } + if at > len(line) { + at = len(line) + } + + prefix := line[:at] + suffix := line[at:] + + newLines := make([][]rune, len(parts)) + newLines[0] = append([]rune(nil), prefix...) + newLines[0] = append(newLines[0], []rune(parts[0])...) + + for i := 1; i < len(parts)-1; i++ { + newLines[i] = []rune(parts[i]) + } + + lastIndex := len(parts) - 1 + newLines[lastIndex] = []rune(parts[lastIndex]) + newLines[lastIndex] = append(newLines[lastIndex], suffix...) + + // Insert into buffer + newBuffer := make([][]rune, len(b.buffer)+len(parts)-1) + copy(newBuffer[:b.PrimaryCursor().Y], b.buffer[:b.PrimaryCursor().Y]) + copy(newBuffer[b.PrimaryCursor().Y:b.PrimaryCursor().Y+len(parts)], newLines) + copy(newBuffer[b.PrimaryCursor().Y+len(parts):], b.buffer[b.PrimaryCursor().Y+1:]) + b.buffer = newBuffer + + // Move cursor to end of pasted text + b.PrimaryCursor().Y = b.PrimaryCursor().Y + len(parts) - 1 + b.PrimaryCursor().X = len([]rune(parts[lastIndex])) + } + } + e.markModified() + + if b.syntax != nil { + b.syntax.Parse([]byte(b.toString())) + } +} + +func (e *Editor) pasteLineAbove() { + b := e.activeBuffer() + if b == nil || len(e.clipboard) == 0 { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + isLineWise := e.clipboard[len(e.clipboard)-1] == '\n' + + if isLineWise { + content := e.clipboard[:len(e.clipboard)-1] + parts := strings.Split(string(content), "\n") + count := len(parts) + + newBuffer := make([][]rune, len(b.buffer)+count) + copy(newBuffer[:b.PrimaryCursor().Y], b.buffer[:b.PrimaryCursor().Y]) + + for i, part := range parts { + newBuffer[b.PrimaryCursor().Y+i] = []rune(part) + } + + copy(newBuffer[b.PrimaryCursor().Y+count:], b.buffer[b.PrimaryCursor().Y:]) + b.buffer = newBuffer + + b.PrimaryCursor().X = 0 + } else { + // Character-wise: paste at cursor + // Handle potential newlines in character-wise clipboard (e.g. from visual selection) + fullText := string(e.clipboard) + parts := strings.Split(fullText, "\n") + + if len(parts) == 1 { + // Single line character-wise paste + line := b.buffer[b.PrimaryCursor().Y] + at := b.PrimaryCursor().X + if at > len(line) { + at = len(line) + } + + newLine := make([]rune, len(line)+len(e.clipboard)) + copy(newLine[:at], line[:at]) + copy(newLine[at:], e.clipboard) + copy(newLine[at+len(e.clipboard):], line[at:]) + b.buffer[b.PrimaryCursor().Y] = newLine + b.PrimaryCursor().X = at + len(e.clipboard) - 1 + if b.PrimaryCursor().X < 0 { + b.PrimaryCursor().X = 0 + } + } else { + // Multi-line character-wise paste + line := b.buffer[b.PrimaryCursor().Y] + prefix := line[:b.PrimaryCursor().X] + suffix := line[b.PrimaryCursor().X:] + + newLines := make([][]rune, len(parts)) + newLines[0] = append([]rune(nil), prefix...) + newLines[0] = append(newLines[0], []rune(parts[0])...) + + for i := 1; i < len(parts)-1; i++ { + newLines[i] = []rune(parts[i]) + } + + lastIndex := len(parts) - 1 + newLines[lastIndex] = []rune(parts[lastIndex]) + newLines[lastIndex] = append(newLines[lastIndex], suffix...) + + // Insert into buffer + newBuffer := make([][]rune, len(b.buffer)+len(parts)-1) + copy(newBuffer[:b.PrimaryCursor().Y], b.buffer[:b.PrimaryCursor().Y]) + copy(newBuffer[b.PrimaryCursor().Y:b.PrimaryCursor().Y+len(parts)], newLines) + copy(newBuffer[b.PrimaryCursor().Y+len(parts):], b.buffer[b.PrimaryCursor().Y+1:]) + b.buffer = newBuffer + + // Move cursor to end of pasted text + b.PrimaryCursor().Y = b.PrimaryCursor().Y + len(parts) - 1 + b.PrimaryCursor().X = len([]rune(parts[lastIndex])) + } + } + e.markModified() + + if b.syntax != nil { + b.syntax.Parse([]byte(b.toString())) + } +} + +func (e *Editor) duplicateLine() { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + line := make([]rune, len(b.buffer[b.PrimaryCursor().Y])) + copy(line, b.buffer[b.PrimaryCursor().Y]) + + newBuffer := make([][]rune, len(b.buffer)+1) + copy(newBuffer[:b.PrimaryCursor().Y+1], b.buffer[:b.PrimaryCursor().Y+1]) + newBuffer[b.PrimaryCursor().Y+1] = line + copy(newBuffer[b.PrimaryCursor().Y+2:], b.buffer[b.PrimaryCursor().Y+1:]) + b.buffer = newBuffer + + b.PrimaryCursor().Y++ + e.markModified() + + if b.syntax != nil { + b.syntax.Parse([]byte(b.toString())) + } +} + +func (e *Editor) jumpToPrevEmptyLine() { + e.pushJump() + b := e.activeBuffer() + if b == nil { + return + } + // Search backwards from current line for an empty line + for y := b.PrimaryCursor().Y - 1; y >= 0; y-- { + if len(b.buffer[y]) == 0 { + b.PrimaryCursor().Y = y + b.PrimaryCursor().X = 0 + return + } + } + e.jumpToTop() +} + +func (e *Editor) jumpToNextEmptyLine() { + e.pushJump() + b := e.activeBuffer() + if b == nil { + return + } + // Search forwards from current line for an empty line + for y := b.PrimaryCursor().Y + 1; y < len(b.buffer); y++ { + if len(b.buffer[y]) == 0 { + b.PrimaryCursor().Y = y + b.PrimaryCursor().X = 0 + return + } + } + e.jumpToBottom() +} + +func (e *Editor) jumpToTop() { + e.pushJump() + b := e.activeBuffer() + if b == nil { + return + } + b.PrimaryCursor().Y = 0 + b.PrimaryCursor().X = 0 +} + +func (e *Editor) jumpToBottom() { + e.pushJump() + b := e.activeBuffer() + if b == nil { + return + } + b.PrimaryCursor().Y = len(b.buffer) - 1 + if b.PrimaryCursor().Y < 0 { + b.PrimaryCursor().Y = 0 + } + b.PrimaryCursor().X = 0 +} + +func (e *Editor) jumpToLineEnd() { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return + } + b.PrimaryCursor().X = len(b.buffer[b.PrimaryCursor().Y]) +} + +func (e *Editor) jumpToLineStart() { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return + } + b.PrimaryCursor().X = 0 +} + +func (e *Editor) jumpToFirstNonBlank() { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return + } + line := b.buffer[b.PrimaryCursor().Y] + b.PrimaryCursor().X = 0 + for i, r := range line { + if r != ' ' && r != '\t' { + b.PrimaryCursor().X = i + break + } + } +} + +// saveState captures a deep copy of the current buffer and cursors for the undo stack. +func (e *Editor) saveState() { + b := e.activeBuffer() + if b == nil { + return + } + // Deep copy the buffer to ensure historical states aren't mutated. + bufferCopy := make([][]rune, len(b.buffer)) + for i, line := range b.buffer { + lineCopy := make([]rune, len(line)) + copy(lineCopy, line) + bufferCopy[i] = lineCopy + } + + // Deep copy cursors. + cursorsCopy := make([]Cursor, len(b.cursors)) + copy(cursorsCopy, b.cursors) + + b.undoStack = append(b.undoStack, HistoryState{ + buffer: bufferCopy, + cursors: cursorsCopy, + }) + // Cap undo stack at 100 entries to prevent memory exhaustion. + if len(b.undoStack) > 100 { + b.undoStack = b.undoStack[1:] + } + // Clear the redo stack whenever a new action is performed. + b.redoStack = []HistoryState{} +} + +func (e *Editor) undo() { + b := e.activeBuffer() + if b == nil || len(b.undoStack) == 0 { + return + } + + // Save current state to redo stack + bufferCopy := make([][]rune, len(b.buffer)) + for i, line := range b.buffer { + lineCopy := make([]rune, len(line)) + copy(lineCopy, line) + bufferCopy[i] = lineCopy + } + cursorsCopy := make([]Cursor, len(b.cursors)) + copy(cursorsCopy, b.cursors) + + b.redoStack = append(b.redoStack, HistoryState{ + buffer: bufferCopy, + cursors: cursorsCopy, + }) + + // Restore from undo stack + state := b.undoStack[len(b.undoStack)-1] + b.undoStack = b.undoStack[:len(b.undoStack)-1] + b.buffer = state.buffer + b.cursors = state.cursors + + if b.syntax != nil { + b.syntax.Parse([]byte(b.toString())) + } +} + +func (e *Editor) redo() { + b := e.activeBuffer() + if b == nil || len(b.redoStack) == 0 { + return + } + + // Save current state to undo stack + bufferCopy := make([][]rune, len(b.buffer)) + for i, line := range b.buffer { + lineCopy := make([]rune, len(line)) + copy(lineCopy, line) + bufferCopy[i] = lineCopy + } + cursorsCopy := make([]Cursor, len(b.cursors)) + copy(cursorsCopy, b.cursors) + + b.undoStack = append(b.undoStack, HistoryState{ + buffer: bufferCopy, + cursors: cursorsCopy, + }) + + // Restore from redo stack + state := b.redoStack[len(b.redoStack)-1] + b.redoStack = b.redoStack[:len(b.redoStack)-1] + b.buffer = state.buffer + b.cursors = state.cursors + + if b.syntax != nil { + b.syntax.Parse([]byte(b.toString())) + } +} + +// JoinLines joins the current line with the next one. +func (e *Editor) JoinLines() { + b := e.activeBuffer() + if b == nil || len(b.buffer) <= 1 { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + cursor := b.PrimaryCursor() + if cursor.Y >= len(b.buffer)-1 { + return // Last line, nothing to join + } + + currentLine := b.buffer[cursor.Y] + nextLine := b.buffer[cursor.Y+1] + + // Trim leading whitespace from next line + trimIdx := 0 + for trimIdx < len(nextLine) && (nextLine[trimIdx] == ' ' || nextLine[trimIdx] == '\t') { + trimIdx++ + } + trimmedNextLine := nextLine[trimIdx:] + + // Determine if we need a space between lines + needsSpace := true + if len(currentLine) == 0 || (len(currentLine) > 0 && currentLine[len(currentLine)-1] == ' ') { + needsSpace = false + } + if len(trimmedNextLine) == 0 { + needsSpace = false + } + + // Join lines + newLine := make([]rune, 0, len(currentLine)+len(trimmedNextLine)+1) + newLine = append(newLine, currentLine...) + if needsSpace { + newLine = append(newLine, ' ') + } + newLine = append(newLine, trimmedNextLine...) + + // Update buffer + b.buffer[cursor.Y] = newLine + b.buffer = append(b.buffer[:cursor.Y+1], b.buffer[cursor.Y+2:]...) + + // Set cursor position to the join point + cursor.X = len(currentLine) + if needsSpace { + // Vim usually puts cursor on the space + } else if cursor.X >= len(newLine) && len(newLine) > 0 { + cursor.X = len(newLine) - 1 + } + + // Syntax update + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + e.markModified() +} + +// getSelectionBounds returns the normalized coordinates (top-left to bottom-right) of the visual selection. +func (e *Editor) getSelectionBounds() (int, int, int, int) { + b := e.activeBuffer() + y1, x1, y2, x2 := e.visualStartY, e.visualStartX, b.PrimaryCursor().Y, b.PrimaryCursor().X + + // Normalize so (y1, x1) is always the "earlier" point in the file. + if y1 > y2 || (y1 == y2 && x1 > x2) { + y1, x1, y2, x2 = y2, x2, y1, x1 + } + + // Force line-wise bounds if in Visual Line mode. + if e.mode == ModeVisualLine { + x1 = 0 + if y2 < len(b.buffer) { + x2 = len(b.buffer[y2]) + if x2 > 0 { + x2-- // last character index + } else { + x2 = 0 + } + } + } + + return y1, x1, y2, x2 +} + +// ollamaComplete sends the selection to the Ollama AI and replaces it with the generated text. +func (e *Editor) ollamaComplete() { + b := e.activeBuffer() + if b == nil { + return + } + if e.ollamaClient == nil || !e.ollamaClient.IsOnline { + e.message = "Ollama is offline" + return + } + + y1, x1, y2, x2 := e.getSelectionBounds() + + // Extract selected text for the prompt. + var selectedText strings.Builder + for y := y1; y <= y2; y++ { + line := b.buffer[y] + if y == y1 && y == y2 { + if x1 < len(line) { + end := x2 + 1 + if end > len(line) { + end = len(line) + } + selectedText.WriteString(string(line[x1:end])) + } + } else if y == y1 { + if x1 < len(line) { + selectedText.WriteString(string(line[x1:])) + } + selectedText.WriteRune('\n') + } else if y == y2 { + end := x2 + 1 + if end > len(line) { + end = len(line) + } + selectedText.WriteString(string(line[:end])) + } else { + selectedText.WriteString(string(line)) + selectedText.WriteRune('\n') + } + } + + prompt := selectedText.String() + if prompt == "" { + return + } + + // Read system prompt (template) from the embedded assets. + instr, err := ContentFS.ReadFile("content/ollama.txt") + if err == nil { + prompt += "\n" + string(instr) + } + + firstLine := strings.Split(prompt, "\n")[0] + if len(firstLine) > 50 { + firstLine = firstLine[:47] + "..." + } + e.message = fmt.Sprintf("Ollama is thinking about: %s", firstLine) + e.draw() + + // Call the Ollama API. + response, err := e.ollamaClient.Generate(prompt) + if err != nil { + e.message = fmt.Sprintf("Ollama error: %v", err) + return + } + + // Replace the visual selection with the AI's response. + e.saveState() + e.deleteVisualSelection() + + lines := strings.Split(strings.TrimSpace(response), "\n") + + at := b.PrimaryCursor().X + currentLine := b.buffer[b.PrimaryCursor().Y] + hasSuffix := at < len(currentLine) + + nextExists := b.PrimaryCursor().Y+1 < len(b.buffer) + nextIsBlank := false + if nextExists { + nextIsBlank = len(b.buffer[b.PrimaryCursor().Y+1]) == 0 + } + + // Add formatting newlines if necessary. + if hasSuffix { + lines = append(lines, "", "") + } else if !nextIsBlank { + lines = append(lines, "") + } + + if len(lines) == 1 { + line := b.buffer[b.PrimaryCursor().Y] + at := b.PrimaryCursor().X + if at > len(line) { + at = len(line) + } + + respRunes := []rune(lines[0]) + newLine := make([]rune, len(line)+len(respRunes)) + copy(newLine[:at], line[:at]) + copy(newLine[at:], respRunes) + copy(newLine[at+len(respRunes):], line[at:]) + b.buffer[b.PrimaryCursor().Y] = newLine + b.PrimaryCursor().X = at + len(respRunes) + } else { + line := b.buffer[b.PrimaryCursor().Y] + at := b.PrimaryCursor().X + if at > len(line) { + at = len(line) + } + + prefix := line[:at] + suffix := line[at:] + + newLines := make([][]rune, len(lines)) + for i, l := range lines { + newLines[i] = []rune(l) + } + + newLines[0] = append([]rune(string(prefix)), newLines[0]...) + newLines[len(newLines)-1] = append(newLines[len(newLines)-1], suffix...) + + newBuffer := make([][]rune, len(b.buffer)+len(newLines)-1) + copy(newBuffer[:b.PrimaryCursor().Y], b.buffer[:b.PrimaryCursor().Y]) + copy(newBuffer[b.PrimaryCursor().Y:], newLines) + copy(newBuffer[b.PrimaryCursor().Y+len(newLines):], b.buffer[b.PrimaryCursor().Y+1:]) + b.buffer = newBuffer + + b.PrimaryCursor().Y = b.PrimaryCursor().Y + len(newLines) - 1 + b.PrimaryCursor().X = len(newLines[len(newLines)-1]) - len(suffix) + } + + e.mode = ModeNormal + e.markModified() + e.message = "Ollama completion inserted (replaced selection)" + + if b.syntax != nil { + b.syntax.Parse([]byte(b.toString())) + } +} + +func (e *Editor) getSelection() []rune { + b := e.activeBuffer() + y1, x1, y2, x2 := e.getSelectionBounds() + var selection []rune + + for y := y1; y <= y2; y++ { + line := b.buffer[y] + start := 0 + end := len(line) + + if e.mode == ModeVisualBlock { + startX := x1 + endX := x2 + if startX > endX { + startX, endX = endX, startX + } + start = startX + end = endX + 1 // inclusive + } else if e.mode != ModeVisualLine { + if y == y1 { + start = x1 + } + if y == y2 { + end = x2 + 1 // inclusive + } + } + + if start > len(line) { + start = len(line) + } + if end > len(line) { + end = len(line) + } + + if start < end { + selection = append(selection, line[start:end]...) + } + + if (y < y2 || e.mode == ModeVisualLine) && e.mode != ModeVisualBlock { + selection = append(selection, '\n') + } else if e.mode == ModeVisualBlock && y < y2 { + selection = append(selection, '\n') + } + } + return selection +} + +func (e *Editor) deleteVisualSelection() { + b := e.activeBuffer() + y1, x1, y2, x2 := e.getSelectionBounds() + if b.readOnly { + e.message = "File is read-only" + return + } + + // Copy to clipboard + e.clipboard = e.getSelection() + + if e.mode == ModeVisualLine { + // Remove all selected lines + b.buffer = append(b.buffer[:y1], b.buffer[y2+1:]...) + if len(b.buffer) == 0 { + b.buffer = [][]rune{{}} + } + if y1 >= len(b.buffer) { + y1 = len(b.buffer) - 1 + } + b.PrimaryCursor().Y = y1 + b.PrimaryCursor().X = 0 + } else if e.mode == ModeVisualBlock { + startX := x1 + endX := x2 + if startX > endX { + startX, endX = endX, startX + } + + for y := y1; y <= y2; y++ { + if y < len(b.buffer) { + line := b.buffer[y] + s := startX + e := endX + 1 + if s > len(line) { + s = len(line) + } + if e > len(line) { + e = len(line) + } + + if s < e { + newLine := append(line[:s], line[e:]...) + b.buffer[y] = newLine + } + } + } + b.PrimaryCursor().Y = y1 + b.PrimaryCursor().X = startX + } else { + // Modify buffer for character-wise selection + line1 := b.buffer[y1] + line2 := b.buffer[y2] + + prefix := make([]rune, x1) + copy(prefix, line1[:x1]) + + suffix := []rune{} + if x2+1 < len(line2) { + suffix = make([]rune, len(line2)-(x2+1)) + copy(suffix, line2[x2+1:]) + } + + newLine := append(prefix, suffix...) + b.buffer[y1] = newLine + + // Remove lines between + if y1 != y2 { + b.buffer = append(b.buffer[:y1+1], b.buffer[y2+1:]...) + } + + b.PrimaryCursor().Y = y1 + b.PrimaryCursor().X = x1 + } + + e.mode = ModeNormal + e.markModified() + + if b.syntax != nil { + b.syntax.Parse([]byte(b.toString())) + } +} + +func (e *Editor) yankVisualSelection() { + e.clipboard = e.getSelection() + e.mode = ModeNormal +} + +func (e *Editor) changeVisualSelection() { + b := e.activeBuffer() + if b != nil && b.readOnly { + e.message = "File is read-only" + return + } + e.deleteVisualSelection() + e.mode = ModeInsert +} + +func (e *Editor) pasteVisualSelection() { + if len(e.clipboard) == 0 { + return + } + b := e.activeBuffer() + if b != nil && b.readOnly { + e.message = "File is read-only" + return + } + // Save clipboard because deleteVisualSelection overwrites it + tmpClipboard := make([]rune, len(e.clipboard)) + copy(tmpClipboard, e.clipboard) + + e.deleteVisualSelection() + + // Restore clipboard and paste + e.clipboard = tmpClipboard + e.pasteLineAbove() +} + +func (e *Editor) toggleComment(y int) { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 || b.fileType == nil || b.fileType.Comment == "" { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + if y < 0 || y >= len(b.buffer) { + return + } + + line := b.buffer[y] + if len(line) == 0 { + return + } + + comment := []rune(b.fileType.Comment) + + // Check if already commented at the beginning of the line + isCommented := false + if len(line) >= len(comment) { + match := true + for i, r := range comment { + if line[i] != r { + match = false + break + } + } + isCommented = match + } + + var newLine []rune + if isCommented { + // Uncomment + contentStart := len(comment) + // Skip optional following space + if contentStart < len(line) && line[contentStart] == ' ' { + contentStart++ + } + newLine = append(newLine, line[contentStart:]...) + } else { + // Comment + newLine = append(newLine, comment...) + newLine = append(newLine, ' ') + newLine = append(newLine, line...) + } + + b.buffer[y] = newLine + e.markModified() + + if b.syntax != nil { + b.syntax.Parse([]byte(b.toString())) + } +} + +func (e *Editor) toggleCommentLine() { + b := e.activeBuffer() + if b != nil { + e.toggleComment(b.PrimaryCursor().Y) + } +} + +func (e *Editor) commentVisualSelection() { + y1, _, y2, _ := e.getSelectionBounds() + for y := y1; y <= y2; y++ { + e.toggleComment(y) + } + e.mode = ModeNormal +} + +func (e *Editor) toggleCase(y, x int) (int, int) { + b := e.activeBuffer() + if b == nil || y < 0 || y >= len(b.buffer) { + return y, x + } + line := b.buffer[y] + if x < 0 || x >= len(line) { + return y, x + } + + r := line[x] + if unicode.IsLower(r) { + line[x] = unicode.ToUpper(r) + } else if unicode.IsUpper(r) { + line[x] = unicode.ToLower(r) + } + + // Move cursor right + newX := x + 1 + if newX >= len(line) { + newX = len(line) - 1 + if newX < 0 { + newX = 0 + } + } + + return y, newX +} + +func (e *Editor) ToggleCaseUnderCursor() { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + e.saveState() + b.PrimaryCursor().Y, b.PrimaryCursor().X = e.toggleCase(b.PrimaryCursor().Y, b.PrimaryCursor().X) + e.markModified() + + if b.syntax != nil { + b.syntax.Parse([]byte(b.toString())) + } +} + +func (e *Editor) ToggleCaseVisualSelection() { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + e.saveState() + y1, x1, y2, x2 := e.getSelectionBounds() + + for y := y1; y <= y2; y++ { + line := b.buffer[y] + start := 0 + end := len(line) - 1 + if y == y1 { + start = x1 + } + if y == y2 { + end = x2 + } + + for x := start; x <= end && x < len(line); x++ { + r := line[x] + if unicode.IsLower(r) { + line[x] = unicode.ToUpper(r) + } else if unicode.IsUpper(r) { + line[x] = unicode.ToLower(r) + } + } + } + + e.mode = ModeNormal + e.markModified() + + if b.syntax != nil { + b.syntax.Parse([]byte(b.toString())) + } +} + +// detectCommentPrefix checks if the given text starts with a known comment marker. +// Returns the comment prefix including trailing space if present, or empty string if not a comment. +// Uses Config.FormatterMarkers which can be customized in config.go. +func detectCommentPrefix(text string) string { + for _, marker := range Config.FormatterMarkers { + // Check for marker with space first (e.g., "// ") + if strings.HasPrefix(text, marker+" ") { + return marker + " " + } + // Then check for marker without space (e.g., "//") + if strings.HasPrefix(text, marker) { + return marker + } + } + return "" +} + +// formatText wraps text to 80 characters (gq-style formatting). +// It formats either the current line in normal mode or the selected lines in visual modes. +func (e *Editor) formatText() { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 { + return + } + if b.readOnly { + e.message = "File is read-only" + return + } + + var startLine, endLine int + + // Determine which lines to format based on current mode + if e.mode == ModeNormal { + // In normal mode, format only the current line + startLine = b.PrimaryCursor().Y + endLine = b.PrimaryCursor().Y + } else if e.mode == ModeVisual || e.mode == ModeVisualLine { + // In visual modes, format the selected lines + startLine = e.visualStartY + endLine = b.PrimaryCursor().Y + if startLine > endLine { + startLine, endLine = endLine, startLine + } + } else { + return + } + + e.saveState() + + const maxWidth = 80 + var newLines [][]rune + + // Process lines in groups (paragraphs) with the same indentation and comment prefix + lineIdx := startLine + for lineIdx <= endLine && lineIdx < len(b.buffer) { + line := b.buffer[lineIdx] + + // Handle empty lines + if len(line) == 0 { + newLines = append(newLines, []rune{}) + lineIdx++ + continue + } + + // Get leading whitespace (indentation) for this line + indent := 0 + for indent < len(line) && unicode.IsSpace(line[indent]) { + indent++ + } + indentStr := line[:indent] + + // Get the text content (without indentation) + content := line[indent:] + contentStr := string(content) + + // Detect comment markers after indentation + commentPrefix := detectCommentPrefix(contentStr) + commentPrefixRunes := []rune(commentPrefix) + + // Collect all consecutive lines with the same indentation and comment prefix + var paragraphText []string + paragraphStartIdx := lineIdx + + for lineIdx <= endLine && lineIdx < len(b.buffer) { + currentLine := b.buffer[lineIdx] + + // Stop at empty lines + if len(currentLine) == 0 { + break + } + + // Check if this line has the same indentation + currentIndent := 0 + for currentIndent < len(currentLine) && unicode.IsSpace(currentLine[currentIndent]) { + currentIndent++ + } + + if currentIndent != indent { + break + } + + // Check if this line has the same comment prefix + currentContent := currentLine[currentIndent:] + currentContentStr := string(currentContent) + currentCommentPrefix := detectCommentPrefix(currentContentStr) + + if currentCommentPrefix != commentPrefix { + break + } + + // Extract the actual text (after comment prefix) + textContent := currentContentStr + if len(commentPrefix) > 0 { + textContent = currentContentStr[len(commentPrefix):] + } + + paragraphText = append(paragraphText, strings.TrimSpace(textContent)) + lineIdx++ + } + + // Join all the text from the paragraph + fullText := strings.Join(paragraphText, " ") + + // Split into words + words := strings.Fields(fullText) + if len(words) == 0 { + // Just preserve the line structure if no words + for i := paragraphStartIdx; i < lineIdx; i++ { + newLines = append(newLines, b.buffer[i]) + } + continue + } + + // Now wrap the combined text + wrapWidth := maxWidth - indent - len(commentPrefixRunes) + if wrapWidth < 20 { + wrapWidth = 20 // Minimum wrap width to avoid infinite loops + } + + var wrappedLines [][]rune + currentLine := make([]rune, 0) + currentLine = append(currentLine, indentStr...) + currentLine = append(currentLine, commentPrefixRunes...) + + for i, word := range words { + wordRunes := []rune(word) + + // Calculate the length if we add this word + // Account for space before word (except for first word on a line) + spaceNeeded := 0 + if len(currentLine) > indent+len(commentPrefixRunes) { + spaceNeeded = 1 // Need a space before the word + } + + projectedLen := len(currentLine) + spaceNeeded + len(wordRunes) + + if projectedLen > maxWidth && len(currentLine) > indent+len(commentPrefixRunes) { + // Adding this word would exceed max width, so start a new line + wrappedLines = append(wrappedLines, currentLine) + currentLine = make([]rune, 0) + currentLine = append(currentLine, indentStr...) + currentLine = append(currentLine, commentPrefixRunes...) + currentLine = append(currentLine, wordRunes...) + } else { + // Add the word to the current line + if len(currentLine) > indent+len(commentPrefixRunes) { + currentLine = append(currentLine, ' ') + } + currentLine = append(currentLine, wordRunes...) + } + + // If this is the last word, add the current line + if i == len(words)-1 { + wrappedLines = append(wrappedLines, currentLine) + } + } + + newLines = append(newLines, wrappedLines...) + } + + // Replace the lines in the buffer + if len(newLines) > 0 { + newBuffer := make([][]rune, 0) + newBuffer = append(newBuffer, b.buffer[:startLine]...) + newBuffer = append(newBuffer, newLines...) + if endLine+1 < len(b.buffer) { + newBuffer = append(newBuffer, b.buffer[endLine+1:]...) + } + b.buffer = newBuffer + + // Adjust cursor position + if b.PrimaryCursor().Y > len(b.buffer)-1 { + b.PrimaryCursor().Y = len(b.buffer) - 1 + } + if b.PrimaryCursor().Y >= 0 && b.PrimaryCursor().X > len(b.buffer[b.PrimaryCursor().Y]) { + b.PrimaryCursor().X = len(b.buffer[b.PrimaryCursor().Y]) + } + } + + e.mode = ModeNormal + e.markModified() + + if b.syntax != nil { + b.syntax.Parse([]byte(b.toString())) + } + + e.message = "Text formatted" +} + +// performSearch performs a linear case-insensitive search for a query string. +func (e *Editor) performSearch(query string, forward bool) { + b := e.activeBuffer() + if b == nil || len(b.buffer) == 0 || query == "" { + return + } + + queryLower := strings.ToLower(query) + startY := b.PrimaryCursor().Y + startX := b.PrimaryCursor().X + + dir := 1 + if !forward { + dir = -1 + } + + y := startY + firstLoop := true + + // Loop through the entire buffer once. + for i := 0; i <= len(b.buffer); i++ { + line := string(b.buffer[y]) + lineLower := strings.ToLower(line) + + matches := []int{} + // Scan line for all occurrences. + for pos := 0; pos < len(lineLower); { + idx := strings.Index(lineLower[pos:], queryLower) + if idx == -1 { + break + } + matchPos := pos + idx + matches = append(matches, matchPos) + pos = matchPos + 1 + } + + if len(matches) > 0 { + if forward { + for _, m := range matches { + // Ensure we skip the current cursor position on the first line. + if firstLoop && m <= startX { + continue + } + b.PrimaryCursor().Y = y + b.PrimaryCursor().X = m + return + } + } else { + for j := len(matches) - 1; j >= 0; j-- { + m := matches[j] + if firstLoop && m >= startX { + continue + } + b.PrimaryCursor().Y = y + b.PrimaryCursor().X = m + return + } + } + } + + // Wrap around buffer boundaries. + y += dir + if y < 0 { + y = len(b.buffer) - 1 + } else if y >= len(b.buffer) { + y = 0 + } + + firstLoop = false + } +} + +func (e *Editor) findNext() { + e.pushJump() + e.performSearch(e.lastSearch, true) +} + +func (e *Editor) findPrev() { + e.pushJump() + e.performSearch(e.lastSearch, false) +} + +func (e *Editor) checkDiagnostics() { + b := e.activeBuffer() + if b == nil || b.lspClient == nil { + return + } + + e.addLog("LSP", "Checking diagnostics...") + + // Send current buffer content to LSP + content := e.bufferToString(b.buffer) + if err := b.lspClient.SendDidChange(content); err != nil { + e.addLog("LSP", fmt.Sprintf("didChange error: %v", err)) + return + } + + // Diagnostics will be updated asynchronously when clangd sends publishDiagnostics + // The background readMessages goroutine handles this automatically + // Get current diagnostics (may be from previous check) + b.diagnostics = b.lspClient.GetDiagnostics() + e.addLog("LSP", fmt.Sprintf("Current diagnostics: %d", len(b.diagnostics))) +} + +func (e *Editor) deleteCurrentBuffer() { + if len(e.buffers) == 0 { + return + } + + // Shutdown LSP client if active + b := e.activeBuffer() + if b != nil && b.lspClient != nil { + b.lspClient.Shutdown() + } + + // Remove the current buffer + e.buffers = append(e.buffers[:e.activeBufferIndex], e.buffers[e.activeBufferIndex+1:]...) + + // Adjust active buffer index + if len(e.buffers) == 0 { + // No more buffers, create an empty one + defaultType := fileTypes[len(fileTypes)-1] + e.buffers = append(e.buffers, &Buffer{ + buffer: [][]rune{{}}, + undoStack: []HistoryState{}, + redoStack: []HistoryState{}, + fileType: defaultType, + }) + e.activeBufferIndex = 0 + } else if e.activeBufferIndex >= len(e.buffers) { + e.activeBufferIndex = len(e.buffers) - 1 + } +} + +// drawStatusBar renders the bottom-aligned information bar showing file details and editor state. +func (e *Editor) drawStatusBar(statusY int) { + w, _ := termbox.Size() + b := e.activeBuffer() + if b == nil { + return + } + + modeStr := "UNKNOWN" + + // Fill background for the entire status line. + for x := 0; x < w; x++ { + fg, bg := GetThemeColor(ColorStatusBar) + termbox.SetCell(x, statusY, ' ', fg, bg) + } + + // Draw the primary mode indicator. + var fg, bg termbox.Attribute + switch e.mode { + case ModeInsert: + modeStr = "INSERT" + fg, bg = GetThemeColor(ColorInsertMode) + case ModeVisual, ModeVisualLine, ModeVisualBlock: + modeStr = "VISUAL" + fg, bg = GetThemeColor(ColorVisualMode) + case ModeFuzzy: + switch e.fuzzyType { + case FuzzyModeFile: + modeStr = "FILES" + fg, bg = GetThemeColor(ColorFuzzyModeFiles) + case FuzzyModeBuffer: + modeStr = "BUFFERS" + fg, bg = GetThemeColor(ColorFuzzyModeBuffers) + case FuzzyModeWarning: + modeStr = "WARNINGS" + fg, bg = GetThemeColor(ColorFuzzyModeWarnings) + default: + modeStr = "FUZZY" + fg, bg = GetThemeColor(ColorNormalMode) + } + default: + modeStr = "NORMAL" + fg, bg = GetThemeColor(ColorNormalMode) + } + + termbox.SetCell(0, statusY, ' ', fg, bg) + for i, r := range modeStr { + termbox.SetCell(i+1, statusY, r, fg, bg) + } + termbox.SetCell(len(modeStr)+1, statusY, ' ', fg, bg) + + // Draw filename and modification status. + fileStr := "[no file]" + if b.filename != "" { + fileStr = b.filename + } + if b.modified { + fileStr += " [+]" + } + if b.readOnly { + fileStr += " (read-only)" + } + fileX := len(modeStr) + 2 + 1 + for i, r := range fileStr { + fg, bg := GetThemeColor(ColorStatusBar) + termbox.SetCell(fileX+i, statusY, r, fg, bg) + } + + // Draw cursor coordinates and file metadata. + lineNum := b.PrimaryCursor().Y + 1 + visualCol := e.bufferToVisual(b.buffer[b.PrimaryCursor().Y], b.PrimaryCursor().X) + 1 + totalLines := len(b.buffer) + percent := 0 + if totalLines > 0 { + percent = (lineNum * 100) / totalLines + } + fileTypeStr := "text" + if b.fileType != nil { + fileTypeStr = strings.ToLower(b.fileType.Name) + } + statusRight := fmt.Sprintf("(%s) [%d/%d] %d,%d %d%% ", fileTypeStr, e.activeBufferIndex+1, len(e.buffers), lineNum, visualCol, percent) + rightPositionWidth := 6 + rightX := w - len(statusRight) - rightPositionWidth + for i, r := range statusRight { + fg, bg := GetThemeColor(ColorStatusBar) + termbox.SetCell(rightX+i, statusY, r, fg, bg) + } + + // Draw connectivity status for LSP and Ollama. + lspColor := ColorLSPStatusDisconnected + if b.lspClient != nil { + lspColor = ColorLSPStatusConnected + } + fgL, bgL := GetThemeColor(lspColor) + for i, r := range " L " { + termbox.SetCell(w-6+i, statusY, r, fgL, bgL) + } + + ollamaColor := ColorOllamaStatusDisconnected + if e.ollamaClient != nil && e.ollamaClient.IsOnline { + ollamaColor = ColorOllamaStatusConnected + } + fgO, bgO := GetThemeColor(ollamaColor) + for i, r := range " O " { + termbox.SetCell(w-3+i, statusY, r, fgO, bgO) + } +} + +func (e *Editor) drawCommandBar(cmdY int) { + w, _ := termbox.Size() + for x := 0; x < w; x++ { + fg, bg := GetThemeColor(ColorDefault) + termbox.SetCell(x, cmdY, ' ', fg, bg) + } + + prompt := "" + buffer := []rune{} + startX := 0 + if e.mode == ModeCommand { + prompt = ":" + buffer = e.commandBuffer + } else if e.mode == ModeFuzzy { + prompt = "> " + buffer = e.fuzzyBuffer + startX = 1 + } else if e.mode == ModeFind { + prompt = "/" + buffer = e.findBuffer + } else if e.mode == ModeReplace { + prompt = "replace: " + buffer = e.replaceInput + } else if e.message != "" { + // Draw transient message + for i, r := range e.message { + if i >= w { + break + } + fg, bg := GetThemeColor(ColorDefault) + termbox.SetCell(i, cmdY, r, fg, bg) + } + return + } else { + // Show LSP diagnostics when not in command/fuzzy/find mode + b := e.activeBuffer() + if b != nil && len(b.diagnostics) > 0 { + // Count errors and warnings + errorCount := 0 + for _, d := range b.diagnostics { + if d.Severity == 1 { // Error + errorCount++ + } + } + + // Show diagnostic summary + diagStr := "" + if errorCount > 0 { + diagStr = fmt.Sprintf("%d error(s): ", errorCount) + } else { + diagStr = fmt.Sprintf("%d diag(s): ", len(b.diagnostics)) + } + + // Add first error message (truncated if too long) + // Make it as long as possible as width of the terminal allows + if len(b.diagnostics) > 0 { + firstMsg := b.diagnostics[0].Message + maxMsgLen := w - len(diagStr) + if len(firstMsg) > maxMsgLen { + firstMsg = firstMsg[:maxMsgLen-3] + "..." + } + diagStr += firstMsg + } + + // Draw diagnostic text using theme colors + fg, _ := GetThemeColor(ColorDiagSummaryError) + if errorCount == 0 { + fg, _ = GetThemeColor(ColorDiagSummaryWarning) + } + + for i, r := range diagStr { + if i >= w { + break + } + _, bg := GetThemeColor(ColorDefault) + termbox.SetCell(i, cmdY, r, fg, bg) + } + return + } + } + + // Draw prompt + for i, r := range prompt { + fg, bg := GetThemeColor(ColorDefault) + termbox.SetCell(startX+i, cmdY, r, fg, bg) + } + + // Draw buffer content + for i, r := range buffer { + fg, bg := GetThemeColor(ColorDefault) + termbox.SetCell(startX+len(prompt)+i, cmdY, r, fg, bg) + } +} + +func (e *Editor) highlightLine(lineIdx int, line []rune) ([]termbox.Attribute, []termbox.Attribute) { + fgAttrs := make([]termbox.Attribute, len(line)) + bgAttrs := make([]termbox.Attribute, len(line)) + + // specific default color for text + defaultFg, defaultBg := GetThemeColor(ColorDefault) + + for i := range fgAttrs { + fgAttrs[i] = defaultFg + bgAttrs[i] = defaultBg + } + + b := e.activeBuffer() + if b != nil && b.syntax != nil { + attrs := b.syntax.Highlight(lineIdx, line) + // SyntaxHighlighter returns FG colors. + // We trust it to return a slice of the same length as line (or we handle potential mismatch if necessary, + // but the current implementation of Highlight seems to handle checks). + // Overwrite default FGs with syntax FGs where they differ from default? + // Or just replace entirely? syntax.Highlight initializes with defaultFg. + // So we can just use it. + fgAttrs = attrs + } + + return fgAttrs, bgAttrs +} + +func matchesKeyword(runes []rune, start int, keyword string) bool { + if start+len(keyword) > len(runes) { + return false + } + for i, ch := range keyword { + if runes[start+i] != ch { + return false + } + } + return true +} + +func isWordStart(line []rune, i int) bool { + if i == 0 { + return true + } + r := line[i-1] + // If previous char is not a word char, then this is start of word (assuming current is word char) + return !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') +} + +// draw is the main UI rendering loop. +func (e *Editor) draw() { + _, defaultBg := GetThemeColor(ColorDefault) + termbox.Clear(termbox.ColorDefault, defaultBg) + w, h := termbox.Size() + b := e.activeBuffer() + if b == nil { + termbox.Flush() + return + } + + textWidth := w - Config.GutterWidth + visibleHeight := h - 2 + if e.mode == ModeFuzzy { + visibleHeight = h - 2 - Config.FuzzyFinderHeight + } + + // Vertical scroll management. + if b.PrimaryCursor().Y < b.scrollY { + b.scrollY = b.PrimaryCursor().Y + } + if b.PrimaryCursor().Y >= b.scrollY+visibleHeight { + b.scrollY = b.PrimaryCursor().Y - visibleHeight + 1 + } + + // Horizontal scroll management. + visualCursorX := e.bufferToVisual(b.buffer[b.PrimaryCursor().Y], b.PrimaryCursor().X) + if visualCursorX < b.scrollX { + b.scrollX = visualCursorX + } + if visualCursorX >= b.scrollX+textWidth { + b.scrollX = visualCursorX - textWidth + 1 + } + + // Optimized mapping for faster cursor lookup during rendering. + cursorMap := make(map[int]map[int]bool) + for _, c := range b.cursors { + if _, ok := cursorMap[c.Y]; !ok { + cursorMap[c.Y] = make(map[int]bool) + } + cursorMap[c.Y][c.X] = true + } + + for screenY := 0; screenY < visibleHeight; screenY++ { + bufferY := screenY + b.scrollY + if bufferY < len(b.buffer) { + // LSP diagnostic sign rendering. + diagSign := ' ' + diagColor, diagBg := GetThemeColor(ColorDefault) + if b.diagnostics != nil { + for _, diag := range b.diagnostics { + if diag.Range.Start.Line == bufferY { + if diag.Severity == 1 { + diagSign = 'E' + diagColor, diagBg = GetThemeColor(ColorGutterSignError) + } else if diag.Severity == 2 && diagSign != 'E' { + diagSign = 'W' + diagColor, diagBg = GetThemeColor(ColorGutterSignWarning) + } else if diag.Severity == 3 && diagSign != 'E' { + diagSign = 'I' + diagColor, diagBg = GetThemeColor(ColorGutterSignInfo) + } else if diag.Severity == 4 && diagSign != 'E' { + diagSign = 'H' + diagColor, diagBg = GetThemeColor(ColorGutterSignHint) + } + } + } + } + + termbox.SetCell(0, screenY, diagSign, diagColor, diagBg) + termbox.SetCell(1, screenY, ' ', diagBg, diagBg) + + // Gutter line number rendering. + lineNum := strconv.Itoa(bufferY + 1) + gutterFg, gutterBg := GetThemeColor(ColorGutterLineNumber) + for i, r := range lineNum { + termbox.SetCell(Config.GutterWidth-len(lineNum)-1+i, screenY, r, gutterFg, gutterBg) + } + + // Text highlighting and rendering block. + var fgAttrs []termbox.Attribute + var bgAttrs []termbox.Attribute + if b.fileType != nil && b.fileType.Name != "Default" { + fgAttrs, bgAttrs = e.highlightLine(bufferY, b.buffer[bufferY]) + } else { + fgAttrs = make([]termbox.Attribute, len(b.buffer[bufferY])) + bgAttrs = make([]termbox.Attribute, len(b.buffer[bufferY])) + for k := range fgAttrs { + fgAttrs[k], bgAttrs[k] = GetThemeColor(ColorDefault) + } + } + + _, bg := GetThemeColor(ColorDefault) + if bufferY == b.PrimaryCursor().Y { + _, bg = GetThemeColor(ColorHighlightedLine) + for x := 0; x < textWidth; x++ { + fg, _ := GetThemeColor(ColorDefault) + termbox.SetCell(x+Config.GutterWidth, screenY, ' ', fg, bg) + } + } + + inVisual := e.mode == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock + var vStartY, vStartX, vEndY, vEndX int + if inVisual { + y1, x1, y2, x2 := e.getSelectionBounds() + vStartY, vStartX, vEndY, vEndX = y1, x1, y2, x2 + if e.mode == ModeVisualBlock { + if vStartX > vEndX { + vStartX, vEndX = vEndX, vStartX + } + } + } + + searchMatches := []bool{} + if e.lastSearch != "" { + searchMatches = make([]bool, len(b.buffer[bufferY])) + lineRunes := b.buffer[bufferY] + queryRunes := []rune(strings.ToLower(e.lastSearch)) + queryLen := len(queryRunes) + + for i := 0; i <= len(lineRunes)-queryLen; i++ { + match := true + for j := 0; j < queryLen; j++ { + if unicode.ToLower(lineRunes[i+j]) != queryRunes[j] { + match = false + break + } + } + if match { + for k := 0; k < queryLen; k++ { + searchMatches[i+k] = true + } + } + } + } + + visualX := 0 + for idx, r := range b.buffer[bufferY] { + width := e.visualWidth(r, visualX) + + charBg := bg + isVisualSelected := false + if inVisual { + if e.mode == ModeVisualBlock { + if bufferY >= vStartY && bufferY <= vEndY { + if idx >= vStartX && idx < vEndX+1 { + isVisualSelected = true + } + } + } else if e.mode == ModeVisualLine { + if bufferY >= vStartY && bufferY <= vEndY { + isVisualSelected = true + } + } else { + if bufferY > vStartY && bufferY < vEndY { + isVisualSelected = true + } else if bufferY == vStartY && bufferY == vEndY { + if idx >= vStartX && idx < vEndX+1 { + isVisualSelected = true + } + } else if bufferY == vStartY { + if idx >= vStartX { + isVisualSelected = true + } + } else if bufferY == vEndY { + if idx < vEndX+1 { + isVisualSelected = true + } + } + } + } + + isCursor := false + if cm, ok := cursorMap[bufferY]; ok { + if cm[idx] { + isCursor = true + } + } + + if isVisualSelected { + selFg, selBg := GetThemeColor(ColorVisualModeSelection) + charBg = selBg + if idx < len(fgAttrs) { + fgAttrs[idx] = selFg + } + } + + if isCursor { + charBg, fgAttrs[idx] = fgAttrs[idx], charBg + if charBg == fgAttrs[idx] { + _, forcedBg := GetThemeColor(ColorCursor) + charBg = forcedBg + fgAttrs[idx] = termbox.ColorWhite + } + } + + if !isVisualSelected && len(searchMatches) > idx && searchMatches[idx] { + searchMatchFg, searchMatchBg := GetThemeColor(ColorSearchMatch) + charBg = searchMatchBg + fgAttrs[idx] = searchMatchFg + } + + if e.mode == ModeReplace { + for _, match := range e.replaceMatches { + if match.startLine == bufferY && idx >= match.startCol && idx < match.endCol { + replaceMatchFg, replaceMatchBg := GetThemeColor(ColorReplaceMatch) + charBg = replaceMatchBg + fgAttrs[idx] = replaceMatchFg + break + } + } + } + + if !isVisualSelected && charBg == bg && len(bgAttrs) > idx && bgAttrs[idx] != defaultBg { + charBg = bgAttrs[idx] + } + + for i := 0; i < width; i++ { + screenX := visualX + i - b.scrollX + if screenX >= 0 && screenX < textWidth { + char := r + if r == '\t' { + char = ' ' + } + termbox.SetCell(screenX+Config.GutterWidth, screenY, char, fgAttrs[idx], charBg) + } + } + visualX += width + } + + if e.mode == ModeVisualLine && bufferY >= vStartY && bufferY <= vEndY { + _, visualModeLineBg := GetThemeColor(ColorVisualModeSelection) + for x := visualX - b.scrollX; x < textWidth; x++ { + if x >= 0 { + termbox.SetCell(x+Config.GutterWidth, screenY, ' ', termbox.ColorDefault, visualModeLineBg) + } + } + } + } else { + fg, bg := GetThemeColor(ColorEmptyLineMarker) + termbox.SetCell(0, screenY, '~', fg, bg) + } + } + + if !e.introDismissed && b.filename == "" && len(b.buffer) == 1 && len(b.buffer[0]) == 0 && !b.modified && e.mode != ModeInsert { + e.drawIntro() + } + + if e.mode == ModeFuzzy { + statusY := h - 2 - Config.FuzzyFinderHeight + fuzzyY := h - 1 - Config.FuzzyFinderHeight + cmdY := h - 1 + + e.drawStatusBar(statusY) + e.drawFuzzyFinder(fuzzyY, Config.FuzzyFinderHeight) + e.drawCommandBar(cmdY) + } else { + e.drawStatusBar(h - 2) + e.drawCommandBar(h - 1) + } + + if e.showDebugLog { + e.drawDebugDiagnostics() + } + + if e.showHover { + e.drawHoverPopup() + } + + if e.showAutocomplete { + e.drawAutocompletePopup() + } + + // Synchronize terminal cursor with editor focus. + if e.mode == ModeCommand { + termbox.SetCursor(e.commandCursorX+1, h-1) + } else if e.mode == ModeFuzzy { + termbox.SetCursor(len(e.fuzzyBuffer)+3, h-1) + } else if e.mode == ModeFind { + termbox.SetCursor(len(e.findBuffer)+1, h-1) + } else if e.mode == ModeReplace { + termbox.SetCursor(len(e.replaceInput)+9, h-1) + } else { + termbox.SetCursor(visualCursorX-b.scrollX+Config.GutterWidth, b.PrimaryCursor().Y-b.scrollY) + } + termbox.Flush() +} + +func (e *Editor) drawDebugDiagnostics() { + w, h := termbox.Size() + b := e.activeBuffer() + if b == nil { + return + } + + startX := 0 + startY := h - 22 + + // Draw window background + for y := startY; y < startY+w && y < h-2; y++ { + for x := startX; x < w; x++ { + fg, bg := GetThemeColor(ColorDebugWindow) + termbox.SetCell(x, y, ' ', fg, bg) + } + } + + // Draw window title + title := "[DEBUG LOG]" + titleX := startX + (w-len(title))/2 + for i, r := range title { + fg, bg := GetThemeColor(ColorDebugTitle) + termbox.SetCell(titleX+i, startY, r, fg, bg) + } + + // Prepare content lines + contentLines := []string{} + + // Add LSP status + if b.lspClient != nil { + contentLines = append(contentLines, fmt.Sprintf("LSP: Active (%s)", filepath.Base(b.filename))) + contentLines = append(contentLines, fmt.Sprintf("Diags: %d", len(b.diagnostics))) + + // Show first few diagnostics + for i, diag := range b.diagnostics { + if i >= 3 { + contentLines = append(contentLines, " ...") + break + } + sevStr := "?" + switch diag.Severity { + case 1: + sevStr = "E" + case 2: + sevStr = "W" + case 3: + sevStr = "I" + case 4: + sevStr = "H" + } + msg := fmt.Sprintf(" [%s] L%d: %s", sevStr, diag.Range.Start.Line+1, diag.Message) + if len(msg) > w-2 { + msg = msg[:w-5] + "..." + } + contentLines = append(contentLines, msg) + } + contentLines = append(contentLines, "---") + } + + // Add recent log messages (last 8) + startLog := 0 + if len(e.logMessages) > Config.NumLogsInDebugWindow { + startLog = len(e.logMessages) - Config.NumLogsInDebugWindow + } + for i := startLog; i < len(e.logMessages); i++ { + msg := e.logMessages[i] + if len(msg) > w-2 { + msg = msg[:w-5] + "..." + } + contentLines = append(contentLines, msg) + } + + // Draw content + for i, line := range contentLines { + if i >= w-2 { + break + } + y := startY + 1 + i + x := startX + 1 + for j, r := range line { + if x+j >= w { + break + } + fg, bg := GetThemeColor(ColorDebugWindow) + termbox.SetCell(x+j, y, r, fg, bg) + } + } +} + +func (e *Editor) drawFuzzyFinder(startY int, fuzzyHeight int) { + w, _ := termbox.Size() + + // Draw results + for i := 0; i < fuzzyHeight; i++ { + resultIdx := i + e.fuzzyScroll + if resultIdx >= len(e.fuzzyResults) { + break + } + + file := e.fuzzyResults[resultIdx] + y := startY + fuzzyHeight - 1 - i + fg, bg := GetThemeColor(ColorFuzzyResult) + + if resultIdx == e.fuzzyIndex { + // Highlight the entire selected line + selFg, selBg := GetThemeColor(ColorFuzzySelected) + for x := 0; x < w; x++ { + termbox.SetCell(x, y, ' ', selFg, selBg) + } + fg, bg = selFg, selBg + file = " > " + file + } else { + file = " " + file + } + + for x, r := range file { + if x < w { + termbox.SetCell(x, y, r, fg, bg) + } + } + } +} + +func (e *Editor) centerScreen() { + b := e.activeBuffer() + if b == nil { + return + } + _, h := termbox.Size() + visibleHeight := h - 2 + + // Calculate target scroll to center current line + targetScrollY := b.PrimaryCursor().Y - (visibleHeight / 2) + + // Don't scroll beyond buffer bounds + if targetScrollY < 0 { + targetScrollY = 0 + } + if targetScrollY > len(b.buffer)-visibleHeight { + targetScrollY = len(b.buffer) - visibleHeight + } + if targetScrollY < 0 { + targetScrollY = 0 + } + + b.scrollY = targetScrollY +} + +func (e *Editor) addCursorAbove() { + b := e.activeBuffer() + if b == nil { + return + } + primary := b.PrimaryCursor() + if primary.Y > 0 { + b.AddCursor(primary.X, primary.Y-1) + } +} + +func (e *Editor) addCursorBelow() { + b := e.activeBuffer() + if b == nil { + return + } + + // Find the cursor with the highest Y (lowest visual position) + maxY := -1 + targetX := -1 + + for _, c := range b.cursors { + if c.Y > maxY { + maxY = c.Y + targetX = c.X + // If this cursor has a preferred column, use that instead of current X + // This helps when moving through shorter lines + if c.PreferredCol > targetX { + targetX = c.PreferredCol + } + } + } + + if maxY < len(b.buffer)-1 { + b.AddCursor(targetX, maxY+1) + } +} + +func (e *Editor) clearSecondaryCursors() { + b := e.activeBuffer() + if b == nil { + return + } + b.ClearCursors() +} + +func (e *Editor) drawHoverPopup() { + if !e.showHover || e.hoverContent == "" { + return + } + + w, _ := termbox.Size() + b := e.activeBuffer() + if b == nil { + return + } + + lines := strings.Split(e.hoverContent, "\n") + maxWidth := 0 + for _, line := range lines { + if len(line) > maxWidth { + maxWidth = len(line) + } + } + + // Cap width to terminal width + if maxWidth > w-10 { + maxWidth = w - 10 + } + + paddingX := 2 + paddingY := 1 + popupWidth := maxWidth + (paddingX * 2) + popupHeight := len(lines) + (paddingY * 2) + + // Calculate position (above cursor) + visualCursorX := e.bufferToVisual(b.buffer[b.PrimaryCursor().Y], b.PrimaryCursor().X) + cursorScreenX := visualCursorX - b.scrollX + Config.GutterWidth + cursorScreenY := b.PrimaryCursor().Y - b.scrollY + + startX := cursorScreenX + startY := cursorScreenY - popupHeight + + // Adjust if out of bounds + if startY < 0 { + startY = cursorScreenY + 1 + } + if startX+popupWidth > w { + startX = w - popupWidth + } + if startX < 0 { + startX = 0 + } + + fg, bg := GetThemeColor(ColorHoverWindow) + // Draw background and content + for y := 0; y < popupHeight; y++ { + for x := 0; x < popupWidth; x++ { + termbox.SetCell(startX+x, startY+y, ' ', fg, bg) + } + } + + // Draw content lines + for i, line := range lines { + if i >= len(lines) { + break + } + y := startY + paddingY + i + for j, r := range line { + if j >= maxWidth { + break + } + if startX+paddingX+j < w { + termbox.SetCell(startX+paddingX+j, y, r, fg, bg) + } + } + } +} + +// triggerHover initiates an LSP hover request for the current cursor position. +func (e *Editor) triggerHover() { + b := e.activeBuffer() + if b == nil || b.lspClient == nil { + return + } + + e.message = "Requesting signature..." + e.draw() + + cursor := b.PrimaryCursor() + content, err := b.lspClient.Hover(cursor.Y, cursor.X) + if err != nil { + e.message = fmt.Sprintf("LSP Hover error: %v", err) + return + } + + e.hoverContent = content + e.showHover = true +} + +// triggerAutocomplete initiates an LSP completion request for the current cursor position. +func (e *Editor) triggerAutocomplete() { + b := e.activeBuffer() + if b == nil || b.lspClient == nil { + return + } + + e.message = "Requesting completions..." + e.draw() + + cursor := b.PrimaryCursor() + items, err := b.lspClient.Completion(cursor.Y, cursor.X) + if err != nil { + e.message = fmt.Sprintf("LSP Completion error: %v", err) + return + } + + if len(items) == 0 { + e.message = "No completions available" + return + } + + e.autocompleteItems = items + e.autocompleteIndex = 0 + e.autocompleteScroll = 0 + e.showAutocomplete = true + e.message = "" +} + +func (e *Editor) drawAutocompletePopup() { + if !e.showAutocomplete || len(e.autocompleteItems) == 0 { + return + } + + w, h := termbox.Size() + b := e.activeBuffer() + if b == nil { + return + } + + // Calculate max label width for alignment + maxLabelWidth := 0 + for _, item := range e.autocompleteItems { + if len(item.Label) > maxLabelWidth { + maxLabelWidth = len(item.Label) + } + } + + // Calculate total width: label + separator + detail + maxWidth := 0 + for _, item := range e.autocompleteItems { + displayText := item.Label + if item.Detail != "" { + // Pad label to align, then add arrow and detail + padding := maxLabelWidth - len(item.Label) + displayText = item.Label + strings.Repeat(" ", padding) + " " + item.Detail + } + if len(displayText) > maxWidth { + maxWidth = len(displayText) + } + } + + // Cap width to terminal width + if maxWidth > w-10 { + maxWidth = w - 10 + } + + popupWidth := maxWidth + 2 + popupHeight := len(e.autocompleteItems) + if popupHeight > 10 { + popupHeight = 10 + } + + // Calculate position (below cursor or above if no space) + visualCursorX := e.bufferToVisual(b.buffer[b.PrimaryCursor().Y], b.PrimaryCursor().X) + cursorScreenX := visualCursorX - b.scrollX + Config.GutterWidth + cursorScreenY := b.PrimaryCursor().Y - b.scrollY + + startX := cursorScreenX + startY := cursorScreenY + 1 + + // Adjust if out of bounds + if startY+popupHeight > h-1 { + startY = cursorScreenY - popupHeight + } + if startX+popupWidth > w { + startX = w - popupWidth + } + if startX < 0 { + startX = 0 + } + + fg, bg := GetThemeColor(ColorAutocompleteWindow) + selFg, selBg := GetThemeColor(ColorAutocompleteSelected) + + // Draw background and content + for y := 0; y < popupHeight; y++ { + itemIdx := y + e.autocompleteScroll + if itemIdx >= len(e.autocompleteItems) { + break + } + item := e.autocompleteItems[itemIdx] + + currentFg, currentBg := fg, bg + if itemIdx == e.autocompleteIndex { + currentFg, currentBg = selFg, selBg + } + + // Fill line + for x := 0; x < popupWidth; x++ { + termbox.SetCell(startX+x, startY+y, ' ', currentFg, currentBg) + } + + // Draw label and detail (signature) with alignment + displayText := item.Label + if item.Detail != "" { + // Pad label to align with others + padding := maxLabelWidth - len(item.Label) + displayText = item.Label + strings.Repeat(" ", padding) + " " + item.Detail + } + if len(displayText) > maxWidth { + displayText = displayText[:maxWidth-3] + "..." + } + for j, r := range displayText { + termbox.SetCell(startX+1+j, startY+y, r, currentFg, currentBg) + } + } +} + +func (e *Editor) insertCompletion(item CompletionItem) { + b := e.activeBuffer() + if b == nil { + return + } + + cursor := b.PrimaryCursor() + line := b.buffer[cursor.Y] + + // Find the start of the word we're completing + start := cursor.X + for start > 0 { + r := line[start-1] + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') { + break + } + start-- + } + + // Text to insert + insertText := item.InsertText + if insertText == "" { + insertText = item.Label + } + + // Check if this is a function/method (Kind 2=Method, 3=Function) + // or if the Detail contains "func" indicating it's a function + isFunction := item.Kind == 2 || item.Kind == 3 || strings.Contains(item.Detail, "func") + + // Replace the prefix with the completion + newRuneLine := make([]rune, start) + copy(newRuneLine, line[:start]) + newRuneLine = append(newRuneLine, []rune(insertText)...) + + // Add () for functions if not already present + cursorOffset := len(insertText) + if isFunction { + // Check if next character is already ( + nextIdx := cursor.X + if nextIdx >= len(line) || line[nextIdx] != '(' { + newRuneLine = append(newRuneLine, '(', ')') + cursorOffset++ // Position cursor inside the parentheses + } + } + + newRuneLine = append(newRuneLine, line[cursor.X:]...) + + b.buffer[cursor.Y] = newRuneLine + cursor.X = start + cursorOffset + + // Handle syntax update + if b.syntax != nil { + b.syntax.Reparse([]byte(b.toString())) + } + + e.markModified() + e.showAutocomplete = false +} diff --git a/tests/test.php b/tests/test.php new file mode 100644 index 0000000..7ab8ace --- /dev/null +++ b/tests/test.php @@ -0,0 +1,406 @@ +<?php +class CONFIG +{ + const MAX_FILESIZE = 512; //max. filesize in MiB + const MAX_FILEAGE = 180; //max. age of files in days + const MIN_FILEAGE = 31; //min. age of files in days + const DECAY_EXP = 2; //high values penalise larger files more + + const UPLOAD_TIMEOUT = 5*60; //max. time an upload can take before it times out + const MIN_ID_LENGTH = 3; //min. length of the random file ID + const MAX_ID_LENGTH = 24; //max. length of the random file ID, set to MIN_ID_LENGTH to disable + const STORE_PATH = 'files/'; //directory to store uploaded files in + const LOG_PATH = null; //path to log uploads + resulting links to + const DOWNLOAD_PATH = '%s'; //the path part of the download url. %s = placeholder for filename + const MAX_EXT_LEN = 7; //max. length for file extensions + const EXTERNAL_HOOK = null; //external program to call for each upload + const AUTO_FILE_EXT = false; //automatically try to detect file extension for files that have none + + const FORCE_HTTPS = false; //force generated links to be https:// + + const ADMIN_EMAIL = 'admin@example.com'; //address for inquiries + + public static function SITE_URL() : string + { + $proto = ($_SERVER['HTTPS'] ?? 'off') == 'on' || CONFIG::FORCE_HTTPS ? 'https' : 'http'; + return "$proto://{$_SERVER['HTTP_HOST']}"; + } + + public static function SCRIPT_URL() : string + { + return CONFIG::SITE_URL().$_SERVER['REQUEST_URI']; + } +}; + + +// generate a random string of characters with given length +function rnd_str(int $len) : string +{ + $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + $chars_len = strlen($chars); + $random = random_bytes($len); + $out = ''; + + for ($i = 0; $i < $len; ++$i) + { + $out .= $chars[ord($random[$i]) % $chars_len]; + } + + return $out; +} + +// check php.ini settings and print warnings if anything's not configured properly +function check_config() : void +{ + $warn_config_value = function($ini_name, $var_name, $var_val) + { + $ini_val = intval(ini_get($ini_name)); + if ($ini_val < $var_val) + print("<pre>Warning: php.ini: $ini_name ($ini_val) set lower than $var_name ($var_val)\n</pre>"); + }; + + $warn_config_value('upload_max_filesize', 'MAX_FILESIZE', CONFIG::MAX_FILESIZE); + $warn_config_value('post_max_size', 'MAX_FILESIZE', CONFIG::MAX_FILESIZE); + $warn_config_value('max_input_time', 'UPLOAD_TIMEOUT', CONFIG::UPLOAD_TIMEOUT); + $warn_config_value('max_execution_time', 'UPLOAD_TIMEOUT', CONFIG::UPLOAD_TIMEOUT); +} + +//extract extension from a path (does not include the dot) +function ext_by_path(string $path) : string +{ + $ext = pathinfo($path, PATHINFO_EXTENSION); + //special handling of .tar.* archives + $ext2 = pathinfo(substr($path,0,-(strlen($ext)+1)), PATHINFO_EXTENSION); + if ($ext2 === 'tar') + { + $ext = $ext2.'.'.$ext; + } + return $ext; +} + +function ext_by_finfo(string $path) : string +{ + $finfo = finfo_open(FILEINFO_EXTENSION); + $finfo_ext = finfo_file($finfo, $path); + finfo_close($finfo); + if ($finfo_ext != '???') + { + return explode('/', $finfo_ext, 2)[0]; + } + else + { + $finfo = finfo_open(); + $finfo_info = finfo_file($finfo, $path); + finfo_close($finfo); + if (strstr($finfo_info, 'text') !== false) + { + return 'txt'; + } + } + return ''; +} + +// store an uploaded file, given its name and temporary path (e.g. values straight out of $_FILES) +// files are stored wit a randomised name, but with their original extension +// +// $name: original filename +// $tmpfile: temporary path of uploaded file +// $formatted: set to true to display formatted message instead of bare link +function store_file(string $name, string $tmpfile, bool $formatted = false) : void +{ + //create folder, if it doesn't exist + if (!file_exists(CONFIG::STORE_PATH)) + { + mkdir(CONFIG::STORE_PATH, 0750, true); //TODO: error handling + } + + //check file size + $size = filesize($tmpfile); + if ($size > CONFIG::MAX_FILESIZE * 1024 * 1024) + { + header('HTTP/1.0 413 Payload Too Large'); + print("Error 413: Max File Size ({CONFIG::MAX_FILESIZE} MiB) Exceeded\n"); + return; + } + if ($size == 0) + { + header('HTTP/1.0 400 Bad Request'); + print('Error 400: Uploaded file is empty\n'); + return; + } + + $ext = ext_by_path($name); + if (empty($ext) && CONFIG::AUTO_FILE_EXT) + { + $ext = ext_by_finfo($tmpfile); + } + $ext = substr($ext, 0, CONFIG::MAX_EXT_LEN); + $tries_per_len=3; //try random names a few times before upping the length + + $id_length=CONFIG::MIN_ID_LENGTH; + if(isset($_POST['id_length']) && ctype_digit($_POST['id_length'])) { + $id_length = max(CONFIG::MIN_ID_LENGTH, min(CONFIG::MAX_ID_LENGTH, $_POST['id_length'])); + } + + for ($len = $id_length; ; ++$len) + { + for ($n=0; $n<=$tries_per_len; ++$n) + { + $id = rnd_str($len); + $basename = $id . (empty($ext) ? '' : '.' . $ext); + $target_file = CONFIG::STORE_PATH . $basename; + + if (!file_exists($target_file)) + break 2; + } + } + + $res = move_uploaded_file($tmpfile, $target_file); + if (!$res) + { + //TODO: proper error handling? + header('HTTP/1.0 520 Unknown Error'); + return; + } + + if (CONFIG::EXTERNAL_HOOK !== null) + { + putenv('REMOTE_ADDR='.$_SERVER['REMOTE_ADDR']); + putenv('ORIGINAL_NAME='.$name); + putenv('STORED_FILE='.$target_file); + $ret = -1; + $out = null; + $last_line = exec(CONFIG::EXTERNAL_HOOK, $out, $ret); + if ($last_line !== false && $ret !== 0) + { + unlink($target_file); + header('HTTP/1.0 400 Bad Request'); + print("Error: $last_line\n"); + return; + } + } + + //print the download link of the file + $url = sprintf(CONFIG::SITE_URL().'/'.CONFIG::DOWNLOAD_PATH, $basename); + + if ($formatted) + { + print("<pre>Access your file here: <a href=\"$url\">$url</a></pre>"); + } + else + { + print("$url\n"); + } + + // log uploader's IP, original filename, etc. + if (CONFIG::LOG_PATH) + { + file_put_contents( + CONFIG::LOG_PATH, + implode("\t", array( + date('c'), + $_SERVER['REMOTE_ADDR'], + filesize($tmpfile), + escapeshellarg($name), + $basename + )) . "\n", + FILE_APPEND + ); + } +} + +// purge all files older than their retention period allows. +function purge_files() : void +{ + $num_del = 0; //number of deleted files + $total_size = 0; //total size of deleted files + + //for each stored file + foreach (scandir(CONFIG::STORE_PATH) as $file) + { + //skip virtual . and .. files + if ($file === '.' || + $file === '..') + { + continue; + } + + $file = CONFIG::STORE_PATH . $file; + + $file_size = filesize($file) / (1024*1024); //size in MiB + $file_age = (time()-filemtime($file)) / (60*60*24); //age in days + + //keep all files below the min age + if ($file_age < CONFIG::MIN_FILEAGE) + { + continue; + } + + //calculate the maximum age in days for this file + $file_max_age = CONFIG::MIN_FILEAGE + + (CONFIG::MAX_FILEAGE - CONFIG::MIN_FILEAGE) * + pow(1 - ($file_size / CONFIG::MAX_FILESIZE), CONFIG::DECAY_EXP); + + //delete if older + if ($file_age > $file_max_age) + { + unlink($file); + + print("deleted $file, $file_size MiB, $file_age days old\n"); + $num_del += 1; + $total_size += $file_size; + } + } + print("Deleted $num_del files totalling $total_size MiB\n"); +} + +function send_text_file(string $filename, string $content) : void +{ + header('Content-type: application/octet-stream'); + header("Content-Disposition: attachment; filename=\"$filename\""); + header('Content-Length: '.strlen($content)); + print($content); +} + +// send a ShareX custom uploader config as .json +function send_sharex_config() : void +{ + $name = $_SERVER['SERVER_NAME']; + $site_url = str_replace("?sharex", "", CONFIG::SCRIPT_URL()); + send_text_file($name.'.sxcu', <<<EOT +{ + "Name": "$name", + "DestinationType": "ImageUploader, FileUploader", + "RequestType": "POST", + "RequestURL": "$site_url", + "FileFormName": "file", + "ResponseType": "Text" +} +EOT); +} + +// send a Hupl uploader config as .hupl (which is just JSON) +function send_hupl_config() : void +{ + $name = $_SERVER['SERVER_NAME']; + $site_url = str_replace("?hupl", "", CONFIG::SCRIPT_URL()); + send_text_file($name.'.hupl', <<<EOT +{ + "name": "$name", + "type": "http", + "targetUrl": "$site_url", + "fileParam": "file" +} +EOT); +} + +// print a plaintext info page, explaining what this script does and how to +// use it, how to upload, etc. +function print_index() : void +{ + $site_url = CONFIG::SCRIPT_URL(); + $sharex_url = $site_url.'?sharex'; + $hupl_url = $site_url.'?hupl'; + $decay = CONFIG::DECAY_EXP; + $min_age = CONFIG::MIN_FILEAGE; + $max_size = CONFIG::MAX_FILESIZE; + $max_age = CONFIG::MAX_FILEAGE; + $mail = CONFIG::ADMIN_EMAIL; + $max_id_length = CONFIG::MAX_ID_LENGTH; + + $length_info = "\nTo use a longer file ID (up to $max_id_length characters), add -F id_length=<number>\n"; + if (CONFIG::MIN_ID_LENGTH == CONFIG::MAX_ID_LENGTH) + { + $length_info = ""; + } + +echo <<<EOT +<!DOCTYPE html> +<html lang="en"> +<head> + <title>Filehost</title> + <meta name="description" content="Minimalistic service for sharing temporary files." /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> +</head> +<body> +<pre> + === How To Upload === +You can upload files to this site via a simple HTTP POST, e.g. using curl: +curl -F "file=@/path/to/your/file.jpg" $site_url + +Or if you want to pipe to curl *and* have a file extension, add a "filename": +echo "hello" | curl -F "file=@-;filename=.txt" $site_url +$length_info +On Windows, you can use <a href="https://getsharex.com/">ShareX</a> and import <a href="$sharex_url">this</a> custom uploader. +On Android, you can use an app called <a href="https://github.com/Rouji/Hupl">Hupl</a> with <a href="$hupl_url">this</a> uploader. + + +Or simply choose a file and click "Upload" below: +(Hint: If you're lucky, your browser may support drag-and-drop onto the file +selection input.) +</pre> +<form id="frm" method="post" enctype="multipart/form-data"> +<input type="file" name="file" id="file" /> +<input type="hidden" name="formatted" value="true" /> +<input type="submit" value="Upload"/> +</form> +<pre> + + + === File Sizes etc. === +The maximum allowed file size is $max_size MiB. + +Files are kept for a minimum of $min_age, and a maximum of $max_age Days. + +How long a file is kept depends on its size. Larger files are deleted earlier +than small ones. This relation is non-linear and skewed in favour of small +files. + +The exact formula for determining the maximum age for a file is: + +MIN_AGE + (MAX_AGE - MIN_AGE) * (1-(FILE_SIZE/MAX_SIZE))^$decay + + + === Source === +The PHP script used to provide this service is open source and available on +<a href="https://github.com/Rouji/single_php_filehost">GitHub</a> + + + === Contact === +If you want to report abuse of this service, or have any other inquiries, +please write an email to $mail +</pre> +</body> +</html> +EOT; +} + + +// decide what to do, based on POST parameters etc. +if (isset($_FILES['file']['name']) && + isset($_FILES['file']['tmp_name']) && + is_uploaded_file($_FILES['file']['tmp_name'])) +{ + //file was uploaded, store it + $formatted = isset($_REQUEST['formatted']); + store_file($_FILES['file']['name'], + $_FILES['file']['tmp_name'], + $formatted); +} +else if (isset($_GET['sharex'])) +{ + send_sharex_config(); +} +else if (isset($_GET['hupl'])) +{ + send_hupl_config(); +} +else if ($argv[1] ?? null === 'purge') +{ + purge_files(); +} +else +{ + check_config(); + print_index(); +} diff --git a/tests/test.py b/tests/test.py new file mode 100644 index 0000000..4479f5c --- /dev/null +++ b/tests/test.py @@ -0,0 +1,32 @@ +def set_password(args): + password = args.password + while not password : + password1 = getpass("" if args.quiet else "Provide password: ") + password_repeat = getpass("" if args.quiet else "Repeat password: ") + if password1 != password_repeat: + print("Passwords do not match, try again") + elif len(password1) < 4: + print("Please provide at least 4 characters") + else: + password = password1 + + password_hash = passwd(password) + cfg = BaseJSONConfigManager(config_dir=jupyter_config_dir()) + cfg.update('jupyter_notebook_config', { + 'NotebookApp': { + 'password': password_hash, + } + }) + if not args.quiet: + print("password stored in config dir: %s" % jupyter_config_dir()) + +def main(argv): + parser = argparse.ArgumentParser(argv[0]) + subparsers = parser.add_subparsers() + parser_password = subparsers.add_parser('password', help='sets a password for your notebook server') + parser_password.add_argument("password", help="password to set, if not given, a password will be queried for (NOTE: this may not be safe)", + nargs="?") + parser_password.add_argument("--quiet", help="suppress messages", action="store_true") + parser_password.set_defaults(function=set_password) + args = parser.parse_args(argv[1:]) + args.function(args) diff --git a/tests/test.rs b/tests/test.rs new file mode 100644 index 0000000..c2d1091 --- /dev/null +++ b/tests/test.rs @@ -0,0 +1,31 @@ +fn main() { + println!("Hello, world!"); +} + +fn add(a: i32, b: i32) -> i32 { + a + b +} + +struct Point { + x: f64, + y: f64, +} + +enum Direction { + Up, + Down, + Left, + Right, +} + +trait Describe { + fn describe(&self) -> String; +} + +impl Describe for Point { + fn describe(&self) -> String { + format!("Point at ({}, {})", self.x, self.y) + } +} + +const MAX_POINTS: u32 = 100; |
