diff options
| author | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-01-22 00:46:42 +0100 |
|---|---|---|
| committer | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-01-22 00:46:42 +0100 |
| commit | 3abc76cce48f3ae66e28ec99af648dc5fdf8bf4a (patch) | |
| tree | 9d17390261bbfe5fcfbf349104a9af85f30e6a9d /tests | |
| parent | 52040cc19cbdca48f91d4eb91e9b7a782bb5fbd0 (diff) | |
| download | crep-3abc76cce48f3ae66e28ec99af648dc5fdf8bf4a.tar.gz | |
Add better tests and testing suit
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test.c | 548 | ||||
| -rw-r--r-- | tests/test.go | 4519 | ||||
| -rw-r--r-- | tests/test.php | 405 | ||||
| -rw-r--r-- | tests/test.py | 48 |
4 files changed, 71 insertions, 5449 deletions
diff --git a/tests/test.c b/tests/test.c index 8e57604..4326f11 100644 --- a/tests/test.c +++ b/tests/test.c @@ -1,540 +1,32 @@ -#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" +#include <stdio.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; +// A simple function definition +void hello() { + printf("Hello, world!\n"); } -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); +// A function with parameters +int add(int a, int b) { + return a + b; } -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); +// A function returning a pointer +int* get_pointer(int* x) { + return x; } -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++; - } +// A function declaration (prototype) +void declared_only(int x); - while (i < len && !is_word_byte(buf[i])) { - i++; - } +// A pointer to a function declaration +char* (*function_ptr_return)(int); - c->pos = i; - reset_completion(c); - return true; +// A complex declaration +static inline const char* complex_func(const int* const ptr, void (*callback)(int)) { + return "complex"; } -static bool cmd_complete_next(EditorState *e, const CommandArgs *a) -{ - BUG_ON(a->nr_args); - complete_command_next(e); - return true; +int main() { + hello(); + return 0; } - -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 index f889232..9df6eb0 100644 --- a/tests/test.go +++ b/tests/test.go @@ -1,4513 +1,32 @@ 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 "fmt" -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() +// Simple function +func Hello() { + fmt.Println("Hello") } -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 +// Function with parameters +func Add(a int, b int) int { + return a + b } -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]) +// Struct type +type Point struct { + X int + Y int } -func (e *Editor) jumpForward() { - if e.jumpIndex >= len(e.jumplist)-1 { - return - } - - e.jumpIndex++ - e.performJump(e.jumplist[e.jumpIndex]) +// Interface type +type Describer interface { + Describe() string } -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() +// Method declaration +func (p Point) Describe() string { + return fmt.Sprintf("Point(%d, %d)", p.X, p.Y) } -// 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 -} +// Constant declaration +const MaxValue = 100 diff --git a/tests/test.php b/tests/test.php index 7ab8ace..47cd4c0 100644 --- a/tests/test.php +++ b/tests/test.php @@ -1,406 +1,29 @@ <?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; +function simple_function() { + echo "Hello"; } -// 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); +function function_with_args($a, $b) { + return $a + $b; } -//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; +class MyClass { + public function myMethod() { + echo "Method"; } - 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]; + protected static function protectedStaticMethod() { + // ... } - 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; - } - } +const GLOBAL_CONST = 1; - //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 - ); - } +class AnotherClass { + const CLASS_CONST = 2; } -// 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(); +interface MyInterface { + public function interfaceMethod(); } diff --git a/tests/test.py b/tests/test.py index 4479f5c..3dbf524 100644 --- a/tests/test.py +++ b/tests/test.py @@ -1,32 +1,20 @@ -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 +def hello(): + print("Hello, world!") - 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 add(a, b): + return a + b -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) +def complex_function(a, b, c=None, *args, **kwargs): + """A more complex function.""" + pass + +async def async_hello(): + print("Async hello") + +class MyClass: + def method_one(self): + pass + + @staticmethod + def static_method(): + pass |
