3 Commits

Author SHA1 Message Date
051106a233 Enable LSP debug logging, expand language feature support, and fix GUI rendering issues.
- Added `--debug` CLI flag to control LSP debug logging and corresponding environment setting.
- Extended LSP capabilities with basic hover, completion, and definition feature support.
- Removed redundant `NoScrollWithMouse` flag, resolving inconsistencies in GUI scrolling behavior.
- Refined variable usage and type consistency across LSP and rendering modules.
- Updated `LspManager` for improved buffer handling and server diagnostics integration.
2025-12-02 01:21:09 -08:00
33bbb5b98f Add SQL, Erlang, and Forth highlighter implementations and tests for LSP process and transport handling.
- Added highlighters for new languages (SQL, Erlang, Forth) with filetype recognition.
- Updated and reorganized syntax files to maintain consistency and modularity.
- Introduced LSP transport framing unit tests and JSON decoding/dispatch tests.
- Refactored `LspManager`, integrating UTF-16/UTF-8 position conversions and robust diagnostics handling.
- Enhanced server start/restart logic with workspace root detection and logging to improve LSP usability.
2025-12-02 00:15:15 -08:00
e089c6e4d1 LSP integration steps 1-4, part of 5. 2025-12-01 20:09:49 -08:00
47 changed files with 29968 additions and 513 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
!.idea !.idea
cmake-build* cmake-build*
build build
build-*
/imgui.ini /imgui.ini
result result

View File

@@ -141,6 +141,13 @@
<pair source="c++m" header="" fileNamingConvention="NONE" /> <pair source="c++m" header="" fileNamingConvention="NONE" />
</extensions> </extensions>
</files> </files>
<codeStyleSettings language="CMake">
<indentOptions>
<option name="INDENT_SIZE" value="8" />
<option name="TAB_SIZE" value="8" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="ObjectiveC"> <codeStyleSettings language="ObjectiveC">
<indentOptions> <indentOptions>
<option name="INDENT_SIZE" value="8" /> <option name="INDENT_SIZE" value="8" />

View File

@@ -9,6 +9,7 @@
// For reconstructing highlighter state on copies // For reconstructing highlighter state on copies
#include "syntax/HighlighterRegistry.h" #include "syntax/HighlighterRegistry.h"
#include "syntax/NullHighlighter.h" #include "syntax/NullHighlighter.h"
#include "lsp/BufferChangeTracker.h"
Buffer::Buffer() Buffer::Buffer()
@@ -19,6 +20,9 @@ Buffer::Buffer()
} }
Buffer::~Buffer() = default;
Buffer::Buffer(const std::string &path) Buffer::Buffer(const std::string &path)
{ {
std::string err; std::string err;
@@ -394,6 +398,30 @@ Buffer::AsString() const
} }
std::string
Buffer::FullText() const
{
std::string out;
// Precompute size for fewer reallocations
std::size_t total = 0;
for (std::size_t i = 0; i < rows_.size(); ++i) {
total += rows_[i].Size();
if (i + 1 < rows_.size())
total += 1; // for '\n'
}
out.reserve(total);
for (std::size_t i = 0; i < rows_.size(); ++i) {
const char *d = rows_[i].Data();
std::size_t n = rows_[i].Size();
if (d && n)
out.append(d, n);
if (i + 1 < rows_.size())
out.push_back('\n');
}
return out;
}
// --- Raw editing APIs (no undo recording, cursor untouched) --- // --- Raw editing APIs (no undo recording, cursor untouched) ---
void void
Buffer::insert_text(int row, int col, std::string_view text) Buffer::insert_text(int row, int col, std::string_view text)
@@ -432,6 +460,9 @@ Buffer::insert_text(int row, int col, std::string_view text)
remain.erase(0, pos + 1); remain.erase(0, pos + 1);
} }
// Do not set dirty here; UndoSystem will manage state/dirty externally // Do not set dirty here; UndoSystem will manage state/dirty externally
if (change_tracker_) {
change_tracker_->recordInsertion(row, col, std::string(text));
}
} }
@@ -470,6 +501,9 @@ Buffer::delete_text(int row, int col, std::size_t len)
break; break;
} }
} }
if (change_tracker_) {
change_tracker_->recordDeletion(row, col, len);
}
} }
@@ -543,3 +577,17 @@ Buffer::Undo() const
{ {
return undo_sys_.get(); return undo_sys_.get();
} }
void
Buffer::SetChangeTracker(std::unique_ptr<kte::lsp::BufferChangeTracker> tracker)
{
change_tracker_ = std::move(tracker);
}
kte::lsp::BufferChangeTracker *
Buffer::GetChangeTracker()
{
return change_tracker_.get();
}

View File

@@ -17,11 +17,20 @@
#include "syntax/HighlighterEngine.h" #include "syntax/HighlighterEngine.h"
#include "Highlight.h" #include "Highlight.h"
// Forward declarations to avoid heavy includes
namespace kte {
namespace lsp {
class BufferChangeTracker;
}
}
class Buffer { class Buffer {
public: public:
Buffer(); Buffer();
~Buffer();
Buffer(const Buffer &other); Buffer(const Buffer &other);
Buffer &operator=(const Buffer &other); Buffer &operator=(const Buffer &other);
@@ -374,6 +383,9 @@ public:
[[nodiscard]] std::string AsString() const; [[nodiscard]] std::string AsString() const;
// Compose full text of this buffer with newlines between rows
[[nodiscard]] std::string FullText() const;
// Syntax highlighting integration (per-buffer) // Syntax highlighting integration (per-buffer)
[[nodiscard]] std::uint64_t Version() const [[nodiscard]] std::uint64_t Version() const
{ {
@@ -443,6 +455,11 @@ public:
[[nodiscard]] const UndoSystem *Undo() const; [[nodiscard]] const UndoSystem *Undo() const;
// LSP integration: optional change tracker
void SetChangeTracker(std::unique_ptr<kte::lsp::BufferChangeTracker> tracker);
kte::lsp::BufferChangeTracker *GetChangeTracker();
private: private:
// State mirroring original C struct (without undo_tree) // State mirroring original C struct (without undo_tree)
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
@@ -466,6 +483,9 @@ private:
bool syntax_enabled_ = true; bool syntax_enabled_ = true;
std::string filetype_; std::string filetype_;
std::unique_ptr<kte::HighlighterEngine> highlighter_; std::unique_ptr<kte::HighlighterEngine> highlighter_;
// Optional LSP change tracker (absent by default)
std::unique_ptr<kte::lsp::BufferChangeTracker> change_tracker_;
}; };
#endif // KTE_BUFFER_H #endif // KTE_BUFFER_H

View File

@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(KTE_VERSION "1.2.3") set(KTE_VERSION "1.2.0")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default. # Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available. # Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
@@ -54,28 +54,34 @@ set(CURSES_NEED_WIDE)
find_package(Curses REQUIRED) find_package(Curses REQUIRED)
include_directories(${CURSES_INCLUDE_DIR}) include_directories(${CURSES_INCLUDE_DIR})
set(SYNTAX_SOURCES # Detect availability of get_wch (wide-char input) in the curses headers
syntax/GoHighlighter.cc include(CheckSymbolExists)
syntax/CppHighlighter.cc set(CMAKE_REQUIRED_INCLUDES ${CURSES_INCLUDE_DIR})
syntax/JsonHighlighter.cc check_symbol_exists(get_wch "ncurses.h" KTE_HAVE_GET_WCH_IN_NCURSES)
syntax/ErlangHighlighter.cc if (NOT KTE_HAVE_GET_WCH_IN_NCURSES)
syntax/MarkdownHighlighter.cc # Some systems expose curses headers as <curses.h>
syntax/TreeSitterHighlighter.cc check_symbol_exists(get_wch "curses.h" KTE_HAVE_GET_WCH_IN_CURSES)
syntax/LispHighlighter.cc
syntax/HighlighterEngine.cc
syntax/RustHighlighter.cc
syntax/HighlighterRegistry.cc
syntax/SqlHighlighter.cc
syntax/NullHighlighter.cc
syntax/ForthHighlighter.cc
syntax/PythonHighlighter.cc
syntax/ShellHighlighter.cc
)
if (KTE_ENABLE_TREESITTER)
list(APPEND SYNTAX_SOURCES
TreeSitterHighlighter.cc)
endif () endif ()
if (KTE_HAVE_GET_WCH_IN_NCURSES OR KTE_HAVE_GET_WCH_IN_CURSES)
add_compile_definitions(KTE_HAVE_GET_WCH)
endif ()
set(SYNTAX_SOURCES
syntax/HighlighterEngine.cc
syntax/CppHighlighter.cc
syntax/HighlighterRegistry.cc
syntax/NullHighlighter.cc
syntax/JsonHighlighter.cc
syntax/MarkdownHighlighter.cc
syntax/ShellHighlighter.cc
syntax/GoHighlighter.cc
syntax/PythonHighlighter.cc
syntax/RustHighlighter.cc
syntax/LispHighlighter.cc
syntax/SqlHighlighter.cc
syntax/ErlangHighlighter.cc
syntax/ForthHighlighter.cc
)
set(COMMON_SOURCES set(COMMON_SOURCES
GapBuffer.cc GapBuffer.cc
@@ -94,43 +100,51 @@ set(COMMON_SOURCES
UndoNode.cc UndoNode.cc
UndoTree.cc UndoTree.cc
UndoSystem.cc UndoSystem.cc
lsp/UtfCodec.cc
lsp/BufferChangeTracker.cc
lsp/JsonRpcTransport.cc
lsp/LspProcessClient.cc
lsp/DiagnosticStore.cc
lsp/TerminalDiagnosticDisplay.cc
lsp/LspManager.cc
${SYNTAX_SOURCES} ${SYNTAX_SOURCES}
) )
set(SYNTAX_HEADERS
syntax/GoHighlighter.h
syntax/HighlighterEngine.h
syntax/ShellHighlighter.h
syntax/MarkdownHighlighter.h
syntax/LispHighlighter.h
syntax/SqlHighlighter.h
syntax/ForthHighlighter.h
syntax/JsonHighlighter.h
syntax/TreeSitterHighlighter.h
syntax/NullHighlighter.h
syntax/CppHighlighter.h
syntax/ErlangHighlighter.h
syntax/LanguageHighlighter.h
syntax/RustHighlighter.h
syntax/PythonHighlighter.h
)
if (KTE_ENABLE_TREESITTER) if (KTE_ENABLE_TREESITTER)
list(APPEND THEME_HEADERS list(APPEND SYNTAX_SOURCES
TreeSitterHighlighter.h) syntax/TreeSitterHighlighter.cc)
endif () endif ()
set(THEME_HEADERS set(THEME_HEADERS
themes/ThemeHelpers.h
themes/EInk.h themes/EInk.h
themes/Gruvbox.h themes/Gruvbox.h
themes/Solarized.h
themes/Plan9.h
themes/Nord.h themes/Nord.h
themes/Plan9.h
themes/Solarized.h
themes/ThemeHelpers.h
) )
set(SYNTAX_HEADERS
syntax/LanguageHighlighter.h
syntax/HighlighterEngine.h
syntax/CppHighlighter.h
syntax/HighlighterRegistry.h
syntax/NullHighlighter.h
syntax/JsonHighlighter.h
syntax/MarkdownHighlighter.h
syntax/ShellHighlighter.h
syntax/GoHighlighter.h
syntax/PythonHighlighter.h
syntax/RustHighlighter.h
syntax/LispHighlighter.h
)
if (KTE_ENABLE_TREESITTER)
list(APPEND SYNTAX_HEADERS
syntax/TreeSitterHighlighter.h)
endif ()
set(COMMON_HEADERS set(COMMON_HEADERS
GapBuffer.h GapBuffer.h
PieceTable.h PieceTable.h
@@ -153,9 +167,23 @@ set(COMMON_HEADERS
UndoTree.h UndoTree.h
UndoSystem.h UndoSystem.h
Highlight.h Highlight.h
lsp/UtfCodec.h
lsp/LspTypes.h
lsp/BufferChangeTracker.h
lsp/JsonRpcTransport.h
lsp/LspClient.h
lsp/LspProcessClient.h
lsp/Diagnostic.h
lsp/DiagnosticStore.h
lsp/DiagnosticDisplay.h
lsp/TerminalDiagnosticDisplay.h
lsp/LspManager.h
lsp/LspServerConfig.h
ext/json.h
ext/json_fwd.h
${SYNTAX_HEADERS}
${THEME_HEADERS} ${THEME_HEADERS}
${SYNTAX_HEADERS}
) )
# kte (terminal-first) executable # kte (terminal-first) executable
@@ -173,6 +201,8 @@ if (KTE_UNDO_DEBUG)
endif () endif ()
target_link_libraries(kte ${CURSES_LIBRARIES}) target_link_libraries(kte ${CURSES_LIBRARIES})
# Ensure vendored headers (e.g., ext/json.h) are on the include path
target_include_directories(kte PRIVATE ${CMAKE_SOURCE_DIR}/ext)
if (KTE_ENABLE_TREESITTER) if (KTE_ENABLE_TREESITTER)
# Users can provide their own tree-sitter include/lib via cache variables # Users can provide their own tree-sitter include/lib via cache variables
@@ -211,6 +241,8 @@ if (BUILD_TESTS)
target_link_libraries(test_undo ${CURSES_LIBRARIES}) target_link_libraries(test_undo ${CURSES_LIBRARIES})
# Ensure vendored headers (e.g., ext/json.h) are on the include path for tests as well
target_include_directories(test_undo PRIVATE ${CMAKE_SOURCE_DIR}/ext)
if (KTE_ENABLE_TREESITTER) if (KTE_ENABLE_TREESITTER)
if (TREESITTER_INCLUDE_DIR) if (TREESITTER_INCLUDE_DIR)
target_include_directories(test_undo PRIVATE ${TREESITTER_INCLUDE_DIR}) target_include_directories(test_undo PRIVATE ${TREESITTER_INCLUDE_DIR})
@@ -219,20 +251,93 @@ if (BUILD_TESTS)
target_link_libraries(test_undo ${TREESITTER_LIBRARY}) target_link_libraries(test_undo ${TREESITTER_LIBRARY})
endif () endif ()
endif () endif ()
# test_utfcodec executable for UTF conversion helpers
add_executable(test_utfcodec
test_utfcodec.cc
${COMMON_SOURCES}
${COMMON_HEADERS}
)
if (KTE_USE_PIECE_TABLE)
target_compile_definitions(test_utfcodec PRIVATE KTE_USE_PIECE_TABLE=1)
endif ()
if (KTE_UNDO_DEBUG)
target_compile_definitions(test_utfcodec PRIVATE KTE_UNDO_DEBUG=1)
endif ()
target_link_libraries(test_utfcodec ${CURSES_LIBRARIES})
# Ensure vendored headers (e.g., ext/json.h) are on the include path for tests as well
target_include_directories(test_utfcodec PRIVATE ${CMAKE_SOURCE_DIR}/ext)
if (KTE_ENABLE_TREESITTER)
if (TREESITTER_INCLUDE_DIR)
target_include_directories(test_utfcodec PRIVATE ${TREESITTER_INCLUDE_DIR})
endif ()
if (TREESITTER_LIBRARY)
target_link_libraries(test_utfcodec ${TREESITTER_LIBRARY})
endif ()
endif ()
# test_transport executable for JSON-RPC framing
add_executable(test_transport
test_transport.cc
${COMMON_SOURCES}
${COMMON_HEADERS}
)
if (KTE_USE_PIECE_TABLE)
target_compile_definitions(test_transport PRIVATE KTE_USE_PIECE_TABLE=1)
endif ()
if (KTE_UNDO_DEBUG)
target_compile_definitions(test_transport PRIVATE KTE_UNDO_DEBUG=1)
endif ()
target_link_libraries(test_transport ${CURSES_LIBRARIES})
# Ensure vendored headers (e.g., ext/json.h) are on the include path for tests as well
target_include_directories(test_transport PRIVATE ${CMAKE_SOURCE_DIR}/ext)
if (KTE_ENABLE_TREESITTER)
if (TREESITTER_INCLUDE_DIR)
target_include_directories(test_transport PRIVATE ${TREESITTER_INCLUDE_DIR})
endif ()
if (TREESITTER_LIBRARY)
target_link_libraries(test_transport ${TREESITTER_LIBRARY})
endif ()
endif ()
# test_lsp_decode executable for dispatcher decoding
add_executable(test_lsp_decode
test_lsp_decode.cc
${COMMON_SOURCES}
${COMMON_HEADERS}
)
if (KTE_USE_PIECE_TABLE)
target_compile_definitions(test_lsp_decode PRIVATE KTE_USE_PIECE_TABLE=1)
endif ()
if (KTE_UNDO_DEBUG)
target_compile_definitions(test_lsp_decode PRIVATE KTE_UNDO_DEBUG=1)
endif ()
target_link_libraries(test_lsp_decode ${CURSES_LIBRARIES})
# Ensure vendored headers (e.g., ext/json.h) are on the include path for tests as well
target_include_directories(test_lsp_decode PRIVATE ${CMAKE_SOURCE_DIR}/ext)
if (KTE_ENABLE_TREESITTER)
if (TREESITTER_INCLUDE_DIR)
target_include_directories(test_lsp_decode PRIVATE ${TREESITTER_INCLUDE_DIR})
endif ()
if (TREESITTER_LIBRARY)
target_link_libraries(test_lsp_decode ${TREESITTER_LIBRARY})
endif ()
endif ()
endif () endif ()
if (${BUILD_GUI}) if (${BUILD_GUI})
# ImGui::CreateContext();
# ImGuiIO& io = ImGui::GetIO();
# // Set custom ini filename path to ~/.config/kte/imgui.ini
# if (const char* home = std::getenv("HOME")) {
# static std::string ini_path = std::string(home) + "/.config/kte/imgui.ini";
# io.IniFilename = ini_path.c_str();
# }
# io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls
# io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls
target_sources(kte PRIVATE target_sources(kte PRIVATE
Font.h Font.h
GUIConfig.cc GUIConfig.cc
@@ -264,6 +369,7 @@ if (${BUILD_GUI})
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1) target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
endif () endif ()
target_link_libraries(kge ${CURSES_LIBRARIES} imgui) target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
target_include_directories(kge PRIVATE ${CMAKE_SOURCE_DIR}/ext)
# On macOS, build kge as a proper .app bundle # On macOS, build kge as a proper .app bundle
if (APPLE) if (APPLE)

View File

@@ -42,11 +42,12 @@ compute_render_x(const std::string &line, const std::size_t curx, const std::siz
static void static void
ensure_cursor_visible(const Editor &ed, Buffer &buf) ensure_cursor_visible(const Editor &ed, Buffer &buf)
{ {
const std::size_t rows = ed.Rows();
const std::size_t cols = ed.Cols(); const std::size_t cols = ed.Cols();
if (cols == 0) if (rows == 0 || cols == 0)
return; return;
const std::size_t content_rows = ed.ContentRows(); const std::size_t content_rows = rows > 0 ? rows - 1 : 0; // last row = status
const std::size_t cury = buf.Cury(); const std::size_t cury = buf.Cury();
const std::size_t curx = buf.Curx(); const std::size_t curx = buf.Curx();
std::size_t rowoffs = buf.Rowoffs(); std::size_t rowoffs = buf.Rowoffs();
@@ -553,6 +554,8 @@ cmd_save(CommandContext &ctx)
ctx.editor.SetStatus("Saved " + buf->Filename()); ctx.editor.SetStatus("Saved " + buf->Filename());
if (auto *u = buf->Undo()) if (auto *u = buf->Undo())
u->mark_saved(); u->mark_saved();
// Notify LSP of save
ctx.editor.NotifyBufferSaved(buf);
return true; return true;
} }
@@ -607,6 +610,8 @@ cmd_save_as(CommandContext &ctx)
ctx.editor.SetStatus("Saved as " + ctx.arg); ctx.editor.SetStatus("Saved as " + ctx.arg);
if (auto *u = buf->Undo()) if (auto *u = buf->Undo())
u->mark_saved(); u->mark_saved();
// Notify LSP of save
ctx.editor.NotifyBufferSaved(buf);
return true; return true;
} }
@@ -3003,7 +3008,9 @@ cmd_page_up(CommandContext &ctx)
ensure_at_least_one_line(*buf); ensure_at_least_one_line(*buf);
auto &rows = buf->Rows(); auto &rows = buf->Rows();
int repeat = ctx.count > 0 ? ctx.count : 1; int repeat = ctx.count > 0 ? ctx.count : 1;
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows()); std::size_t content_rows = ctx.editor.Rows() > 0 ? ctx.editor.Rows() - 1 : 0;
if (content_rows == 0)
content_rows = 1;
// Base on current top-of-screen (row offset) // Base on current top-of-screen (row offset)
std::size_t rowoffs = buf->Rowoffs(); std::size_t rowoffs = buf->Rowoffs();
@@ -3027,6 +3034,7 @@ cmd_page_up(CommandContext &ctx)
y = rows.empty() ? 0 : rows.size() - 1; y = rows.empty() ? 0 : rows.size() - 1;
buf->SetOffsets(rowoffs, 0); buf->SetOffsets(rowoffs, 0);
buf->SetCursor(0, y); buf->SetCursor(0, y);
ensure_cursor_visible(ctx.editor, *buf);
return true; return true;
} }
@@ -3042,7 +3050,9 @@ cmd_page_down(CommandContext &ctx)
ensure_at_least_one_line(*buf); ensure_at_least_one_line(*buf);
auto &rows = buf->Rows(); auto &rows = buf->Rows();
int repeat = ctx.count > 0 ? ctx.count : 1; int repeat = ctx.count > 0 ? ctx.count : 1;
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows()); std::size_t content_rows = ctx.editor.Rows() > 0 ? ctx.editor.Rows() - 1 : 0;
if (content_rows == 0)
content_rows = 1;
std::size_t rowoffs = buf->Rowoffs(); std::size_t rowoffs = buf->Rowoffs();
// Compute maximum top offset // Compute maximum top offset
@@ -3063,74 +3073,7 @@ cmd_page_down(CommandContext &ctx)
std::size_t y = std::min<std::size_t>(rowoffs, rows.empty() ? 0 : rows.size() - 1); std::size_t y = std::min<std::size_t>(rowoffs, rows.empty() ? 0 : rows.size() - 1);
buf->SetOffsets(rowoffs, 0); buf->SetOffsets(rowoffs, 0);
buf->SetCursor(0, y); buf->SetCursor(0, y);
return true; ensure_cursor_visible(ctx.editor, *buf);
}
static bool
cmd_scroll_up(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
ensure_at_least_one_line(*buf);
const auto &rows = buf->Rows();
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
std::size_t rowoffs = buf->Rowoffs();
// Scroll up by 3 lines (or count if specified), without moving cursor
int scroll_amount = ctx.count > 0 ? ctx.count : 3;
if (rowoffs >= static_cast<std::size_t>(scroll_amount))
rowoffs -= static_cast<std::size_t>(scroll_amount);
else
rowoffs = 0;
buf->SetOffsets(rowoffs, buf->Coloffs());
// If cursor is now below the visible area, move it to the last visible line
std::size_t cury = buf->Cury();
if (cury >= rowoffs + content_rows) {
std::size_t new_y = rowoffs + content_rows - 1;
if (new_y >= rows.size() && !rows.empty())
new_y = rows.size() - 1;
buf->SetCursor(buf->Curx(), new_y);
}
return true;
}
static bool
cmd_scroll_down(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
ensure_at_least_one_line(*buf);
const auto &rows = buf->Rows();
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
std::size_t rowoffs = buf->Rowoffs();
// Scroll down by 3 lines (or count if specified), without moving cursor
int scroll_amount = ctx.count > 0 ? ctx.count : 3;
// Compute maximum top offset
std::size_t max_top = 0;
if (!rows.empty() && rows.size() > content_rows)
max_top = rows.size() - content_rows;
rowoffs += static_cast<std::size_t>(scroll_amount);
if (rowoffs > max_top)
rowoffs = max_top;
buf->SetOffsets(rowoffs, buf->Coloffs());
// If cursor is now above the visible area, move it to the first visible line
std::size_t cury = buf->Cury();
if (cury < rowoffs) {
buf->SetCursor(buf->Curx(), rowoffs);
}
return true; return true;
} }
@@ -3743,8 +3686,6 @@ InstallDefaultCommands()
CommandRegistry::Register({CommandId::MoveEnd, "end", "Move to end of line", cmd_move_end}); CommandRegistry::Register({CommandId::MoveEnd, "end", "Move to end of line", cmd_move_end});
CommandRegistry::Register({CommandId::PageUp, "page-up", "Page up", cmd_page_up}); CommandRegistry::Register({CommandId::PageUp, "page-up", "Page up", cmd_page_up});
CommandRegistry::Register({CommandId::PageDown, "page-down", "Page down", cmd_page_down}); CommandRegistry::Register({CommandId::PageDown, "page-down", "Page down", cmd_page_down});
CommandRegistry::Register({CommandId::ScrollUp, "scroll-up", "Scroll viewport up", cmd_scroll_up});
CommandRegistry::Register({CommandId::ScrollDown, "scroll-down", "Scroll viewport down", cmd_scroll_down});
CommandRegistry::Register({CommandId::WordPrev, "word-prev", "Move to previous word", cmd_word_prev}); CommandRegistry::Register({CommandId::WordPrev, "word-prev", "Move to previous word", cmd_word_prev});
CommandRegistry::Register({CommandId::WordNext, "word-next", "Move to next word", cmd_word_next}); CommandRegistry::Register({CommandId::WordNext, "word-next", "Move to next word", cmd_word_next});
CommandRegistry::Register({ CommandRegistry::Register({

View File

@@ -58,8 +58,6 @@ enum class CommandId {
MoveEnd, MoveEnd,
PageUp, PageUp,
PageDown, PageDown,
ScrollUp, // scroll viewport up (towards beginning) without moving cursor
ScrollDown, // scroll viewport down (towards end) without moving cursor
WordPrev, WordPrev,
WordNext, WordNext,
DeleteWordPrev, // delete previous word (ESC BACKSPACE) DeleteWordPrev, // delete previous word (ESC BACKSPACE)
@@ -100,6 +98,9 @@ enum class CommandId {
// Syntax highlighting // Syntax highlighting
Syntax, // ":syntax on|off|reload" Syntax, // ":syntax on|off|reload"
SetOption, // generic ":set key=value" (v1: filetype=<lang>) SetOption, // generic ":set key=value" (v1: filetype=<lang>)
// LSP
LspHover,
LspGotoDefinition,
}; };

View File

@@ -1,8 +1,11 @@
#include <algorithm> #include <algorithm>
#include <utility> #include <utility>
#include <filesystem> #include <filesystem>
#include "syntax/HighlighterRegistry.h"
#include "syntax/NullHighlighter.h"
#include "Editor.h" #include "Editor.h"
#include "lsp/LspManager.h"
#include "syntax/HighlighterRegistry.h" #include "syntax/HighlighterRegistry.h"
#include "syntax/CppHighlighter.h" #include "syntax/CppHighlighter.h"
#include "syntax/NullHighlighter.h" #include "syntax/NullHighlighter.h"
@@ -27,6 +30,15 @@ Editor::SetStatus(const std::string &message)
} }
void
Editor::NotifyBufferSaved(Buffer *buf)
{
if (lsp_manager_ && buf) {
lsp_manager_->onBufferSaved(buf);
}
}
Buffer * Buffer *
Editor::CurrentBuffer() Editor::CurrentBuffer()
{ {
@@ -179,6 +191,10 @@ Editor::OpenFile(const std::string &path, std::string &err)
eng->InvalidateFrom(0); eng->InvalidateFrom(0);
} }
} }
// Notify LSP (if wired) for current buffer open
if (lsp_manager_) {
lsp_manager_->onBufferOpened(&cur);
}
return true; return true;
} }
} }
@@ -214,6 +230,10 @@ Editor::OpenFile(const std::string &path, std::string &err)
// Add as a new buffer and switch to it // Add as a new buffer and switch to it
std::size_t idx = AddBuffer(std::move(b)); std::size_t idx = AddBuffer(std::move(b));
SwitchTo(idx); SwitchTo(idx);
// Notify LSP (if wired) for current buffer open
if (lsp_manager_) {
lsp_manager_->onBufferOpened(&buffers_[curbuf_]);
}
return true; return true;
} }

View File

@@ -11,6 +11,13 @@
#include "Buffer.h" #include "Buffer.h"
// fwd decl for LSP wiring
namespace kte {
namespace lsp {
class LspManager;
}
}
class Editor { class Editor {
public: public:
@@ -32,16 +39,6 @@ public:
} }
[[nodiscard]] std::size_t ContentRows() const
{
// Always compute from current rows_ to avoid stale values.
// Reserve 1 row for status line.
if (rows_ == 0)
return 1;
return std::max<std::size_t>(1, rows_ - 1);
}
// Mode and flags (mirroring legacy fields) // Mode and flags (mirroring legacy fields)
void SetMode(int m) void SetMode(int m)
{ {
@@ -446,6 +443,22 @@ public:
bool OpenFile(const std::string &path, std::string &err); bool OpenFile(const std::string &path, std::string &err);
// LSP: attach/detach manager
void SetLspManager(kte::lsp::LspManager *mgr)
{
lsp_manager_ = mgr;
}
// LSP helpers: trigger hover/definition at current cursor in current buffer
bool LspHoverAtCursor();
bool LspGotoDefinitionAtCursor();
// LSP: notify buffer saved (used by commands)
void NotifyBufferSaved(Buffer *buf);
// Buffer switching/closing // Buffer switching/closing
bool SwitchTo(std::size_t index); bool SwitchTo(std::size_t index);
@@ -561,6 +574,9 @@ public:
private: private:
std::string replace_find_tmp_; std::string replace_find_tmp_;
std::string replace_with_tmp_; std::string replace_with_tmp_;
// Non-owning pointer to LSP manager (if provided)
kte::lsp::LspManager *lsp_manager_ = nullptr;
}; };
#endif // KTE_EDITOR_H #endif // KTE_EDITOR_H

View File

@@ -13,7 +13,6 @@
#include "Editor.h" #include "Editor.h"
#include "Command.h" #include "Command.h"
#include "GUIFrontend.h" #include "GUIFrontend.h"
#include <filesystem>
#include "Font.h" // embedded default font (DefaultFontRegular) #include "Font.h" // embedded default font (DefaultFontRegular)
#include "GUIConfig.h" #include "GUIConfig.h"
#include "GUITheme.h" #include "GUITheme.h"
@@ -106,25 +105,7 @@ GUIFrontend::Init(Editor &ed)
IMGUI_CHECKVERSION(); IMGUI_CHECKVERSION();
ImGui::CreateContext(); ImGui::CreateContext();
ImGuiIO &io = ImGui::GetIO(); ImGuiIO &io = ImGui::GetIO();
(void) io;
// Set custom ini filename path to ~/.config/kte/imgui.ini
if (const char *home = std::getenv("HOME")) {
namespace fs = std::filesystem;
fs::path config_dir = fs::path(home) / ".config" / "kte";
std::error_code ec;
if (!fs::exists(config_dir)) {
fs::create_directories(config_dir, ec);
}
if (fs::exists(config_dir)) {
static std::string ini_path = (config_dir / "imgui.ini").string();
io.IniFilename = ini_path.c_str();
}
}
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls
ImGui::StyleColorsDark(); ImGui::StyleColorsDark();
// Apply background mode and selected theme (default: Nord). Can be changed at runtime via commands. // Apply background mode and selected theme (default: Nord). Can be changed at runtime via commands.
@@ -222,7 +203,28 @@ GUIFrontend::Step(Editor &ed, bool &running)
input_.ProcessSDLEvent(e); input_.ProcessSDLEvent(e);
} }
// Start a new ImGui frame BEFORE processing commands so dimensions are correct // Execute pending mapped inputs (drain queue)
for (;;) {
MappedInput mi;
if (!input_.Poll(mi))
break;
if (mi.hasCommand) {
// Track kill ring before and after to sync GUI clipboard when it changes
const std::string before = ed.KillRingHead();
Execute(ed, mi.id, mi.arg, mi.count);
const std::string after = ed.KillRingHead();
if (after != before && !after.empty()) {
// Update the system clipboard to mirror the kill ring head in GUI
SDL_SetClipboardText(after.c_str());
}
}
}
if (ed.QuitRequested()) {
running = false;
}
// Start a new ImGui frame
ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplSDL2_NewFrame(window_); ImGui_ImplSDL2_NewFrame(window_);
ImGui::NewFrame(); ImGui::NewFrame();
@@ -262,27 +264,6 @@ GUIFrontend::Step(Editor &ed, bool &running)
} }
} }
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
for (;;) {
MappedInput mi;
if (!input_.Poll(mi))
break;
if (mi.hasCommand) {
// Track kill ring before and after to sync GUI clipboard when it changes
const std::string before = ed.KillRingHead();
Execute(ed, mi.id, mi.arg, mi.count);
const std::string after = ed.KillRingHead();
if (after != before && !after.empty()) {
// Update the system clipboard to mirror the kill ring head in GUI
SDL_SetClipboardText(after.c_str());
}
}
}
if (ed.QuitRequested()) {
running = false;
}
// No runtime font UI; always use embedded font. // No runtime font UI; always use embedded font.
// Draw editor UI // Draw editor UI

View File

@@ -285,11 +285,15 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
bool produced = false; bool produced = false;
switch (e.type) { switch (e.type) {
case SDL_MOUSEWHEEL: { case SDL_MOUSEWHEEL: {
// Map vertical wheel to viewport scrolling (ScrollUp/ScrollDown) // If ImGui wants to capture the mouse (e.g., hovering the File Picker list),
// Note: We don't check WantCaptureMouse here because ImGui sets it to true // don't translate wheel events into editor scrolling.
// whenever the mouse is over any ImGui window (including our editor content area). // This prevents background buffer scroll while using GUI widgets.
// The NoScrollWithMouse flag on the child window prevents ImGui from handling ImGuiIO &io = ImGui::GetIO();
// scroll internally, so we can safely process wheel events ourselves. if (io.WantCaptureMouse) {
return true; // consumed by GUI
}
// Map vertical wheel to line-wise cursor movement (MoveUp/MoveDown)
int dy = e.wheel.y; int dy = e.wheel.y;
#ifdef SDL_MOUSEWHEEL_FLIPPED #ifdef SDL_MOUSEWHEEL_FLIPPED
if (e.wheel.direction == SDL_MOUSEWHEEL_FLIPPED) if (e.wheel.direction == SDL_MOUSEWHEEL_FLIPPED)
@@ -297,7 +301,7 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
#endif #endif
if (dy != 0) { if (dy != 0) {
int repeat = dy > 0 ? dy : -dy; int repeat = dy > 0 ? dy : -dy;
CommandId id = dy > 0 ? CommandId::ScrollUp : CommandId::ScrollDown; CommandId id = dy > 0 ? CommandId::MoveUp : CommandId::MoveDown;
std::lock_guard<std::mutex> lk(mu_); std::lock_guard<std::mutex> lk(mu_);
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
q_.push(MappedInput{true, id, std::string(), 0}); q_.push(MappedInput{true, id, std::string(), 0});
@@ -368,7 +372,7 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
// Digits without shift, or a plain '-' // Digits without shift, or a plain '-'
const bool is_digit_key = (key >= SDLK_0 && key <= SDLK_9) && !(mods & KMOD_SHIFT); const bool is_digit_key = (key >= SDLK_0 && key <= SDLK_9) && !(mods & KMOD_SHIFT);
const bool is_minus_key = (key == SDLK_MINUS); const bool is_minus_key = (key == SDLK_MINUS);
if (uarg_active_ && uarg_collecting_ &&(is_digit_key || is_minus_key)) { if (uarg_active_ && uarg_collecting_ && (is_digit_key || is_minus_key)) {
suppress_text_input_once_ = true; suppress_text_input_once_ = true;
} }
} }
@@ -560,12 +564,7 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
if (produced && mi.hasCommand) { if (produced && mi.hasCommand) {
// Attach universal-argument count if present, then clear the state // Attach universal-argument count if present, then clear the state
if (uarg_active_ &&mi if (uarg_active_ && mi.id != CommandId::UArgStatus) {
.
id != CommandId::UArgStatus
)
{
int count = 0; int count = 0;
if (!uarg_had_digits_ && !uarg_negative_) { if (!uarg_had_digits_ && !uarg_negative_) {
count = (uarg_value_ > 0) ? uarg_value_ : 4; count = (uarg_value_ > 0) ? uarg_value_ : 4;

View File

@@ -47,7 +47,6 @@ GUIRenderer::Draw(Editor &ed)
ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar
| ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollbar
| ImGuiWindowFlags_NoScrollWithMouse
| ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoResize
| ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoMove
| ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoCollapse
@@ -60,85 +59,73 @@ GUIRenderer::Draw(Editor &ed)
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 6.f)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 6.f));
ImGui::Begin("kte", nullptr, flags); ImGui::Begin("kte", nullptr, flags | ImGuiWindowFlags_NoScrollWithMouse);
const Buffer *buf = ed.CurrentBuffer(); const Buffer *buf = ed.CurrentBuffer();
if (!buf) { if (!buf) {
ImGui::TextUnformatted("[no buffer]"); ImGui::TextUnformatted("[no buffer]");
} else { } else {
const auto &lines = buf->Rows(); const auto &lines = buf->Rows();
// Reserve space for status bar at bottom
ImGui::BeginChild("scroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false,
ImGuiWindowFlags_HorizontalScrollbar);
// Detect click-to-move inside this scroll region
ImVec2 list_origin = ImGui::GetCursorScreenPos();
float scroll_y = ImGui::GetScrollY();
float scroll_x = ImGui::GetScrollX();
std::size_t rowoffs = 0; // we render from the first line; scrolling is handled by ImGui
std::size_t cy = buf->Cury(); std::size_t cy = buf->Cury();
std::size_t cx = buf->Curx(); std::size_t cx = buf->Curx();
const float line_h = ImGui::GetTextLineHeight(); const float line_h = ImGui::GetTextLineHeight();
const float row_h = ImGui::GetTextLineHeightWithSpacing(); const float row_h = ImGui::GetTextLineHeightWithSpacing();
const float space_w = ImGui::CalcTextSize(" ").x; const float space_w = ImGui::CalcTextSize(" ").x;
// Two-way sync between Buffer::Rowoffs and ImGui scroll position: // Two-way sync between Buffer::Rowoffs and ImGui scroll position:
// - If command layer changed Buffer::Rowoffs since last frame, drive ImGui scroll from it. // - If command layer changed Buffer::Rowoffs since last frame, drive ImGui scroll from it.
// - Otherwise, propagate ImGui scroll to Buffer::Rowoffs so command layer has an up-to-date view. // - Otherwise, propagate ImGui scroll to Buffer::Rowoffs so command layer has an up-to-date view.
static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs // This prevents clicks/wheel from being immediately overridden by stale offsets.
static long prev_buf_coloffs = -1; // previous frame's Buffer::Coloffs
const long buf_rowoffs = static_cast<long>(buf->Rowoffs());
const long buf_coloffs = static_cast<long>(buf->Coloffs());
// Detect programmatic change (e.g., page_down command changed rowoffs)
// Use SetNextWindowScroll BEFORE BeginChild to set initial scroll position
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) {
float target_y = static_cast<float>(buf_rowoffs) * row_h;
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y));
}
if (prev_buf_coloffs >= 0 && buf_coloffs != prev_buf_coloffs) {
float target_x = static_cast<float>(buf_coloffs) * space_w;
float target_y = static_cast<float>(buf_rowoffs) * row_h;
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
}
// Reserve space for status bar at bottom
ImGui::BeginChild("scroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false,
ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// Get child window position and scroll for click handling
ImVec2 child_window_pos = ImGui::GetWindowPos();
float scroll_y = ImGui::GetScrollY();
float scroll_x = ImGui::GetScrollX();
std::size_t rowoffs = 0; // we render from the first line; scrolling is handled by ImGui
// Synchronize buffer offsets from ImGui scroll if user scrolled manually
bool forced_scroll = false; bool forced_scroll = false;
{ {
static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs
static long prev_buf_coloffs = -1; // previous frame's Buffer::Coloffs
static float prev_scroll_y = -1.0f; // previous frame's ImGui scroll Y in pixels static float prev_scroll_y = -1.0f; // previous frame's ImGui scroll Y in pixels
static float prev_scroll_x = -1.0f; // previous frame's ImGui scroll X in pixels static float prev_scroll_x = -1.0f; // previous frame's ImGui scroll X in pixels
const long buf_rowoffs = static_cast<long>(buf->Rowoffs());
const long buf_coloffs = static_cast<long>(buf->Coloffs());
const long scroll_top = static_cast<long>(scroll_y / row_h); const long scroll_top = static_cast<long>(scroll_y / row_h);
const long scroll_left = static_cast<long>(scroll_x / space_w); const long scroll_left = static_cast<long>(scroll_x / space_w);
// Check if rowoffs was programmatically changed this frame // Detect programmatic change (e.g., keyboard navigation ensured visibility)
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) { if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) {
ImGui::SetScrollY(static_cast<float>(buf_rowoffs) * row_h);
scroll_y = ImGui::GetScrollY();
forced_scroll = true; forced_scroll = true;
} }
if (prev_buf_coloffs >= 0 && buf_coloffs != prev_buf_coloffs) {
// If user scrolled (not programmatic), update buffer offsets accordingly ImGui::SetScrollX(static_cast<float>(buf_coloffs) * space_w);
if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y && !forced_scroll) { scroll_x = ImGui::GetScrollX();
if (Buffer *mbuf = const_cast<Buffer *>(buf)) { forced_scroll = true;
}
// If user scrolled, update buffer offsets accordingly
if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y) {
if (auto mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)), mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
mbuf->Coloffs()); mbuf->Coloffs());
} }
} }
if (prev_scroll_x >= 0.0f && scroll_x != prev_scroll_x && !forced_scroll) { if (prev_scroll_x >= 0.0f && scroll_x != prev_scroll_x) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) { if (auto mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetOffsets(mbuf->Rowoffs(), mbuf->SetOffsets(mbuf->Rowoffs(),
static_cast<std::size_t>(std::max(0L, scroll_left))); static_cast<std::size_t>(std::max(0L, scroll_left)));
} }
} }
// Update trackers for next frame // Update trackers for next frame
prev_scroll_y = scroll_y; prev_buf_rowoffs = static_cast<long>(buf->Rowoffs());
prev_scroll_x = scroll_x; prev_buf_coloffs = static_cast<long>(buf->Coloffs());
prev_scroll_y = ImGui::GetScrollY();
prev_scroll_x = ImGui::GetScrollX();
} }
prev_buf_rowoffs = buf_rowoffs;
prev_buf_coloffs = buf_coloffs;
// Synchronize cursor and scrolling. // Synchronize cursor and scrolling.
// Ensure the cursor is visible even on the first frame or when it didn't move, // Ensure the cursor is visible even on the first frame or when it didn't move,
// unless we already forced scrolling from Buffer::Rowoffs this frame. // unless we already forced scrolling from Buffer::Rowoffs this frame.
@@ -174,19 +161,29 @@ GUIRenderer::Draw(Editor &ed)
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version()); buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
} }
} }
// Handle mouse click before rendering to avoid dependent on drawn items // Handle mouse click before rendering to avoid dependency on drawn items
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImVec2 mp = ImGui::GetIO().MousePos; ImVec2 mp = ImGui::GetIO().MousePos;
// Compute content-relative position accounting for scroll // Compute viewport-relative row so (0) is top row of the visible area
// mp.y - child_window_pos.y gives us pixels from top of child window // Note: list_origin is already in the scrolled space of the child window,
// Adding scroll_y gives us pixels from top of content (buffer row 0) // so we must NOT subtract scroll_y again (would double-apply).
float content_y = (mp.y - child_window_pos.y) + scroll_y; float vy_f = (mp.y - list_origin.y) / row_h;
long by_l = static_cast<long>(content_y / row_h); long vy = static_cast<long>(vy_f);
if (by_l < 0) if (vy < 0)
by_l = 0; vy = 0;
// Convert to buffer row // Clamp vy within visible content height to avoid huge jumps
std::size_t by = static_cast<std::size_t>(by_l); ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
float child_h = (cr_max.y - cr_min.y);
long vis_rows = static_cast<long>(child_h / row_h);
if (vis_rows < 1)
vis_rows = 1;
if (vy >= vis_rows)
vy = vis_rows - 1;
// Translate viewport row to buffer row using Buffer::Rowoffs
std::size_t by = buf->Rowoffs() + static_cast<std::size_t>(vy);
if (by >= lines.size()) { if (by >= lines.size()) {
if (!lines.empty()) if (!lines.empty())
by = lines.size() - 1; by = lines.size() - 1;
@@ -194,43 +191,59 @@ GUIRenderer::Draw(Editor &ed)
by = 0; by = 0;
} }
// Compute content-relative X position accounting for scroll // Compute desired pixel X inside the viewport content.
// mp.x - child_window_pos.x gives us pixels from left edge of child window // list_origin is already scrolled; do not subtract scroll_x here.
// Adding scroll_x gives us pixels from left edge of content (column 0) float px = (mp.x - list_origin.x);
float content_x = (mp.x - child_window_pos.x) + scroll_x; if (px < 0.0f)
if (content_x < 0.0f) px = 0.0f;
content_x = 0.0f;
// Empty buffer guard: if there are no lines yet, just move to 0:0 // Empty buffer guard: if there are no lines yet, just move to 0:0
if (lines.empty()) { if (lines.empty()) {
Execute(ed, CommandId::MoveCursorTo, std::string("0:0")); Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
} else { } else {
// Convert pixel X to source column accounting for tabs // Convert pixel X to a render-column target including horizontal col offset
// Use our own tab expansion of width 8 to match command layer logic.
std::string line_clicked = static_cast<std::string>(lines[by]); std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = 8; const std::size_t tabw = 8;
// We iterate source columns computing absolute rendered column (rx_abs) from 0,
// then translate to viewport-space by subtracting Coloffs.
std::size_t coloffs = buf->Coloffs();
std::size_t rx_abs = 0; // absolute rendered column
std::size_t i = 0; // source column iterator
// Iterate through source columns, computing rendered position, to find closest match // Fast-forward i until rx_abs >= coloffs to align with leftmost visible column
std::size_t rx = 0; // rendered column position if (!line_clicked.empty() && coloffs > 0) {
std::size_t best_col = 0; while (i < line_clicked.size() && rx_abs < coloffs) {
if (line_clicked[i] == '\t') {
rx_abs += (tabw - (rx_abs % tabw));
} else {
rx_abs += 1;
}
++i;
}
}
// Now search for closest source column to clicked px within/after viewport
std::size_t best_col = i; // default to first visible column
float best_dist = std::numeric_limits<float>::infinity(); float best_dist = std::numeric_limits<float>::infinity();
while (true) {
for (std::size_t i = 0; i <= line_clicked.size(); ++i) { // For i in [current..size], evaluate candidate including the implicit end position
// Check current position std::size_t rx_view = (rx_abs >= coloffs) ? (rx_abs - coloffs) : 0;
float rx_px = static_cast<float>(rx) * space_w; float rx_px = static_cast<float>(rx_view) * space_w;
float dist = std::fabs(content_x - rx_px); float dist = std::fabs(px - rx_px);
if (dist < best_dist) { if (dist <= best_dist) {
best_dist = dist; best_dist = dist;
best_col = i; best_col = i;
} }
if (i == line_clicked.size())
// Advance to next position if not at end break;
if (i < line_clicked.size()) { // advance to next source column
if (line_clicked[i] == '\t') { if (line_clicked[i] == '\t') {
rx += (tabw - (rx % tabw)); rx_abs += (tabw - (rx_abs % tabw));
} else { } else {
rx += 1; rx_abs += 1;
}
} }
++i;
} }
// Dispatch absolute buffer coordinates (row:col) // Dispatch absolute buffer coordinates (row:col)
@@ -244,10 +257,10 @@ GUIRenderer::Draw(Editor &ed)
for (std::size_t i = rowoffs; i < lines.size(); ++i) { for (std::size_t i = rowoffs; i < lines.size(); ++i) {
// Capture the screen position before drawing the line // Capture the screen position before drawing the line
ImVec2 line_pos = ImGui::GetCursorScreenPos(); ImVec2 line_pos = ImGui::GetCursorScreenPos();
std::string line = static_cast<std::string>(lines[i]); auto line = static_cast<std::string>(lines[i]);
// Expand tabs to spaces with width=8 and apply horizontal scroll offset // Expand tabs to spaces with width=8 and apply horizontal scroll offset
const std::size_t tabw = 8; constexpr std::size_t tabw = 8;
std::string expanded; std::string expanded;
expanded.reserve(line.size() + 16); expanded.reserve(line.size() + 16);
std::size_t rx_abs_draw = 0; // rendered column for drawing std::size_t rx_abs_draw = 0; // rendered column for drawing
@@ -264,7 +277,7 @@ GUIRenderer::Draw(Editor &ed)
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx); for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
it != std::sregex_iterator(); ++it) { it != std::sregex_iterator(); ++it) {
const auto &m = *it; const auto &m = *it;
std::size_t sx = static_cast<std::size_t>(m.position()); auto sx = static_cast<std::size_t>(m.position());
std::size_t ex = sx + static_cast<std::size_t>(m.length()); std::size_t ex = sx + static_cast<std::size_t>(m.length());
hl_src_ranges.emplace_back(sx, ex); hl_src_ranges.emplace_back(sx, ex);
} }
@@ -307,8 +320,8 @@ GUIRenderer::Draw(Editor &ed)
continue; // fully left of view continue; // fully left of view
std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0; std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0;
std::size_t vx1 = rx_end - coloffs_now; std::size_t vx1 = rx_end - coloffs_now;
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y); auto p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w, auto p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
line_pos.y + line_h); line_pos.y + line_h);
// Choose color: current match stronger // Choose color: current match stronger
bool is_current = has_current && sx == cur_x && ex == cur_end; bool is_current = has_current && sx == cur_x && ex == cur_end;
@@ -336,7 +349,7 @@ GUIRenderer::Draw(Editor &ed)
const kte::LineHighlight &lh = buf->Highlighter()->GetLine( const kte::LineHighlight &lh = buf->Highlighter()->GetLine(
*buf, static_cast<int>(i), buf->Version()); *buf, static_cast<int>(i), buf->Version());
// Helper to convert a src column to expanded rx position // Helper to convert a src column to expanded rx position
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t { auto src_to_rx_full = [&](const std::size_t sidx) -> std::size_t {
std::size_t rx = 0; std::size_t rx = 0;
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) { for (std::size_t k = 0; k < sidx && k < line.size(); ++k) {
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1; rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
@@ -358,12 +371,13 @@ GUIRenderer::Draw(Editor &ed)
if (vx1 <= vx0) if (vx1 <= vx0)
continue; continue;
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind)); ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind));
ImVec2 p = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y); auto p = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
ImGui::GetWindowDrawList()->AddText( ImGui::GetWindowDrawList()->AddText(
p, col, expanded.c_str() + vx0, expanded.c_str() + vx1); p, col, expanded.c_str() + vx0, expanded.c_str() + vx1);
} }
// We drew text via draw list (no layout advance). Manually advance the cursor to the next line. // We drew text via draw list (no layout advance). Advance by the same amount
// Use row_h (with spacing) to match click calculation and ensure consistent line positions. // ImGui uses between lines (line height + spacing) so hit-testing (which
// divides by row_h) aligns with drawing.
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h)); ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} else { } else {
// No syntax: draw as one run // No syntax: draw as one run
@@ -407,7 +421,7 @@ GUIRenderer::Draw(Editor &ed)
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
// If a prompt is active, replace the entire status bar with the prompt text // If a prompt is active, replace the entire status bar with the prompt text
if (ed.PromptActive()) { if (ed.PromptActive()) {
std::string label = ed.PromptLabel(); const std::string &label = ed.PromptLabel();
std::string ptext = ed.PromptText(); std::string ptext = ed.PromptText();
auto kind = ed.CurrentPromptKind(); auto kind = ed.CurrentPromptKind();
if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
@@ -456,8 +470,8 @@ GUIRenderer::Draw(Editor &ed)
float ratio = tail_sz.x / avail_px; float ratio = tail_sz.x / avail_px;
size_t skip = ratio > 1.5f size_t skip = ratio > 1.5f
? std::min(tail.size() - start, ? std::min(tail.size() - start,
(size_t) std::max<size_t>( static_cast<size_t>(std::max<size_t>(
1, (size_t) (tail.size() / 4))) 1, tail.size() / 4)))
: 1; : 1;
start += skip; start += skip;
std::string candidate = tail.substr(start); std::string candidate = tail.substr(start);
@@ -514,8 +528,7 @@ GUIRenderer::Draw(Editor &ed)
left += " "; left += " ";
// Insert buffer position prefix "[x/N] " before filename // Insert buffer position prefix "[x/N] " before filename
{ {
std::size_t total = ed.BufferCount(); if (std::size_t total = ed.BufferCount(); total > 0) {
if (total > 0) {
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display
left += "["; left += "[";
left += std::to_string(static_cast<unsigned long long>(idx1)); left += std::to_string(static_cast<unsigned long long>(idx1));
@@ -529,7 +542,7 @@ GUIRenderer::Draw(Editor &ed)
left += " *"; left += " *";
// Append total line count as "<n>L" // Append total line count as "<n>L"
{ {
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size()); auto lcount = buf->Rows().size();
left += " "; left += " ";
left += std::to_string(lcount); left += std::to_string(lcount);
left += "L"; left += "L";
@@ -615,9 +628,9 @@ GUIRenderer::Draw(Editor &ed)
ImGuiViewport *vp2 = ImGui::GetMainViewport(); ImGuiViewport *vp2 = ImGui::GetMainViewport();
// Desired size, min size, and margins // Desired size, min size, and margins
const ImVec2 want(800.0f, 500.0f); constexpr ImVec2 want(800.0f, 500.0f);
const ImVec2 min_sz(240.0f, 160.0f); constexpr ImVec2 min_sz(240.0f, 160.0f);
const float margin = 20.0f; // space from viewport edges constexpr float margin = 20.0f; // space from viewport edges
// Compute the maximum allowed size (viewport minus margins) and make sure it's not negative // Compute the maximum allowed size (viewport minus margins) and make sure it's not negative
ImVec2 max_sz(std::max(32.0f, vp2->Size.x - 2.0f * margin), ImVec2 max_sz(std::max(32.0f, vp2->Size.x - 2.0f * margin),

View File

@@ -2,11 +2,9 @@ ROADMAP / TODO:
- [x] Search + Replace - [x] Search + Replace
- [x] Regex search + replace - [x] Regex search + replace
- [ ] The undo system should actually work
- [x] Able to mark buffers as read-only - [x] Able to mark buffers as read-only
- [x] Built-in help text - [x] Built-in help text
- [x] Shorten paths in the homedir with ~ - [x] Shorten paths in the homedir with ~
- [x] When the filename is longer than the message window, scoot left to - [x] When the filename is longer than the message window, scoot left to
keep it in view keep it in view
- [x] Syntax highlighting
- [ ] The undo system should actually work
- [ ] LSP integration

View File

@@ -1,6 +1,12 @@
#include <ncurses.h> #include <ncurses.h>
#include <clocale>
#include <termios.h> #include <termios.h>
#include <unistd.h> #include <unistd.h>
#ifdef __APPLE__
#include <xlocale.h>
#endif
#include <langinfo.h>
#include <cctype>
#include "TerminalFrontend.h" #include "TerminalFrontend.h"
#include "Command.h" #include "Command.h"
@@ -10,6 +16,35 @@
bool bool
TerminalFrontend::Init(Editor &ed) TerminalFrontend::Init(Editor &ed)
{ {
// Enable UTF-8 locale so ncurses and the terminal handle multibyte correctly
// This relies on the user's environment (e.g., LANG/LC_ALL) being set to a UTF-8 locale.
// If not set, try a couple of common UTF-8 fallbacks.
const char *loc = std::setlocale(LC_ALL, "");
auto is_utf8_codeset = []() -> bool {
const char *cs = nl_langinfo(CODESET);
if (!cs)
return false;
std::string s(cs);
for (auto &ch: s)
ch = static_cast<char>(std::toupper(static_cast<unsigned char>(ch)));
return (s.find("UTF-8") != std::string::npos) || (s.find("UTF8") != std::string::npos);
};
bool utf8_ok = (MB_CUR_MAX > 1) && is_utf8_codeset();
if (!utf8_ok) {
// Try common UTF-8 locales
loc = std::setlocale(LC_CTYPE, "C.UTF-8");
utf8_ok = (loc != nullptr) && (MB_CUR_MAX > 1) && is_utf8_codeset();
if (!utf8_ok) {
loc = std::setlocale(LC_CTYPE, "en_US.UTF-8");
utf8_ok = (loc != nullptr) && (MB_CUR_MAX > 1) && is_utf8_codeset();
}
if (!utf8_ok) {
// macOS often uses plain "UTF-8" locale identifier
loc = std::setlocale(LC_CTYPE, "UTF-8");
utf8_ok = (loc != nullptr) && (MB_CUR_MAX > 1) && is_utf8_codeset();
}
}
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS) // Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
{ {
struct termios tio{}; struct termios tio{};
@@ -55,6 +90,9 @@ TerminalFrontend::Init(Editor &ed)
prev_r_ = r; prev_r_ = r;
prev_c_ = c; prev_c_ = c;
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c)); ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
// Inform renderer of UTF-8 capability so it can choose proper output path
renderer_.SetUtf8Enabled(utf8_ok);
input_.SetUtf8Enabled(utf8_ok);
return true; return true;
} }

View File

@@ -1,4 +1,6 @@
#include <cstdio> #include <cstdio>
#include <cwchar>
#include <climits>
#include <ncurses.h> #include <ncurses.h>
#include "TerminalInputHandler.h" #include "TerminalInputHandler.h"
@@ -35,19 +37,49 @@ map_key_to_command(const int ch,
case KEY_MOUSE: { case KEY_MOUSE: {
MEVENT ev{}; MEVENT ev{};
if (getmouse(&ev) == OK) { if (getmouse(&ev) == OK) {
// Mouse wheel → scroll viewport without moving cursor // Mouse wheel → map to MoveUp/MoveDown one line per wheel notch
unsigned long wheel_up_mask = 0;
unsigned long wheel_dn_mask = 0;
#ifdef BUTTON4_PRESSED #ifdef BUTTON4_PRESSED
if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) { wheel_up_mask |= BUTTON4_PRESSED;
out = {true, CommandId::ScrollUp, "", 0}; #endif
return true; #ifdef BUTTON4_RELEASED
} wheel_up_mask |= BUTTON4_RELEASED;
#endif
#ifdef BUTTON4_CLICKED
wheel_up_mask |= BUTTON4_CLICKED;
#endif
#ifdef BUTTON4_DOUBLE_CLICKED
wheel_up_mask |= BUTTON4_DOUBLE_CLICKED;
#endif
#ifdef BUTTON4_TRIPLE_CLICKED
wheel_up_mask |= BUTTON4_TRIPLE_CLICKED;
#endif #endif
#ifdef BUTTON5_PRESSED #ifdef BUTTON5_PRESSED
if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) { wheel_dn_mask |= BUTTON5_PRESSED;
out = {true, CommandId::ScrollDown, "", 0}; #endif
#ifdef BUTTON5_RELEASED
wheel_dn_mask |= BUTTON5_RELEASED;
#endif
#ifdef BUTTON5_CLICKED
wheel_dn_mask |= BUTTON5_CLICKED;
#endif
#ifdef BUTTON5_DOUBLE_CLICKED
wheel_dn_mask |= BUTTON5_DOUBLE_CLICKED;
#endif
#ifdef BUTTON5_TRIPLE_CLICKED
wheel_dn_mask |= BUTTON5_TRIPLE_CLICKED;
#endif
if (wheel_up_mask && (ev.bstate & wheel_up_mask)) {
// Prefer viewport scrolling for wheel: page up
out = {true, CommandId::PageUp, "", 0};
return true;
}
if (wheel_dn_mask && (ev.bstate & wheel_dn_mask)) {
// Prefer viewport scrolling for wheel: page down
out = {true, CommandId::PageDown, "", 0};
return true; return true;
} }
#endif
// React to left button click/press // React to left button click/press
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) { if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) {
char buf[64]; char buf[64];
@@ -281,6 +313,62 @@ map_key_to_command(const int ch,
bool bool
TerminalInputHandler::decode_(MappedInput &out) TerminalInputHandler::decode_(MappedInput &out)
{ {
#if defined(KTE_HAVE_GET_WCH)
if (utf8_enabled_) {
// Prefer wide-character input so we can capture Unicode code points
wint_t wch = 0;
int rc = get_wch(&wch);
if (rc == ERR) {
return false;
}
if (rc == KEY_CODE_YES) {
// Function/special key; pass through existing mapper
int sk = static_cast<int>(wch);
bool consumed = map_key_to_command(
sk,
k_prefix_, esc_meta_,
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_,
uarg_text_,
out);
if (!consumed)
return false;
} else {
// Regular character
if (wch <= 0x7F) {
// ASCII path -> reuse existing mapping (handles control, ESC, etc.)
int ch = static_cast<int>(wch);
bool consumed = map_key_to_command(
ch,
k_prefix_, esc_meta_,
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_,
uarg_text_,
out);
if (!consumed)
return false;
} else {
// Non-ASCII printable -> insert UTF-8 text directly
if (iswcntrl(static_cast<wint_t>(wch))) {
out.hasCommand = false;
return true;
}
char mb[MB_LEN_MAX];
mbstate_t st{};
std::size_t n = wcrtomb(mb, static_cast<wchar_t>(wch), &st);
if (n == static_cast<std::size_t>(-1)) {
// Fallback placeholder if encoding failed
out.hasCommand = true;
out.id = CommandId::InsertText;
out.arg = "?";
out.count = 0;
} else {
out.hasCommand = true;
out.id = CommandId::InsertText;
out.arg.assign(mb, mb + n);
out.count = 0;
}
}
}
} else {
int ch = getch(); int ch = getch();
if (ch == ERR) { if (ch == ERR) {
return false; // no input return false; // no input
@@ -292,6 +380,22 @@ TerminalInputHandler::decode_(MappedInput &out)
out); out);
if (!consumed) if (!consumed)
return false; return false;
}
#else
// Wide-character input not available in this curses; fall back to byte-wise getch
(void) utf8_enabled_;
int ch = getch();
if (ch == ERR) {
return false; // no input
}
bool consumed = map_key_to_command(
ch,
k_prefix_, esc_meta_,
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_, uarg_text_,
out);
if (!consumed)
return false;
#endif
// If a command was produced and a universal argument is active, attach it and clear state // If a command was produced and a universal argument is active, attach it and clear state
if (out.hasCommand && uarg_active_ && out.id != CommandId::UArgStatus) { if (out.hasCommand && uarg_active_ && out.id != CommandId::UArgStatus) {
int count = 0; int count = 0;

View File

@@ -15,6 +15,12 @@ public:
bool Poll(MappedInput &out) override; bool Poll(MappedInput &out) override;
void SetUtf8Enabled(bool on)
{
utf8_enabled_ = on;
}
private: private:
bool decode_(MappedInput &out); bool decode_(MappedInput &out);
@@ -30,6 +36,8 @@ private:
bool uarg_had_digits_ = false; // whether any digits were supplied bool uarg_had_digits_ = false; // whether any digits were supplied
int uarg_value_ = 0; // current absolute value (>=0) int uarg_value_ = 0; // current absolute value (>=0)
std::string uarg_text_; // raw digits/minus typed for status display std::string uarg_text_; // raw digits/minus typed for status display
bool utf8_enabled_ = true;
}; };
#endif // KTE_TERMINAL_INPUT_HANDLER_H #endif // KTE_TERMINAL_INPUT_HANDLER_H

View File

@@ -4,6 +4,7 @@
#include <cstdlib> #include <cstdlib>
#include <ncurses.h> #include <ncurses.h>
#include <regex> #include <regex>
#include <cwchar>
#include <string> #include <string>
#include "TerminalRenderer.h" #include "TerminalRenderer.h"
@@ -34,8 +35,6 @@ TerminalRenderer::Draw(Editor &ed)
const Buffer *buf = ed.CurrentBuffer(); const Buffer *buf = ed.CurrentBuffer();
int content_rows = rows - 1; // last line is status int content_rows = rows - 1; // last line is status
if (content_rows < 1)
content_rows = 1;
int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area
if (buf) { if (buf) {
@@ -152,8 +151,10 @@ TerminalRenderer::Draw(Editor &ed)
} }
}; };
while (written < cols) { while (written < cols) {
char ch = ' '; // Default to space when beyond EOL
bool from_src = false; bool from_src = false;
int wcw = 1; // display width
std::size_t advance_bytes = 0;
if (src_i < line.size()) { if (src_i < line.size()) {
unsigned char c = static_cast<unsigned char>(line[src_i]); unsigned char c = static_cast<unsigned char>(line[src_i]);
if (c == '\t') { if (c == '\t') {
@@ -211,18 +212,46 @@ TerminalRenderer::Draw(Editor &ed)
++src_i; ++src_i;
continue; continue;
} else { } else {
// normal char if (!Utf8Enabled()) {
if (render_col < coloffs) { // ASCII fallback: treat each byte as single width
if (render_col + 1 <= coloffs) {
++render_col; ++render_col;
++src_i; ++src_i;
continue; continue;
} }
ch = static_cast<char>(c); wcw = 1;
advance_bytes = 1;
from_src = true; from_src = true;
} else {
// Decode one UTF-8 codepoint
mbstate_t st{};
const char *p = line.data() + src_i;
std::size_t rem = line.size() - src_i;
wchar_t tmp_wc = 0;
std::size_t n = mbrtowc(&tmp_wc, p, rem, &st);
if (n == static_cast<std::size_t>(-1) || n ==
static_cast<std::size_t>(-2) || n == 0) {
// Invalid/incomplete -> treat as single-byte placeholder
tmp_wc = L'?';
n = 1;
}
int w = wcwidth(tmp_wc);
if (w < 0)
w = 1;
// If this codepoint is scrolled off to the left, skip it
if (render_col + static_cast<std::size_t>(w) <=
coloffs) {
render_col += static_cast<std::size_t>(w);
src_i += n;
continue;
}
wcw = w;
advance_bytes = n;
from_src = true;
}
} }
} else { } else {
// beyond EOL, fill spaces // beyond EOL, fill spaces
ch = ' ';
from_src = false; from_src = false;
} }
bool in_hl = search_mode && from_src && is_src_in_hl(src_i); bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
@@ -248,11 +277,20 @@ TerminalRenderer::Draw(Editor &ed)
if (!in_hl && from_src) { if (!in_hl && from_src) {
apply_token_attr(token_at(src_i)); apply_token_attr(token_at(src_i));
} }
addch(static_cast<unsigned char>(ch)); if (written + wcw > cols) {
++written; break;
++render_col; }
if (from_src) if (from_src) {
++src_i; // Output original bytes for this unit (UTF-8 codepoint or ASCII byte)
const char *cp = line.data() + (src_i);
int out_n = Utf8Enabled() ? static_cast<int>(advance_bytes) : 1;
addnstr(cp, out_n);
src_i += static_cast<std::size_t>(out_n);
} else {
addch(' ');
}
written += wcw;
render_col += wcw;
if (src_i >= line.size() && written >= cols) if (src_i >= line.size() && written >= cols)
break; break;
} }
@@ -424,6 +462,10 @@ TerminalRenderer::Draw(Editor &ed)
else else
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: not set", row1, col1); std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: not set", row1, col1);
right = rbuf; right = rbuf;
// If UTF-8 is not enabled (ASCII fallback), append a short hint
if (!Utf8Enabled()) {
right += " | ASCII";
}
} }
// Compute placements with truncation rules: prioritize left and right; middle gets remaining // Compute placements with truncation rules: prioritize left and right; middle gets remaining

View File

@@ -14,6 +14,21 @@ public:
~TerminalRenderer() override; ~TerminalRenderer() override;
void Draw(Editor &ed) override; void Draw(Editor &ed) override;
// Enable/disable UTF-8 aware rendering (set by TerminalFrontend after locale init)
void SetUtf8Enabled(bool on)
{
utf8_enabled_ = on;
}
[[nodiscard]] bool Utf8Enabled() const
{
return utf8_enabled_;
}
private:
bool utf8_enabled_ = true;
}; };
#endif // KTE_TERMINAL_RENDERER_H #endif // KTE_TERMINAL_RENDERER_H

25626
ext/json.h Normal file

File diff suppressed because it is too large Load Diff

185
ext/json_fwd.h Normal file
View File

@@ -0,0 +1,185 @@
// __ _____ _____ _____
// __| | __| | | | JSON for Modern C++
// | | |__ | | | | | | version 3.12.0
// |_____|_____|_____|_|___| https://github.com/nlohmann/json
//
// SPDX-FileCopyrightText: 2013-2025 Niels Lohmann <https://nlohmann.me>
// SPDX-License-Identifier: MIT
#ifndef INCLUDE_NLOHMANN_JSON_FWD_HPP_
#define INCLUDE_NLOHMANN_JSON_FWD_HPP_
#include <cstdint> // int64_t, uint64_t
#include <map> // map
#include <memory> // allocator
#include <string> // string
#include <vector> // vector
// #include <nlohmann/detail/abi_macros.hpp>
// __ _____ _____ _____
// __| | __| | | | JSON for Modern C++
// | | |__ | | | | | | version 3.12.0
// |_____|_____|_____|_|___| https://github.com/nlohmann/json
//
// SPDX-FileCopyrightText: 2013-2025 Niels Lohmann <https://nlohmann.me>
// SPDX-License-Identifier: MIT
// This file contains all macro definitions affecting or depending on the ABI
#ifndef JSON_SKIP_LIBRARY_VERSION_CHECK
#if defined(NLOHMANN_JSON_VERSION_MAJOR) && defined(NLOHMANN_JSON_VERSION_MINOR) && defined(NLOHMANN_JSON_VERSION_PATCH)
#if NLOHMANN_JSON_VERSION_MAJOR != 3 || NLOHMANN_JSON_VERSION_MINOR != 12 || NLOHMANN_JSON_VERSION_PATCH != 0
#warning "Already included a different version of the library!"
#endif
#endif
#endif
#define NLOHMANN_JSON_VERSION_MAJOR 3 // NOLINT(modernize-macro-to-enum)
#define NLOHMANN_JSON_VERSION_MINOR 12 // NOLINT(modernize-macro-to-enum)
#define NLOHMANN_JSON_VERSION_PATCH 0 // NOLINT(modernize-macro-to-enum)
#ifndef JSON_DIAGNOSTICS
#define JSON_DIAGNOSTICS 0
#endif
#ifndef JSON_DIAGNOSTIC_POSITIONS
#define JSON_DIAGNOSTIC_POSITIONS 0
#endif
#ifndef JSON_USE_LEGACY_DISCARDED_VALUE_COMPARISON
#define JSON_USE_LEGACY_DISCARDED_VALUE_COMPARISON 0
#endif
#if JSON_DIAGNOSTICS
#define NLOHMANN_JSON_ABI_TAG_DIAGNOSTICS _diag
#else
#define NLOHMANN_JSON_ABI_TAG_DIAGNOSTICS
#endif
#if JSON_DIAGNOSTIC_POSITIONS
#define NLOHMANN_JSON_ABI_TAG_DIAGNOSTIC_POSITIONS _dp
#else
#define NLOHMANN_JSON_ABI_TAG_DIAGNOSTIC_POSITIONS
#endif
#if JSON_USE_LEGACY_DISCARDED_VALUE_COMPARISON
#define NLOHMANN_JSON_ABI_TAG_LEGACY_DISCARDED_VALUE_COMPARISON _ldvcmp
#else
#define NLOHMANN_JSON_ABI_TAG_LEGACY_DISCARDED_VALUE_COMPARISON
#endif
#ifndef NLOHMANN_JSON_NAMESPACE_NO_VERSION
#define NLOHMANN_JSON_NAMESPACE_NO_VERSION 0
#endif
// Construct the namespace ABI tags component
#define NLOHMANN_JSON_ABI_TAGS_CONCAT_EX(a, b, c) json_abi ## a ## b ## c
#define NLOHMANN_JSON_ABI_TAGS_CONCAT(a, b, c) \
NLOHMANN_JSON_ABI_TAGS_CONCAT_EX(a, b, c)
#define NLOHMANN_JSON_ABI_TAGS \
NLOHMANN_JSON_ABI_TAGS_CONCAT( \
NLOHMANN_JSON_ABI_TAG_DIAGNOSTICS, \
NLOHMANN_JSON_ABI_TAG_LEGACY_DISCARDED_VALUE_COMPARISON, \
NLOHMANN_JSON_ABI_TAG_DIAGNOSTIC_POSITIONS)
// Construct the namespace version component
#define NLOHMANN_JSON_NAMESPACE_VERSION_CONCAT_EX(major, minor, patch) \
_v ## major ## _ ## minor ## _ ## patch
#define NLOHMANN_JSON_NAMESPACE_VERSION_CONCAT(major, minor, patch) \
NLOHMANN_JSON_NAMESPACE_VERSION_CONCAT_EX(major, minor, patch)
#if NLOHMANN_JSON_NAMESPACE_NO_VERSION
#define NLOHMANN_JSON_NAMESPACE_VERSION
#else
#define NLOHMANN_JSON_NAMESPACE_VERSION \
NLOHMANN_JSON_NAMESPACE_VERSION_CONCAT(NLOHMANN_JSON_VERSION_MAJOR, \
NLOHMANN_JSON_VERSION_MINOR, \
NLOHMANN_JSON_VERSION_PATCH)
#endif
// Combine namespace components
#define NLOHMANN_JSON_NAMESPACE_CONCAT_EX(a, b) a ## b
#define NLOHMANN_JSON_NAMESPACE_CONCAT(a, b) \
NLOHMANN_JSON_NAMESPACE_CONCAT_EX(a, b)
#ifndef NLOHMANN_JSON_NAMESPACE
#define NLOHMANN_JSON_NAMESPACE \
nlohmann::NLOHMANN_JSON_NAMESPACE_CONCAT( \
NLOHMANN_JSON_ABI_TAGS, \
NLOHMANN_JSON_NAMESPACE_VERSION)
#endif
#ifndef NLOHMANN_JSON_NAMESPACE_BEGIN
#define NLOHMANN_JSON_NAMESPACE_BEGIN \
namespace nlohmann \
{ \
inline namespace NLOHMANN_JSON_NAMESPACE_CONCAT( \
NLOHMANN_JSON_ABI_TAGS, \
NLOHMANN_JSON_NAMESPACE_VERSION) \
{
#endif
#ifndef NLOHMANN_JSON_NAMESPACE_END
#define NLOHMANN_JSON_NAMESPACE_END \
} /* namespace (inline namespace) NOLINT(readability/namespace) */ \
} // namespace nlohmann
#endif
/*!
@brief namespace for Niels Lohmann
@see https://github.com/nlohmann
@since version 1.0.0
*/
NLOHMANN_JSON_NAMESPACE_BEGIN
/*!
@brief default JSONSerializer template argument
This serializer ignores the template arguments and uses ADL
([argument-dependent lookup](https://en.cppreference.com/w/cpp/language/adl))
for serialization.
*/
template<typename T = void, typename SFINAE = void>
struct adl_serializer;
/// a class to store JSON values
/// @sa https://json.nlohmann.me/api/basic_json/
template<template<typename U, typename V, typename... Args> class ObjectType =
std::map,
template<typename U, typename... Args> class ArrayType = std::vector,
class StringType = std::string, class BooleanType = bool,
class NumberIntegerType = std::int64_t,
class NumberUnsignedType = std::uint64_t,
class NumberFloatType = double,
template<typename U> class AllocatorType = std::allocator,
template<typename T, typename SFINAE = void> class JSONSerializer =
adl_serializer,
class BinaryType = std::vector<std::uint8_t>, // cppcheck-suppress syntaxError
class CustomBaseClass = void>
class basic_json;
/// @brief JSON Pointer defines a string syntax for identifying a specific value within a JSON document
/// @sa https://json.nlohmann.me/api/json_pointer/
template<typename RefStringType>
class json_pointer;
/*!
@brief default specialization
@sa https://json.nlohmann.me/api/json/
*/
using json = basic_json<>;
/// @brief a minimal map-like container that preserves insertion order
/// @sa https://json.nlohmann.me/api/ordered_map/
template<class Key, class T, class IgnoredLess, class Allocator>
struct ordered_map;
/// @brief specialization that maintains the insertion order of object keys
/// @sa https://json.nlohmann.me/api/ordered_json/
using ordered_json = basic_json<nlohmann::ordered_map>;
NLOHMANN_JSON_NAMESPACE_END
#endif // INCLUDE_NLOHMANN_JSON_FWD_HPP_

View File

@@ -6,4 +6,4 @@ then
fmt_args="-fmt 3" fmt_args="-fmt 3"
fi fi
ls -1 *.cc *.h | grep -v '^Font.h$' | xargs cloc ${fmt_args} ls -1 *.cc *.h lsp/*.{cc,h} | grep -v '^Font.h$' | xargs cloc ${fmt_args}

View File

@@ -0,0 +1,49 @@
/*
* BufferChangeTracker.cc - minimal initial implementation
*/
#include "BufferChangeTracker.h"
#include "../Buffer.h"
namespace kte::lsp {
BufferChangeTracker::BufferChangeTracker(const Buffer *buffer)
: buffer_(buffer) {}
void
BufferChangeTracker::recordInsertion(int /*row*/, int /*col*/, const std::string &/*text*/)
{
// For Phase 12 bring-up, coalesce to full-document changes
fullChangePending_ = true;
++version_;
}
void
BufferChangeTracker::recordDeletion(int /*row*/, int /*col*/, std::size_t /*len*/)
{
fullChangePending_ = true;
++version_;
}
std::vector<TextDocumentContentChangeEvent>
BufferChangeTracker::getChanges() const
{
std::vector<TextDocumentContentChangeEvent> v;
if (!buffer_)
return v;
if (fullChangePending_) {
TextDocumentContentChangeEvent ev;
ev.text = buffer_->FullText();
v.push_back(std::move(ev));
}
return v;
}
void
BufferChangeTracker::clearChanges()
{
fullChangePending_ = false;
}
} // namespace kte::lsp

44
lsp/BufferChangeTracker.h Normal file
View File

@@ -0,0 +1,44 @@
/*
* BufferChangeTracker.h - integrates with Buffer to accumulate LSP-friendly changes
*/
#ifndef KTE_BUFFER_CHANGE_TRACKER_H
#define KTE_BUFFER_CHANGE_TRACKER_H
#include <memory>
#include <vector>
#include <string>
#include "LspTypes.h"
class Buffer; // forward declare from core
namespace kte::lsp {
class BufferChangeTracker {
public:
explicit BufferChangeTracker(const Buffer *buffer);
// Called by Buffer on each edit operation
void recordInsertion(int row, int col, const std::string &text);
void recordDeletion(int row, int col, std::size_t len);
// Get accumulated changes since last sync
std::vector<TextDocumentContentChangeEvent> getChanges() const;
// Clear changes after sending to LSP
void clearChanges();
// Get current document version for LSP
int getVersion() const
{
return version_;
}
private:
const Buffer *buffer_ = nullptr;
bool fullChangePending_ = false;
int version_ = 0;
};
} // namespace kte::lsp
#endif // KTE_BUFFER_CHANGE_TRACKER_H

37
lsp/Diagnostic.h Normal file
View File

@@ -0,0 +1,37 @@
/*
* Diagnostic.h - LSP diagnostic data types
*/
#ifndef KTE_LSP_DIAGNOSTIC_H
#define KTE_LSP_DIAGNOSTIC_H
#include <optional>
#include <string>
#include <vector>
#include "LspTypes.h"
namespace kte::lsp {
enum class DiagnosticSeverity {
Error = 1,
Warning = 2,
Information = 3,
Hint = 4
};
struct DiagnosticRelatedInformation {
std::string uri; // related location URI
Range range; // related range
std::string message;
};
struct Diagnostic {
Range range{};
DiagnosticSeverity severity{DiagnosticSeverity::Information};
std::optional<std::string> code;
std::optional<std::string> source;
std::string message;
std::vector<DiagnosticRelatedInformation> relatedInfo;
};
} // namespace kte::lsp
#endif // KTE_LSP_DIAGNOSTIC_H

30
lsp/DiagnosticDisplay.h Normal file
View File

@@ -0,0 +1,30 @@
/*
* DiagnosticDisplay.h - Abstract interface for showing diagnostics
*/
#ifndef KTE_LSP_DIAGNOSTIC_DISPLAY_H
#define KTE_LSP_DIAGNOSTIC_DISPLAY_H
#include <string>
#include <vector>
#include "Diagnostic.h"
namespace kte::lsp {
class DiagnosticDisplay {
public:
virtual ~DiagnosticDisplay() = default;
virtual void updateDiagnostics(const std::string &uri,
const std::vector<Diagnostic> &diagnostics) = 0;
virtual void showInlineDiagnostic(const Diagnostic &diagnostic) = 0;
virtual void showDiagnosticList(const std::vector<Diagnostic> &diagnostics) = 0;
virtual void hideDiagnosticList() = 0;
virtual void updateStatusBar(int errorCount, int warningCount) = 0;
};
} // namespace kte::lsp
#endif // KTE_LSP_DIAGNOSTIC_DISPLAY_H

123
lsp/DiagnosticStore.cc Normal file
View File

@@ -0,0 +1,123 @@
/*
* DiagnosticStore.cc - implementation
*/
#include "DiagnosticStore.h"
#include <algorithm>
namespace kte::lsp {
void
DiagnosticStore::setDiagnostics(const std::string &uri, std::vector<Diagnostic> diagnostics)
{
diagnostics_[uri] = std::move(diagnostics);
}
const std::vector<Diagnostic> &
DiagnosticStore::getDiagnostics(const std::string &uri) const
{
auto it = diagnostics_.find(uri);
static const std::vector<Diagnostic> kEmpty;
if (it == diagnostics_.end())
return kEmpty;
return it->second;
}
std::vector<Diagnostic>
DiagnosticStore::getDiagnosticsAtLine(const std::string &uri, int line) const
{
std::vector<Diagnostic> out;
auto it = diagnostics_.find(uri);
if (it == diagnostics_.end())
return out;
out.reserve(it->second.size());
for (const auto &d: it->second) {
if (containsLine(d.range, line))
out.push_back(d);
}
return out;
}
std::optional<Diagnostic>
DiagnosticStore::getDiagnosticAtPosition(const std::string &uri, Position pos) const
{
auto it = diagnostics_.find(uri);
if (it == diagnostics_.end())
return std::nullopt;
for (const auto &d: it->second) {
if (containsPosition(d.range, pos))
return d;
}
return std::nullopt;
}
void
DiagnosticStore::clear(const std::string &uri)
{
diagnostics_.erase(uri);
}
void
DiagnosticStore::clearAll()
{
diagnostics_.clear();
}
int
DiagnosticStore::getErrorCount(const std::string &uri) const
{
auto it = diagnostics_.find(uri);
if (it == diagnostics_.end())
return 0;
int count = 0;
for (const auto &d: it->second) {
if (d.severity == DiagnosticSeverity::Error)
++count;
}
return count;
}
int
DiagnosticStore::getWarningCount(const std::string &uri) const
{
auto it = diagnostics_.find(uri);
if (it == diagnostics_.end())
return 0;
int count = 0;
for (const auto &d: it->second) {
if (d.severity == DiagnosticSeverity::Warning)
++count;
}
return count;
}
bool
DiagnosticStore::containsLine(const Range &r, int line)
{
return (line > r.start.line || line == r.start.line) &&
(line < r.end.line || line == r.end.line);
}
bool
DiagnosticStore::containsPosition(const Range &r, const Position &p)
{
if (p.line < r.start.line || p.line > r.end.line)
return false;
if (r.start.line == r.end.line) {
return p.line == r.start.line && p.character >= r.start.character && p.character <= r.end.character;
}
if (p.line == r.start.line)
return p.character >= r.start.character;
if (p.line == r.end.line)
return p.character <= r.end.character;
return true; // between start and end lines
}
} // namespace kte::lsp

42
lsp/DiagnosticStore.h Normal file
View File

@@ -0,0 +1,42 @@
/*
* DiagnosticStore.h - Central storage for diagnostics by document URI
*/
#ifndef KTE_LSP_DIAGNOSTIC_STORE_H
#define KTE_LSP_DIAGNOSTIC_STORE_H
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
#include "Diagnostic.h"
namespace kte::lsp {
class DiagnosticStore {
public:
void setDiagnostics(const std::string &uri, std::vector<Diagnostic> diagnostics);
const std::vector<Diagnostic> &getDiagnostics(const std::string &uri) const;
std::vector<Diagnostic> getDiagnosticsAtLine(const std::string &uri, int line) const;
std::optional<Diagnostic> getDiagnosticAtPosition(const std::string &uri, Position pos) const;
void clear(const std::string &uri);
void clearAll();
int getErrorCount(const std::string &uri) const;
int getWarningCount(const std::string &uri) const;
private:
std::unordered_map<std::string, std::vector<Diagnostic> > diagnostics_;
static bool containsLine(const Range &r, int line);
static bool containsPosition(const Range &r, const Position &p);
};
} // namespace kte::lsp
#endif // KTE_LSP_DIAGNOSTIC_STORE_H

147
lsp/JsonRpcTransport.cc Normal file
View File

@@ -0,0 +1,147 @@
/*
* JsonRpcTransport.cc - minimal stdio JSON-RPC framing (Content-Length)
*/
#include "JsonRpcTransport.h"
#include <cerrno>
#include <cstddef>
#include <cstdlib>
#include <cstring>
#include <string>
#include <optional>
#include <unistd.h>
namespace kte::lsp {
void
JsonRpcTransport::connect(int inFd, int outFd)
{
inFd_ = inFd;
outFd_ = outFd;
}
void
JsonRpcTransport::send(const std::string &/*method*/, const std::string &payload)
{
if (outFd_ < 0)
return;
const std::string header = "Content-Length: " + std::to_string(payload.size()) + "\r\n\r\n";
std::lock_guard<std::mutex> lk(writeMutex_);
// write header
const char *hbuf = header.data();
size_t hleft = header.size();
while (hleft > 0) {
ssize_t n = ::write(outFd_, hbuf, hleft);
if (n < 0) {
if (errno == EINTR)
continue;
return;
}
hbuf += static_cast<size_t>(n);
hleft -= static_cast<size_t>(n);
}
// write payload
const char *pbuf = payload.data();
size_t pleft = payload.size();
while (pleft > 0) {
ssize_t n = ::write(outFd_, pbuf, pleft);
if (n < 0) {
if (errno == EINTR)
continue;
return;
}
pbuf += static_cast<size_t>(n);
pleft -= static_cast<size_t>(n);
}
}
static bool
readLineCrlf(int fd, std::string &out, size_t maxLen)
{
out.clear();
char ch;
while (true) {
ssize_t n = ::read(fd, &ch, 1);
if (n == 0)
return false; // EOF
if (n < 0) {
if (errno == EINTR)
continue;
return false;
}
out.push_back(ch);
// Handle CRLF or bare LF as end-of-line
if ((out.size() >= 2 && out[out.size() - 2] == '\r' && out[out.size() - 1] == '\n') ||
(out.size() >= 1 && out[out.size() - 1] == '\n')) {
return true;
}
if (out.size() > maxLen) {
// sanity cap
return false;
}
}
}
std::optional<JsonRpcMessage>
JsonRpcTransport::read()
{
if (inFd_ < 0)
return std::nullopt;
// Parse headers (case-insensitive), accept/ignore extras
size_t contentLength = 0;
while (true) {
std::string line;
if (!readLineCrlf(inFd_, line, kMaxHeaderLine))
return std::nullopt;
// Normalize end-of-line handling: consider blank line as end of headers
if (line == "\r\n" || line == "\n" || line == "\r")
break;
// Trim trailing CRLF
if (!line.empty() && (line.back() == '\n' || line.back() == '\r')) {
while (!line.empty() && (line.back() == '\n' || line.back() == '\r'))
line.pop_back();
}
// Find colon
auto pos = line.find(':');
if (pos == std::string::npos)
continue;
std::string name = line.substr(0, pos);
std::string value = line.substr(pos + 1);
// trim leading spaces in value
size_t i = 0;
while (i < value.size() && (value[i] == ' ' || value[i] == '\t'))
++i;
value.erase(0, i);
// lower-case name for comparison
for (auto &c: name)
c = static_cast<char>(::tolower(static_cast<unsigned char>(c)));
if (name == "content-length") {
size_t len = static_cast<size_t>(std::strtoull(value.c_str(), nullptr, 10));
if (len > kMaxBody) {
return std::nullopt; // drop too-large message
}
contentLength = len;
}
// else: ignore other headers
}
if (contentLength == 0)
return std::nullopt;
std::string body;
body.resize(contentLength);
size_t readTotal = 0;
while (readTotal < contentLength) {
ssize_t n = ::read(inFd_, &body[readTotal], contentLength - readTotal);
if (n == 0)
return std::nullopt;
if (n < 0) {
if (errno == EINTR)
continue;
return std::nullopt;
}
readTotal += static_cast<size_t>(n);
}
return JsonRpcMessage{std::move(body)};
}
} // namespace kte::lsp

43
lsp/JsonRpcTransport.h Normal file
View File

@@ -0,0 +1,43 @@
/*
* JsonRpcTransport.h - minimal JSON-RPC over stdio transport
*/
#ifndef KTE_JSON_RPC_TRANSPORT_H
#define KTE_JSON_RPC_TRANSPORT_H
#include <optional>
#include <string>
#include <mutex>
namespace kte::lsp {
struct JsonRpcMessage {
std::string raw; // raw JSON payload (stub)
};
class JsonRpcTransport {
public:
JsonRpcTransport() = default;
~JsonRpcTransport() = default;
// Connect this transport to file descriptors (read from inFd, write to outFd)
void connect(int inFd, int outFd);
// Send a method call (request or notification)
// 'payload' should be a complete JSON object string to send as the message body.
void send(const std::string &method, const std::string &payload);
// Blocking read next message; returns nullopt on EOF or error
std::optional<JsonRpcMessage> read();
private:
int inFd_ = -1;
int outFd_ = -1;
std::mutex writeMutex_;
// Limits to keep the transport resilient
static constexpr size_t kMaxHeaderLine = 16 * 1024; // 16 KiB per header line
static constexpr size_t kMaxBody = 64ull * 1024ull * 1024ull; // 64 MiB body cap
};
} // namespace kte::lsp
#endif // KTE_JSON_RPC_TRANSPORT_H

75
lsp/LspClient.h Normal file
View File

@@ -0,0 +1,75 @@
/*
* LspClient.h - Core LSP client abstraction (initial stub)
*/
#ifndef KTE_LSP_CLIENT_H
#define KTE_LSP_CLIENT_H
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "LspTypes.h"
#include "Diagnostic.h"
namespace kte::lsp {
// Callback types for initial language features
// If error is non-empty, the result may be default-constructed/empty
using CompletionCallback = std::function<void(const CompletionList & result, const std::string & error)>;
using HoverCallback = std::function<void(const HoverResult & result, const std::string & error)>;
using LocationCallback = std::function<void(const std::vector<Location> & result, const std::string & error)>;
class LspClient {
public:
virtual ~LspClient() = default;
// Lifecycle
virtual bool initialize(const std::string &rootPath) = 0;
virtual void shutdown() = 0;
// Document Synchronization
virtual void didOpen(const std::string &uri, const std::string &languageId,
int version, const std::string &text) = 0;
virtual void didChange(const std::string &uri, int version,
const std::vector<TextDocumentContentChangeEvent> &changes) = 0;
virtual void didClose(const std::string &uri) = 0;
virtual void didSave(const std::string &uri) = 0;
// Language Features (initial)
virtual void completion(const std::string &, Position,
CompletionCallback) {}
virtual void hover(const std::string &, Position,
HoverCallback) {}
virtual void definition(const std::string &, Position,
LocationCallback) {}
// Process Management
virtual bool isRunning() const = 0;
virtual std::string getServerName() const = 0;
// Handlers (optional; set by manager)
using DiagnosticsHandler = std::function<void(const std::string & uri,
const std::vector<Diagnostic> &diagnostics
)
>;
virtual void setDiagnosticsHandler(DiagnosticsHandler h)
{
(void) h;
}
};
} // namespace kte::lsp
#endif // KTE_LSP_CLIENT_H

736
lsp/LspManager.cc Normal file
View File

@@ -0,0 +1,736 @@
/*
* LspManager.cc - central coordination of LSP servers and diagnostics
*/
#include "LspManager.h"
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <utility>
#include <cstdio>
#include <cstdlib>
#include <ctime>
#include <cstdarg>
#include "../Buffer.h"
#include "../Editor.h"
#include "BufferChangeTracker.h"
#include "LspProcessClient.h"
#include "UtfCodec.h"
namespace fs = std::filesystem;
namespace kte::lsp {
static void
lsp_debug_file(const char *fmt, ...)
{
FILE *f = std::fopen("/tmp/kte-lsp.log", "a");
if (!f)
return;
// prepend timestamp
std::time_t t = std::time(nullptr);
char ts[32];
std::strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", std::localtime(&t));
std::fprintf(f, "[%s] ", ts);
va_list ap;
va_start(ap, fmt);
std::vfprintf(f, fmt, ap);
va_end(ap);
std::fputc('\n', f);
std::fclose(f);
}
LspManager::LspManager(Editor *editor, DiagnosticDisplay *display)
: editor_(editor), display_(display)
{
// Pre-populate with sensible default server configs
registerDefaultServers();
}
void
LspManager::registerServer(const std::string &languageId, const LspServerConfig &config)
{
serverConfigs_[languageId] = config;
}
bool
LspManager::startServerForBuffer(Buffer *buffer)
{
const auto lang = getLanguageId(buffer);
if (lang.empty())
return false;
if (servers_.find(lang) != servers_.end() && servers_[lang]->isRunning()) {
return true;
}
auto it = serverConfigs_.find(lang);
if (it == serverConfigs_.end()) {
return false;
}
const auto &cfg = it->second;
// Respect autostart for automatic starts on buffer open
if (!cfg.autostart) {
return false;
}
// Allow env override of server path
std::string command = cfg.command;
if (lang == "cpp") {
if (const char *p = std::getenv("KTE_LSP_CLANGD"); p && *p)
command = p;
} else if (lang == "go") {
if (const char *p = std::getenv("KTE_LSP_GOPLS"); p && *p)
command = p;
} else if (lang == "rust") {
if (const char *p = std::getenv("KTE_LSP_RUST_ANALYZER"); p && *p)
command = p;
}
if (debug_) {
std::fprintf(stderr, "[kte][lsp] startServerForBuffer: lang=%s cmd=%s args=%zu file=%s\n",
lang.c_str(), command.c_str(), cfg.args.size(), buffer->Filename().c_str());
lsp_debug_file("startServerForBuffer: lang=%s cmd=%s args=%zu file=%s",
lang.c_str(), command.c_str(), cfg.args.size(), buffer->Filename().c_str());
}
auto client = std::make_unique<LspProcessClient>(command, cfg.args);
// Wire diagnostics handler to manager
client->setDiagnosticsHandler([this](const std::string &uri, const std::vector<Diagnostic> &diags) {
this->handleDiagnostics(uri, diags);
});
// Determine workspace root using rootPatterns if set; fallback to file's parent
std::string rootPath;
if (!buffer->Filename().empty()) {
rootPath = detectWorkspaceRoot(buffer->Filename(), cfg);
if (rootPath.empty()) {
fs::path p(buffer->Filename());
rootPath = p.has_parent_path() ? p.parent_path().string() : std::string{};
}
}
if (debug_) {
const char *pathEnv = std::getenv("PATH");
std::fprintf(stderr, "[kte][lsp] initializing server: rootPath=%s PATH=%s\n",
rootPath.c_str(), pathEnv ? pathEnv : "<null>");
lsp_debug_file("initializing server: rootPath=%s PATH=%s",
rootPath.c_str(), pathEnv ? pathEnv : "<null>");
}
if (!client->initialize(rootPath)) {
if (debug_) {
std::fprintf(stderr, "[kte][lsp] initialize failed for lang=%s\n", lang.c_str());
lsp_debug_file("initialize failed for lang=%s", lang.c_str());
}
return false;
}
servers_[lang] = std::move(client);
return true;
}
void
LspManager::stopServer(const std::string &languageId)
{
auto it = servers_.find(languageId);
if (it != servers_.end()) {
it->second->shutdown();
servers_.erase(it);
}
}
void
LspManager::stopAllServers()
{
for (auto &kv: servers_) {
kv.second->shutdown();
}
servers_.clear();
}
bool
LspManager::startServerForLanguage(const std::string &languageId, const std::string &rootPath)
{
auto cfgIt = serverConfigs_.find(languageId);
if (cfgIt == serverConfigs_.end())
return false;
// If already running, nothing to do
auto it = servers_.find(languageId);
if (it != servers_.end() && it->second && it->second->isRunning()) {
return true;
}
const auto &cfg = cfgIt->second;
std::string command = cfg.command;
if (languageId == "cpp") {
if (const char *p = std::getenv("KTE_LSP_CLANGD"); p && *p)
command = p;
} else if (languageId == "go") {
if (const char *p = std::getenv("KTE_LSP_GOPLS"); p && *p)
command = p;
} else if (languageId == "rust") {
if (const char *p = std::getenv("KTE_LSP_RUST_ANALYZER"); p && *p)
command = p;
}
if (debug_) {
std::fprintf(stderr, "[kte][lsp] startServerForLanguage: lang=%s cmd=%s args=%zu root=%s\n",
languageId.c_str(), command.c_str(), cfg.args.size(), rootPath.c_str());
lsp_debug_file("startServerForLanguage: lang=%s cmd=%s args=%zu root=%s",
languageId.c_str(), command.c_str(), cfg.args.size(), rootPath.c_str());
}
auto client = std::make_unique<LspProcessClient>(command, cfg.args);
client->setDiagnosticsHandler([this](const std::string &uri, const std::vector<Diagnostic> &diags) {
this->handleDiagnostics(uri, diags);
});
std::string root = rootPath;
if (!root.empty()) {
// keep
} else {
// Try cwd if not provided
root = std::string();
}
if (!client->initialize(root)) {
if (debug_) {
std::fprintf(stderr, "[kte][lsp] initialize failed for lang=%s\n", languageId.c_str());
lsp_debug_file("initialize failed for lang=%s", languageId.c_str());
}
return false;
}
servers_[languageId] = std::move(client);
return true;
}
bool
LspManager::restartServer(const std::string &languageId, const std::string &rootPath)
{
stopServer(languageId);
return startServerForLanguage(languageId, rootPath);
}
void
LspManager::onBufferOpened(Buffer *buffer)
{
if (debug_) {
std::fprintf(stderr, "[kte][lsp] onBufferOpened: file=%s lang=%s\n",
buffer->Filename().c_str(), getLanguageId(buffer).c_str());
lsp_debug_file("onBufferOpened: file=%s lang=%s",
buffer->Filename().c_str(), getLanguageId(buffer).c_str());
}
if (!startServerForBuffer(buffer)) {
if (debug_) {
std::fprintf(stderr, "[kte][lsp] onBufferOpened: server did not start\n");
lsp_debug_file("onBufferOpened: server did not start");
}
return;
}
auto *client = ensureServerForLanguage(getLanguageId(buffer));
if (!client)
return;
const auto uri = getUri(buffer);
const auto lang = getLanguageId(buffer);
const int version = static_cast<int>(buffer->Version());
const std::string text = buffer->FullText();
if (debug_) {
std::fprintf(stderr, "[kte][lsp] didOpen: uri=%s lang=%s version=%d bytes=%zu\n",
uri.c_str(), lang.c_str(), version, text.size());
lsp_debug_file("didOpen: uri=%s lang=%s version=%d bytes=%zu",
uri.c_str(), lang.c_str(), version, text.size());
}
client->didOpen(uri, lang, version, text);
}
void
LspManager::onBufferChanged(Buffer *buffer)
{
auto *client = ensureServerForLanguage(getLanguageId(buffer));
if (!client)
return;
const auto uri = getUri(buffer);
int version = static_cast<int>(buffer->Version());
std::vector<TextDocumentContentChangeEvent> changes;
if (auto *tracker = buffer->GetChangeTracker()) {
changes = tracker->getChanges();
tracker->clearChanges();
version = tracker->getVersion();
} else {
// Fallback: full document change
TextDocumentContentChangeEvent ev;
ev.range.reset();
ev.text = buffer->FullText();
changes.push_back(std::move(ev));
}
// Option A: convert ranges from UTF-8 (editor coords) -> UTF-16 (LSP wire)
std::vector<TextDocumentContentChangeEvent> changes16;
changes16.reserve(changes.size());
// LineProvider that serves lines from this buffer by URI
Buffer *bufForUri = buffer; // changes are for this buffer
auto provider = [bufForUri](const std::string &/*u*/, int line) -> std::string_view {
if (!bufForUri)
return std::string_view();
const auto &rows = bufForUri->Rows();
if (line < 0 || static_cast<size_t>(line) >= rows.size())
return std::string_view();
// Materialize one line into a thread_local scratch; return view
thread_local std::string scratch;
scratch = static_cast<std::string>(rows[static_cast<size_t>(line)]);
return std::string_view(scratch);
};
for (const auto &ch: changes) {
TextDocumentContentChangeEvent out = ch;
if (ch.range.has_value()) {
Range r16 = toUtf16(uri, *ch.range, provider);
if (debug_) {
lsp_debug_file("didChange range convert: L%d C%d-%d -> L%d C%d-%d",
ch.range->start.line, ch.range->start.character,
ch.range->end.character,
r16.start.line, r16.start.character, r16.end.character);
}
out.range = r16;
}
changes16.push_back(std::move(out));
}
client->didChange(uri, version, changes16);
}
void
LspManager::onBufferClosed(Buffer *buffer)
{
auto *client = ensureServerForLanguage(getLanguageId(buffer));
if (!client)
return;
client->didClose(getUri(buffer));
// Clear diagnostics for this file
diagnosticStore_.clear(getUri(buffer));
}
void
LspManager::onBufferSaved(Buffer *buffer)
{
auto *client = ensureServerForLanguage(getLanguageId(buffer));
if (!client)
return;
client->didSave(getUri(buffer));
}
void
LspManager::requestCompletion(Buffer *buffer, Position pos, CompletionCallback callback)
{
if (auto *client = ensureServerForLanguage(getLanguageId(buffer))) {
const auto uri = getUri(buffer);
// Convert position to UTF-16 using Option A provider
auto provider = [buffer](const std::string &/*u*/, int line) -> std::string_view {
if (!buffer)
return std::string_view();
const auto &rows = buffer->Rows();
if (line < 0 || static_cast<size_t>(line) >= rows.size())
return std::string_view();
thread_local std::string scratch;
scratch = static_cast<std::string>(rows[static_cast<size_t>(line)]);
return std::string_view(scratch);
};
Position p16 = toUtf16(uri, pos, provider);
if (debug_) {
lsp_debug_file("completion pos convert: L%d C%d -> L%d C%d", pos.line, pos.character, p16.line,
p16.character);
}
client->completion(uri, p16, std::move(callback));
}
}
void
LspManager::requestHover(Buffer *buffer, Position pos, HoverCallback callback)
{
if (auto *client = ensureServerForLanguage(getLanguageId(buffer))) {
const auto uri = getUri(buffer);
auto provider = [buffer](const std::string &/*u*/, int line) -> std::string_view {
if (!buffer)
return std::string_view();
const auto &rows = buffer->Rows();
if (line < 0 || static_cast<size_t>(line) >= rows.size())
return std::string_view();
thread_local std::string scratch;
scratch = static_cast<std::string>(rows[static_cast<size_t>(line)]);
return std::string_view(scratch);
};
Position p16 = toUtf16(uri, pos, provider);
if (debug_) {
lsp_debug_file("hover pos convert: L%d C%d -> L%d C%d", pos.line, pos.character, p16.line,
p16.character);
}
// Wrap the callback to convert any returned range from UTF-16 (wire) -> UTF-8 (editor)
HoverCallback wrapped = [this, uri, provider, cb = std::move(callback)](const HoverResult &res16,
const std::string &err) {
if (!cb)
return;
if (!res16.range.has_value()) {
cb(res16, err);
return;
}
HoverResult res8 = res16;
res8.range = toUtf8(uri, *res16.range, provider);
if (debug_) {
const auto &r16 = *res16.range;
const auto &r8 = *res8.range;
lsp_debug_file("hover range convert: L%d %d-%d -> L%d %d-%d",
r16.start.line, r16.start.character, r16.end.character,
r8.start.line, r8.start.character, r8.end.character);
}
cb(res8, err);
};
client->hover(uri, p16, std::move(wrapped));
}
}
void
LspManager::requestDefinition(Buffer *buffer, Position pos, LocationCallback callback)
{
if (auto *client = ensureServerForLanguage(getLanguageId(buffer))) {
const auto uri = getUri(buffer);
auto provider = [buffer](const std::string &/*u*/, int line) -> std::string_view {
if (!buffer)
return std::string_view();
const auto &rows = buffer->Rows();
if (line < 0 || static_cast<size_t>(line) >= rows.size())
return std::string_view();
thread_local std::string scratch;
scratch = static_cast<std::string>(rows[static_cast<size_t>(line)]);
return std::string_view(scratch);
};
Position p16 = toUtf16(uri, pos, provider);
if (debug_) {
lsp_debug_file("definition pos convert: L%d C%d -> L%d C%d", pos.line, pos.character, p16.line,
p16.character);
}
// Wrap callback to convert Location ranges from UTF-16 (wire) -> UTF-8 (editor)
LocationCallback wrapped = [this, uri, provider, cb = std::move(callback)](
const std::vector<Location> &locs16,
const std::string &err) {
if (!cb)
return;
std::vector<Location> locs8;
locs8.reserve(locs16.size());
for (const auto &l: locs16) {
Location x = l;
x.range = toUtf8(uri, l.range, provider);
if (debug_) {
lsp_debug_file("definition range convert: L%d %d-%d -> L%d %d-%d",
l.range.start.line, l.range.start.character,
l.range.end.character,
x.range.start.line, x.range.start.character,
x.range.end.character);
}
locs8.push_back(std::move(x));
}
cb(locs8, err);
};
client->definition(uri, p16, std::move(wrapped));
}
}
void
LspManager::handleDiagnostics(const std::string &uri, const std::vector<Diagnostic> &diagnostics)
{
// Convert incoming ranges from UTF-16 (wire) -> UTF-8 (editor)
std::vector<Diagnostic> conv = diagnostics;
Buffer *buf = findBufferByUri(uri);
auto provider = [buf](const std::string &/*u*/, int line) -> std::string_view {
if (!buf)
return std::string_view();
const auto &rows = buf->Rows();
if (line < 0 || static_cast<size_t>(line) >= rows.size())
return std::string_view();
thread_local std::string scratch;
scratch = static_cast<std::string>(rows[static_cast<size_t>(line)]);
return std::string_view(scratch);
};
for (auto &d: conv) {
Range r8 = toUtf8(uri, d.range, provider);
if (debug_) {
lsp_debug_file("diagnostic range convert: L%d C%d-%d -> L%d C%d-%d",
d.range.start.line, d.range.start.character, d.range.end.character,
r8.start.line, r8.start.character, r8.end.character);
}
d.range = r8;
}
diagnosticStore_.setDiagnostics(uri, conv);
if (display_) {
display_->updateDiagnostics(uri, conv);
display_->updateStatusBar(diagnosticStore_.getErrorCount(uri), diagnosticStore_.getWarningCount(uri));
}
}
bool
LspManager::toggleAutostart(const std::string &languageId)
{
auto it = serverConfigs_.find(languageId);
if (it == serverConfigs_.end())
return false;
it->second.autostart = !it->second.autostart;
return it->second.autostart;
}
std::vector<std::string>
LspManager::configuredLanguages() const
{
std::vector<std::string> out;
out.reserve(serverConfigs_.size());
for (const auto &kv: serverConfigs_)
out.push_back(kv.first);
std::sort(out.begin(), out.end());
return out;
}
std::vector<std::string>
LspManager::runningLanguages() const
{
std::vector<std::string> out;
for (const auto &kv: servers_) {
if (kv.second && kv.second->isRunning())
out.push_back(kv.first);
}
std::sort(out.begin(), out.end());
return out;
}
std::string
LspManager::getLanguageId(Buffer *buffer)
{
// Prefer explicit filetype if set
const auto &ft = buffer->Filetype();
if (!ft.empty())
return ft;
// Otherwise map extension
fs::path p(buffer->Filename());
return extToLanguageId(p.extension().string());
}
std::string
LspManager::getUri(Buffer *buffer)
{
const auto &path = buffer->Filename();
if (path.empty()) {
// Untitled buffer: use a pseudo-URI
return std::string("untitled:") + std::to_string(reinterpret_cast<std::uintptr_t>(buffer));
}
fs::path p(path);
p = fs::weakly_canonical(p);
#ifdef _WIN32
// rudimentary file URI; future: robust encoding
return std::string("file:/") + p.string();
#else
return std::string("file://") + p.string();
#endif
}
// Resolve a Buffer* by matching constructed file URI
Buffer *
LspManager::findBufferByUri(const std::string &uri)
{
if (!editor_)
return nullptr;
// Compare against getUri for each buffer
auto &bufs = editor_->Buffers();
for (auto &b: bufs) {
if (getUri(&b) == uri)
return &b;
}
return nullptr;
}
std::string
LspManager::extToLanguageId(const std::string &ext)
{
std::string e = ext;
if (!e.empty() && e[0] == '.')
e.erase(0, 1);
std::string lower;
lower.resize(e.size());
std::transform(e.begin(), e.end(), lower.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
if (lower == "rs")
return "rust";
if (lower == "c" || lower == "cc" || lower == "cpp" || lower == "h" || lower == "hpp")
return "cpp";
if (lower == "go")
return "go";
if (lower == "py")
return "python";
if (lower == "js")
return "javascript";
if (lower == "ts")
return "typescript";
if (lower == "json")
return "json";
if (lower == "sh" || lower == "bash" || lower == "zsh")
return "shell";
if (lower == "md")
return "markdown";
return lower; // best-effort
}
LspClient *
LspManager::ensureServerForLanguage(const std::string &languageId)
{
auto it = servers_.find(languageId);
if (it != servers_.end() && it->second && it->second->isRunning()) {
return it->second.get();
}
// Attempt to start from config if present
auto cfg = serverConfigs_.find(languageId);
if (cfg == serverConfigs_.end())
return nullptr;
auto client = std::make_unique<LspProcessClient>(cfg->second.command, cfg->second.args);
client->setDiagnosticsHandler([this](const std::string &uri, const std::vector<Diagnostic> &diags) {
this->handleDiagnostics(uri, diags);
});
// No specific file context here; initialize with empty or current working dir
if (!client->initialize(""))
return nullptr;
auto *ret = client.get();
servers_[languageId] = std::move(client);
return ret;
}
void
LspManager::registerDefaultServers()
{
// Import defaults and register by inferred languageId from file patterns
for (const auto &cfg: GetDefaultServerConfigs()) {
if (cfg.filePatterns.empty()) {
// If no patterns, we can't infer; skip
continue;
}
for (const auto &pat: cfg.filePatterns) {
const auto lang = patternToLanguageId(pat);
if (lang.empty())
continue;
// Don't overwrite if user already registered a server for this lang
if (serverConfigs_.find(lang) == serverConfigs_.end()) {
serverConfigs_.emplace(lang, cfg);
}
}
}
}
std::string
LspManager::patternToLanguageId(const std::string &pattern)
{
// Expect patterns like "*.rs", "*.cpp" etc. Extract extension and reuse extToLanguageId
// Find last '.' in the pattern and take substring after it, stripping any trailing wildcards
std::string ext;
// Common case: starts with *.
auto pos = pattern.rfind('.');
if (pos != std::string::npos && pos + 1 < pattern.size()) {
ext = pattern.substr(pos + 1);
// Remove any trailing wildcard characters
while (!ext.empty() && (ext.back() == '*' || ext.back() == '?')) {
ext.pop_back();
}
} else {
// No dot; try to treat whole pattern as extension after trimming leading '*'
ext = pattern;
while (!ext.empty() && (ext.front() == '*' || ext.front() == '.')) {
ext.erase(ext.begin());
}
}
if (ext.empty())
return {};
return extToLanguageId(ext);
}
// Detect workspace root by walking up from filePath looking for any of the
// configured rootPatterns (simple filenames). Supports comma/semicolon-separated
// patterns in cfg.rootPatterns.
std::string
LspManager::detectWorkspaceRoot(const std::string &filePath, const LspServerConfig &cfg)
{
if (filePath.empty())
return {};
fs::path start(filePath);
fs::path dir = start.has_parent_path() ? start.parent_path() : start;
// Build cache key
const std::string cacheKey = (dir.string() + "|" + cfg.rootPatterns);
auto it = rootCache_.find(cacheKey);
if (it != rootCache_.end()) {
return it->second;
}
// Split patterns by ',', ';', or ':'
std::vector<std::string> pats;
{
std::string acc;
for (char c: cfg.rootPatterns) {
if (c == ',' || c == ';' || c == ':') {
if (!acc.empty()) {
pats.push_back(acc);
acc.clear();
}
} else if (!std::isspace(static_cast<unsigned char>(c))) {
acc.push_back(c);
}
}
if (!acc.empty())
pats.push_back(acc);
}
// If no patterns defined, cache empty and return {}
if (pats.empty()) {
rootCache_[cacheKey] = {};
return {};
}
fs::path cur = dir;
while (true) {
// Check each pattern in this directory
for (const auto &pat: pats) {
if (pat.empty())
continue;
fs::path candidate = cur / pat;
std::error_code ec;
bool exists = fs::exists(candidate, ec);
if (!ec && exists) {
rootCache_[cacheKey] = cur.string();
return rootCache_[cacheKey];
}
}
if (cur.has_parent_path()) {
fs::path parent = cur.parent_path();
if (parent == cur)
break; // reached root guard
cur = parent;
} else {
break;
}
}
rootCache_[cacheKey] = {};
return {};
}
} // namespace kte::lsp

108
lsp/LspManager.h Normal file
View File

@@ -0,0 +1,108 @@
/*
* LspManager.h - central coordination of LSP servers and diagnostics
*/
#ifndef KTE_LSP_MANAGER_H
#define KTE_LSP_MANAGER_H
#include <memory>
#include <string>
#include <unordered_map>
class Buffer; // fwd
class Editor; // fwd
#include "DiagnosticDisplay.h"
#include "DiagnosticStore.h"
#include "LspClient.h"
#include "LspServerConfig.h"
#include "UtfCodec.h"
namespace kte::lsp {
class LspManager {
public:
explicit LspManager(Editor *editor, DiagnosticDisplay *display);
// Server management
void registerServer(const std::string &languageId, const LspServerConfig &config);
bool startServerForBuffer(Buffer *buffer);
void stopServer(const std::string &languageId);
void stopAllServers();
// Manual lifecycle controls
bool startServerForLanguage(const std::string &languageId, const std::string &rootPath = std::string());
bool restartServer(const std::string &languageId, const std::string &rootPath = std::string());
// Document sync (to be called by editor/buffer events)
void onBufferOpened(Buffer *buffer);
void onBufferChanged(Buffer *buffer);
void onBufferClosed(Buffer *buffer);
void onBufferSaved(Buffer *buffer);
// Feature requests (stubs)
void requestCompletion(Buffer *buffer, Position pos, CompletionCallback callback);
void requestHover(Buffer *buffer, Position pos, HoverCallback callback);
void requestDefinition(Buffer *buffer, Position pos, LocationCallback callback);
// Diagnostics (public so LspClient impls can forward results here later)
void handleDiagnostics(const std::string &uri, const std::vector<Diagnostic> &diagnostics);
void setDebugLogging(bool enabled)
{
debug_ = enabled;
}
// Configuration utilities
bool toggleAutostart(const std::string &languageId);
std::vector<std::string> configuredLanguages() const;
std::vector<std::string> runningLanguages() const;
private:
[[maybe_unused]] Editor *editor_{}; // non-owning
DiagnosticDisplay *display_{}; // non-owning
DiagnosticStore diagnosticStore_{};
// Key: languageId → client
std::unordered_map<std::string, std::unique_ptr<LspClient> > servers_;
std::unordered_map<std::string, LspServerConfig> serverConfigs_;
// Helpers
static std::string getLanguageId(Buffer *buffer);
static std::string getUri(Buffer *buffer);
static std::string extToLanguageId(const std::string &ext);
LspClient *ensureServerForLanguage(const std::string &languageId);
bool debug_ = false;
// Configuration helpers
void registerDefaultServers();
static std::string patternToLanguageId(const std::string &pattern);
// Workspace root detection helpers/cache
std::string detectWorkspaceRoot(const std::string &filePath, const LspServerConfig &cfg);
// key = startDir + "|" + cfg.rootPatterns
std::unordered_map<std::string, std::string> rootCache_;
// Resolve a buffer by its file:// (or untitled:) URI
Buffer *findBufferByUri(const std::string &uri);
};
} // namespace kte::lsp
#endif // KTE_LSP_MANAGER_H

948
lsp/LspProcessClient.cc Normal file
View File

@@ -0,0 +1,948 @@
/*
* LspProcessClient.cc - process-based LSP client (Phase 1 minimal)
*/
#include "LspProcessClient.h"
#include <sstream>
#include <vector>
#include <string>
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <cerrno>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <signal.h>
#include <thread>
#include "json.h"
namespace kte::lsp {
LspProcessClient::LspProcessClient(std::string serverCommand, std::vector<std::string> serverArgs)
: command_(std::move(serverCommand)), args_(std::move(serverArgs)), transport_(new JsonRpcTransport())
{
if (const char *dbg = std::getenv("KTE_LSP_DEBUG"); dbg && *dbg) {
debug_ = true;
}
if (const char *to = std::getenv("KTE_LSP_REQ_TIMEOUT_MS"); to && *to) {
char *end = nullptr;
long long v = std::strtoll(to, &end, 10);
if (end && *end == '\0' && v >= 0) {
requestTimeoutMs_ = v;
}
}
if (const char *mp = std::getenv("KTE_LSP_MAX_PENDING"); mp && *mp) {
char *end = nullptr;
long long v = std::strtoll(mp, &end, 10);
if (end && *end == '\0' && v >= 0) {
maxPending_ = static_cast<size_t>(v);
}
}
}
LspProcessClient::~LspProcessClient()
{
shutdown();
}
bool
LspProcessClient::spawnServerProcess()
{
int toChild[2]; // parent writes toChild[1] -> child's stdin
int fromChild[2]; // child writes fromChild[1] -> parent's stdout reader
if (pipe(toChild) != 0) {
if (debug_)
std::fprintf(stderr, "[kte][lsp] pipe(toChild) failed: %s\n", std::strerror(errno));
return false;
}
if (pipe(fromChild) != 0) {
::close(toChild[0]);
::close(toChild[1]);
if (debug_)
std::fprintf(stderr, "[kte][lsp] pipe(fromChild) failed: %s\n", std::strerror(errno));
return false;
}
pid_t pid = fork();
if (pid < 0) {
// fork failed
::close(toChild[0]);
::close(toChild[1]);
::close(fromChild[0]);
::close(fromChild[1]);
if (debug_)
std::fprintf(stderr, "[kte][lsp] fork failed: %s\n", std::strerror(errno));
return false;
}
if (pid == 0) {
// Child: set up stdio
::dup2(toChild[0], STDIN_FILENO);
::dup2(fromChild[1], STDOUT_FILENO);
// Close extra fds
::close(toChild[0]);
::close(toChild[1]);
::close(fromChild[0]);
::close(fromChild[1]);
// Build argv
std::vector<char *> argv;
argv.push_back(const_cast<char *>(command_.c_str()));
for (auto &s: args_)
argv.push_back(const_cast<char *>(s.c_str()));
argv.push_back(nullptr);
// Exec
execvp(command_.c_str(), argv.data());
// If exec fails
// Note: in child; cannot easily log to parent. Attempt to write to stderr.
std::fprintf(stderr, "[kte][lsp] execvp failed for '%s': %s\n", command_.c_str(), std::strerror(errno));
_exit(127);
}
// Parent: keep ends
childPid_ = pid;
outFd_ = toChild[1]; // write to child's stdin
inFd_ = fromChild[0]; // read from child's stdout
// Close the other ends we don't use
::close(toChild[0]);
::close(fromChild[1]);
// Set CLOEXEC on our fds
fcntl(outFd_, F_SETFD, FD_CLOEXEC);
fcntl(inFd_, F_SETFD, FD_CLOEXEC);
if (debug_) {
std::ostringstream oss;
oss << command_;
for (const auto &a: args_) {
oss << ' ' << a;
}
const char *pathEnv = std::getenv("PATH");
std::fprintf(stderr, "[kte][lsp] spawned pid=%d argv=[%s] inFd=%d outFd=%d PATH=%s\n",
static_cast<int>(childPid_), oss.str().c_str(), inFd_, outFd_,
pathEnv ? pathEnv : "<null>");
}
transport_->connect(inFd_, outFd_);
return true;
}
void
LspProcessClient::terminateProcess()
{
if (outFd_ >= 0) {
::close(outFd_);
outFd_ = -1;
}
if (inFd_ >= 0) {
::close(inFd_);
inFd_ = -1;
}
if (childPid_ > 0) {
// Try to wait non-blocking; if still running, send SIGTERM
int status = 0;
pid_t r = waitpid(childPid_, &status, WNOHANG);
if (r == 0) {
// still running
kill(childPid_, SIGTERM);
waitpid(childPid_, &status, 0);
}
childPid_ = -1;
}
}
void
LspProcessClient::sendInitialize(const std::string &rootPath)
{
int idNum = nextRequestIntId_++;
pendingInitializeId_ = std::to_string(idNum);
nlohmann::json j;
j["jsonrpc"] = "2.0";
j["id"] = idNum;
j["method"] = "initialize";
nlohmann::json params;
params["processId"] = static_cast<int>(getpid());
params["rootUri"] = toFileUri(rootPath);
// Minimal client capabilities for now
nlohmann::json caps;
caps["textDocument"]["synchronization"]["didSave"] = true;
params["capabilities"] = std::move(caps);
j["params"] = std::move(params);
transport_->send("initialize", j.dump());
}
bool
LspProcessClient::initialize(const std::string &rootPath)
{
if (running_)
return true;
if (debug_)
std::fprintf(stderr, "[kte][lsp] initialize: rootPath=%s\n", rootPath.c_str());
if (!spawnServerProcess())
return false;
running_ = true;
sendInitialize(rootPath);
startReader();
startTimeoutWatchdog();
return true;
}
void
LspProcessClient::shutdown()
{
if (!running_)
return;
if (debug_)
std::fprintf(stderr, "[kte][lsp] shutdown\n");
// Send shutdown request then exit notification (best-effort)
int id = nextRequestIntId_++;
{
nlohmann::json j;
j["jsonrpc"] = "2.0";
j["id"] = id;
j["method"] = "shutdown";
transport_->send("shutdown", j.dump());
}
{
nlohmann::json j;
j["jsonrpc"] = "2.0";
j["method"] = "exit";
transport_->send("exit", j.dump());
}
// Close pipes to unblock reader, then join thread, then ensure child is gone
terminateProcess();
stopReader();
stopTimeoutWatchdog();
// Clear any pending callbacks
{
std::lock_guard<std::mutex> lk(pendingMutex_);
pending_.clear();
pendingOrder_.clear();
}
running_ = false;
}
void
LspProcessClient::didOpen(const std::string &uri, const std::string &languageId,
int version, const std::string &text)
{
if (!running_)
return;
if (debug_)
std::fprintf(stderr, "[kte][lsp] -> didOpen uri=%s lang=%s version=%d bytes=%zu\n",
uri.c_str(), languageId.c_str(), version, text.size());
nlohmann::json j;
j["jsonrpc"] = "2.0";
j["method"] = "textDocument/didOpen";
j["params"]["textDocument"]["uri"] = uri;
j["params"]["textDocument"]["languageId"] = languageId;
j["params"]["textDocument"]["version"] = version;
j["params"]["textDocument"]["text"] = text;
transport_->send("textDocument/didOpen", j.dump());
}
void
LspProcessClient::didChange(const std::string &uri, int version,
const std::vector<TextDocumentContentChangeEvent> &changes)
{
if (!running_)
return;
if (debug_)
std::fprintf(stderr, "[kte][lsp] -> didChange uri=%s version=%d changes=%zu\n",
uri.c_str(), version, changes.size());
// Phase 1: send full or ranged changes using proper JSON construction
nlohmann::json j;
j["jsonrpc"] = "2.0";
j["method"] = "textDocument/didChange";
j["params"]["textDocument"]["uri"] = uri;
j["params"]["textDocument"]["version"] = version;
auto &arr = j["params"]["contentChanges"];
arr = nlohmann::json::array();
for (const auto &ch: changes) {
nlohmann::json c;
if (ch.range.has_value()) {
c["range"]["start"]["line"] = ch.range->start.line;
c["range"]["start"]["character"] = ch.range->start.character;
c["range"]["end"]["line"] = ch.range->end.line;
c["range"]["end"]["character"] = ch.range->end.character;
}
c["text"] = ch.text;
arr.push_back(std::move(c));
}
transport_->send("textDocument/didChange", j.dump());
}
void
LspProcessClient::didClose(const std::string &uri)
{
if (!running_)
return;
if (debug_)
std::fprintf(stderr, "[kte][lsp] -> didClose uri=%s\n", uri.c_str());
nlohmann::json j;
j["jsonrpc"] = "2.0";
j["method"] = "textDocument/didClose";
j["params"]["textDocument"]["uri"] = uri;
transport_->send("textDocument/didClose", j.dump());
}
void
LspProcessClient::didSave(const std::string &uri)
{
if (!running_)
return;
if (debug_)
std::fprintf(stderr, "[kte][lsp] -> didSave uri=%s\n", uri.c_str());
nlohmann::json j;
j["jsonrpc"] = "2.0";
j["method"] = "textDocument/didSave";
j["params"]["textDocument"]["uri"] = uri;
transport_->send("textDocument/didSave", j.dump());
}
void
LspProcessClient::startReader()
{
stopReader_ = false;
reader_ = std::thread([this] {
this->readerLoop();
});
}
void
LspProcessClient::stopReader()
{
stopReader_ = true;
if (reader_.joinable()) {
// Waking up read() by closing inFd_ is handled in terminateProcess(); ensure its closed first
// Here, best-effort join with small delay
reader_.join();
}
}
void
LspProcessClient::readerLoop()
{
if (debug_)
std::fprintf(stderr, "[kte][lsp] readerLoop start\n");
while (!stopReader_) {
auto msg = transport_->read();
if (!msg.has_value()) {
// EOF or error
break;
}
handleIncoming(msg->raw);
}
if (debug_)
std::fprintf(stderr, "[kte][lsp] readerLoop end\n");
}
void
LspProcessClient::handleIncoming(const std::string &json)
{
try {
auto j = nlohmann::json::parse(json, nullptr, false);
if (j.is_discarded())
return; // malformed JSON
// Validate jsonrpc if present
if (auto itRpc = j.find("jsonrpc"); itRpc != j.end()) {
if (!itRpc->is_string() || *itRpc != "2.0")
return;
}
auto normalizeId = [](const nlohmann::json &idVal) -> std::string {
if (idVal.is_string())
return idVal.get<std::string>();
if (idVal.is_number_integer())
return std::to_string(idVal.get<long long>());
return std::string();
};
// Handle responses (have id and no method) or server -> client requests (have id and method)
if (auto itId = j.find("id"); itId != j.end() && !itId->is_null()) {
const std::string respIdStr = normalizeId(*itId);
// If it's a request from server, it will also have a method
if (auto itMeth = j.find("method"); itMeth != j.end() && itMeth->is_string()) {
const std::string method = *itMeth;
if (method == "workspace/configuration") {
// Respond with default empty settings array matching requested items length
size_t n = 0;
if (auto itParams = j.find("params");
itParams != j.end() && itParams->is_object()) {
if (auto itItems = itParams->find("items");
itItems != itParams->end() && itItems->is_array()) {
n = itItems->size();
}
}
nlohmann::json resp;
resp["jsonrpc"] = "2.0";
// echo id type: if original was string, send string; else number
if (itId->is_string())
resp["id"] = *itId;
else if (itId->is_number_integer())
resp["id"] = *itId;
nlohmann::json arr = nlohmann::json::array();
for (size_t i = 0; i < n; ++i)
arr.push_back(nlohmann::json::object());
resp["result"] = std::move(arr);
transport_->send("response", resp.dump());
return;
}
if (method == "window/showMessageRequest") {
// Best-effort respond with null result (dismiss)
nlohmann::json resp;
resp["jsonrpc"] = "2.0";
if (itId->is_string())
resp["id"] = *itId;
else if (itId->is_number_integer())
resp["id"] = *itId;
resp["result"] = nullptr;
transport_->send("response", resp.dump());
return;
}
// Unknown server request: respond with MethodNotFound
nlohmann::json err;
err["code"] = -32601;
err["message"] = "Method not found";
nlohmann::json resp;
resp["jsonrpc"] = "2.0";
if (itId->is_string())
resp["id"] = *itId;
else if (itId->is_number_integer())
resp["id"] = *itId;
resp["error"] = std::move(err);
transport_->send("response", resp.dump());
return;
}
// Initialize handshake special-case
if (!pendingInitializeId_.empty() && respIdStr == pendingInitializeId_) {
nlohmann::json init;
init["jsonrpc"] = "2.0";
init["method"] = "initialized";
init["params"] = nlohmann::json::object();
transport_->send("initialized", init.dump());
pendingInitializeId_.clear();
}
// Dispatcher lookup
std::function < void(const nlohmann::json &, const nlohmann::json *) > cb;
{
std::lock_guard<std::mutex> lk(pendingMutex_);
auto it = pending_.find(respIdStr);
if (it != pending_.end()) {
cb = it->second.callback;
if (it->second.orderIt != pendingOrder_.end()) {
pendingOrder_.erase(it->second.orderIt);
}
pending_.erase(it);
}
}
if (cb) {
const nlohmann::json *errPtr = nullptr;
const auto itErr = j.find("error");
if (itErr != j.end() && itErr->is_object())
errPtr = &(*itErr);
nlohmann::json result;
const auto itRes = j.find("result");
if (itRes != j.end())
result = *itRes; // may be null
cb(result, errPtr);
}
return;
}
const auto itMethod = j.find("method");
if (itMethod == j.end() || !itMethod->is_string())
return;
const std::string method = *itMethod;
if (method == "window/logMessage") {
if (debug_) {
const auto itParams = j.find("params");
if (itParams != j.end()) {
const auto itMsg = itParams->find("message");
if (itMsg != itParams->end() && itMsg->is_string()) {
std::fprintf(stderr, "[kte][lsp] logMessage: %s\n",
itMsg->get_ref<const std::string &>().c_str());
}
}
}
return;
}
if (method == "window/showMessage") {
const auto itParams = j.find("params");
if (debug_ &&itParams
!=
j.end() && itParams->is_object()
)
{
int typ = 0;
std::string msg;
if (auto itm = itParams->find("message"); itm != itParams->end() && itm->is_string())
msg = *itm;
if (auto ity = itParams->find("type");
ity != itParams->end() && ity->is_number_integer())
typ = *ity;
std::fprintf(stderr, "[kte][lsp] showMessage(type=%d): %s\n", typ, msg.c_str());
}
return;
}
if (method != "textDocument/publishDiagnostics") {
return;
}
const auto itParams = j.find("params");
if (itParams == j.end() || !itParams->is_object())
return;
const auto itUri = itParams->find("uri");
if (itUri == itParams->end() || !itUri->is_string())
return;
const std::string uri = *itUri;
std::vector<Diagnostic> diags;
const auto itDiag = itParams->find("diagnostics");
if (itDiag != itParams->end() && itDiag->is_array()) {
for (const auto &djson: *itDiag) {
if (!djson.is_object())
continue;
Diagnostic d;
// severity
int sev = 3;
if (auto itS = djson.find("severity"); itS != djson.end() && itS->is_number_integer()) {
sev = *itS;
}
switch (sev) {
case 1:
d.severity = DiagnosticSeverity::Error;
break;
case 2:
d.severity = DiagnosticSeverity::Warning;
break;
case 3:
d.severity = DiagnosticSeverity::Information;
break;
case 4:
d.severity = DiagnosticSeverity::Hint;
break;
default:
d.severity = DiagnosticSeverity::Information;
break;
}
if (auto itM = djson.find("message"); itM != djson.end() && itM->is_string()) {
d.message = *itM;
}
if (auto itR = djson.find("range"); itR != djson.end() && itR->is_object()) {
if (auto itStart = itR->find("start");
itStart != itR->end() && itStart->is_object()) {
if (auto itL = itStart->find("line");
itL != itStart->end() && itL->is_number_integer()) {
d.range.start.line = *itL;
}
if (auto itC = itStart->find("character");
itC != itStart->end() && itC->is_number_integer()) {
d.range.start.character = *itC;
}
}
if (auto itEnd = itR->find("end"); itEnd != itR->end() && itEnd->is_object()) {
if (auto itL = itEnd->find("line");
itL != itEnd->end() && itL->is_number_integer()) {
d.range.end.line = *itL;
}
if (auto itC = itEnd->find("character");
itC != itEnd->end() && itC->is_number_integer()) {
d.range.end.character = *itC;
}
}
}
// optional code/source
if (auto itCode = djson.find("code"); itCode != djson.end()) {
if (itCode->is_string())
d.code = itCode->get<std::string>();
else if (itCode->is_number_integer())
d.code = std::to_string(itCode->get<int>());
}
if (auto itSrc = djson.find("source"); itSrc != djson.end() && itSrc->is_string()) {
d.source = itSrc->get<std::string>();
}
diags.push_back(std::move(d));
}
}
if (diagnosticsHandler_) {
diagnosticsHandler_(uri, diags);
}
} catch (...) {
// swallow parse errors
}
}
int
LspProcessClient::sendRequest(const std::string &method, const nlohmann::json &params,
std::function<void(const nlohmann::json & result, const nlohmann::json * errorJson)> cb)
{
if (!running_)
return 0;
int id = nextRequestIntId_++;
nlohmann::json j;
j["jsonrpc"] = "2.0";
j["id"] = id;
j["method"] = method;
if (!params.is_null())
j["params"] = params;
if (debug_)
std::fprintf(stderr, "[kte][lsp] -> request method=%s id=%d\n", method.c_str(), id);
transport_->send(method, j.dump());
if (cb) {
std::function < void() > callDropped;
{
std::lock_guard<std::mutex> lk(pendingMutex_);
if (maxPending_ > 0 && pending_.size() >= maxPending_) {
// Evict oldest
if (!pendingOrder_.empty()) {
std::string oldestId = pendingOrder_.front();
auto it = pending_.find(oldestId);
if (it != pending_.end()) {
auto cbOld = it->second.callback;
std::string methOld = it->second.method;
if (debug_) {
std::fprintf(
stderr,
"[kte][lsp] dropping oldest pending id=%s method=%s (cap=%zu)\n",
oldestId.c_str(), methOld.c_str(), maxPending_);
}
// Prepare drop callback to run outside lock
callDropped = [cbOld] {
if (cbOld) {
nlohmann::json err;
err["code"] = -32001;
err["message"] =
"Request dropped (max pending exceeded)";
cbOld(nlohmann::json(), &err);
}
};
pending_.erase(it);
}
pendingOrder_.pop_front();
}
}
pendingOrder_.push_back(std::to_string(id));
auto itOrder = pendingOrder_.end();
--itOrder;
PendingRequest pr;
pr.method = method;
pr.callback = std::move(cb);
if (requestTimeoutMs_ > 0) {
pr.deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(
requestTimeoutMs_);
}
pr.orderIt = itOrder;
pending_[std::to_string(id)] = std::move(pr);
}
if (callDropped)
callDropped();
}
return id;
}
void
LspProcessClient::completion(const std::string &uri, Position pos, CompletionCallback cb)
{
nlohmann::json params;
params["textDocument"]["uri"] = uri;
params["position"]["line"] = pos.line;
params["position"]["character"] = pos.character;
sendRequest("textDocument/completion", params,
[cb = std::move(cb)](const nlohmann::json &result, const nlohmann::json *error) {
CompletionList out{};
std::string err;
if (error) {
if (auto itMsg = error->find("message");
itMsg != error->end() && itMsg->is_string())
err = itMsg->get<std::string>();
else
err = "LSP error";
} else {
auto parseItem = [](const nlohmann::json &j) -> CompletionItem {
CompletionItem it{};
if (auto il = j.find("label"); il != j.end() && il->is_string())
it.label = il->get<std::string>();
if (auto idt = j.find("detail"); idt != j.end() && idt->is_string())
it.detail = idt->get<std::string>();
if (auto ins = j.find("insertText"); ins != j.end() && ins->is_string())
it.insertText = ins->get<std::string>();
return it;
};
if (result.is_array()) {
for (const auto &ji: result) {
if (ji.is_object())
out.items.push_back(parseItem(ji));
}
} else if (result.is_object()) {
if (auto ii = result.find("isIncomplete");
ii != result.end() && ii->is_boolean())
out.isIncomplete = ii->get<bool>();
if (auto itms = result.find("items");
itms != result.end() && itms->is_array()) {
for (const auto &ji: *itms) {
if (ji.is_object())
out.items.push_back(parseItem(ji));
}
}
}
}
if (cb)
cb(out, err);
});
}
void
LspProcessClient::hover(const std::string &uri, Position pos, HoverCallback cb)
{
nlohmann::json params;
params["textDocument"]["uri"] = uri;
params["position"]["line"] = pos.line;
params["position"]["character"] = pos.character;
sendRequest("textDocument/hover", params,
[cb = std::move(cb)](const nlohmann::json &result, const nlohmann::json *error) {
HoverResult out{};
std::string err;
if (error) {
if (auto itMsg = error->find("message");
itMsg != error->end() && itMsg->is_string())
err = itMsg->get<std::string>();
else
err = "LSP error";
} else if (!result.is_null()) {
auto appendText = [&](const std::string &s) {
if (!out.contents.empty())
out.contents.push_back('\n');
out.contents += s;
};
if (result.is_object()) {
if (auto itC = result.find("contents"); itC != result.end()) {
if (itC->is_string()) {
appendText(itC->get<std::string>());
} else if (itC->is_object()) {
if (auto itV = itC->find("value");
itV != itC->end() && itV->is_string())
appendText(itV->get<std::string>());
} else if (itC->is_array()) {
for (const auto &el: *itC) {
if (el.is_string())
appendText(el.get<std::string>());
else if (el.is_object()) {
if (auto itV = el.find("value");
itV != el.end() && itV->is_string())
appendText(itV->get<std::string>());
}
}
}
}
if (auto itR = result.find("range");
itR != result.end() && itR->is_object()) {
Range r{};
if (auto s = itR->find("start");
s != itR->end() && s->is_object()) {
if (auto il = s->find("line");
il != s->end() && il->is_number_integer())
r.start.line = *il;
if (auto ic = s->find("character");
ic != s->end() && ic->is_number_integer())
r.start.character = *ic;
}
if (auto e = itR->find("end"); e != itR->end() && e->is_object()) {
if (auto il = e->find("line");
il != e->end() && il->is_number_integer())
r.end.line = *il;
if (auto ic = e->find("character");
ic != e->end() && ic->is_number_integer())
r.end.character = *ic;
}
out.range = r;
}
}
}
if (cb)
cb(out, err);
});
}
void
LspProcessClient::definition(const std::string &uri, Position pos, LocationCallback cb)
{
nlohmann::json params;
params["textDocument"]["uri"] = uri;
params["position"]["line"] = pos.line;
params["position"]["character"] = pos.character;
sendRequest("textDocument/definition", params,
[cb = std::move(cb)](const nlohmann::json &result, const nlohmann::json *error) {
std::vector<Location> out;
std::string err;
auto parseRange = [](const nlohmann::json &jr) -> Range {
Range r{};
if (!jr.is_object())
return r;
if (auto s = jr.find("start"); s != jr.end() && s->is_object()) {
if (auto il = s->find("line"); il != s->end() && il->is_number_integer())
r.start.line = *il;
if (auto ic = s->find("character");
ic != s->end() && ic->is_number_integer())
r.start.character = *ic;
}
if (auto e = jr.find("end"); e != jr.end() && e->is_object()) {
if (auto il = e->find("line"); il != e->end() && il->is_number_integer())
r.end.line = *il;
if (auto ic = e->find("character");
ic != e->end() && e->is_number_integer())
r.end.character = *ic;
}
return r;
};
auto pushLocObj = [&](const nlohmann::json &jo) {
Location loc{};
if (auto iu = jo.find("uri"); iu != jo.end() && iu->is_string())
loc.uri = iu->get<std::string>();
if (auto ir = jo.find("range"); ir != jo.end())
loc.range = parseRange(*ir);
out.push_back(std::move(loc));
};
if (error) {
if (auto itMsg = error->find("message");
itMsg != error->end() && itMsg->is_string())
err = itMsg->get<std::string>();
else
err = "LSP error";
} else if (!result.is_null()) {
if (result.is_object()) {
if (result.contains("uri") && result.contains("range")) {
pushLocObj(result);
} else if (result.contains("targetUri")) {
Location loc{};
if (auto tu = result.find("targetUri");
tu != result.end() && tu->is_string())
loc.uri = tu->get<std::string>();
if (auto tr = result.find("targetRange"); tr != result.end())
loc.range = parseRange(*tr);
out.push_back(std::move(loc));
}
} else if (result.is_array()) {
for (const auto &el: result) {
if (el.is_object()) {
if (el.contains("uri")) {
pushLocObj(el);
} else if (el.contains("targetUri")) {
Location loc{};
if (auto tu = el.find("targetUri");
tu != el.end() && tu->is_string())
loc.uri = tu->get<std::string>();
if (auto tr = el.find("targetRange");
tr != el.end())
loc.range = parseRange(*tr);
out.push_back(std::move(loc));
}
}
}
}
}
if (cb)
cb(out, err);
});
}
bool
LspProcessClient::isRunning() const
{
return running_;
}
std::string
LspProcessClient::getServerName() const
{
return command_;
}
std::string
LspProcessClient::toFileUri(const std::string &path)
{
if (path.empty())
return std::string();
#ifdef _WIN32
return std::string("file:/") + path;
#else
return std::string("file://") + path;
#endif
}
void
LspProcessClient::startTimeoutWatchdog()
{
stopTimeout_ = false;
if (requestTimeoutMs_ <= 0)
return;
timeoutThread_ = std::thread([this] {
while (!stopTimeout_) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
auto now = std::chrono::steady_clock::now();
struct Expired {
std::string id;
std::string method;
std::function<void(const nlohmann::json &, const nlohmann::json *)> cb;
};
std::vector<Expired> expired;
{
std::lock_guard<std::mutex> lk(pendingMutex_);
for (auto it = pending_.begin(); it != pending_.end();) {
const auto &pr = it->second;
if (pr.deadline.time_since_epoch().count() != 0 && now >= pr.deadline) {
expired.push_back(Expired{it->first, pr.method, pr.callback});
if (pr.orderIt != pendingOrder_.end())
pendingOrder_.erase(pr.orderIt);
it = pending_.erase(it);
} else {
++it;
}
}
}
for (auto &kv: expired) {
if (debug_) {
std::fprintf(stderr, "[kte][lsp] request timeout id=%s method=%s\n",
kv.id.c_str(), kv.method.c_str());
}
if (kv.cb) {
nlohmann::json err;
err["code"] = -32000;
err["message"] = "Request timed out";
kv.cb(nlohmann::json(), &err);
}
}
}
});
}
void
LspProcessClient::stopTimeoutWatchdog()
{
stopTimeout_ = true;
if (timeoutThread_.joinable())
timeoutThread_.join();
}
} // namespace kte::lsp

189
lsp/LspProcessClient.h Normal file
View File

@@ -0,0 +1,189 @@
/*
* LspProcessClient.h - process-based LSP client (initial stub)
*/
#ifndef KTE_LSP_PROCESS_CLIENT_H
#define KTE_LSP_PROCESS_CLIENT_H
#include <memory>
#include <string>
#include <vector>
#include <thread>
#include <atomic>
#include <functional>
#include <unordered_map>
#include <mutex>
#include <chrono>
#include <list>
#include "json.h"
#include "LspClient.h"
#include "JsonRpcTransport.h"
namespace kte::lsp {
class LspProcessClient : public LspClient {
public:
LspProcessClient(std::string serverCommand, std::vector<std::string> serverArgs);
~LspProcessClient() override;
bool initialize(const std::string &rootPath) override;
void shutdown() override;
void didOpen(const std::string &uri, const std::string &languageId,
int version, const std::string &text) override;
void didChange(const std::string &uri, int version,
const std::vector<TextDocumentContentChangeEvent> &changes) override;
void didClose(const std::string &uri) override;
void didSave(const std::string &uri) override;
// Language Features (wire-up via dispatcher; minimal callbacks for now)
void completion(const std::string &uri, Position pos,
CompletionCallback cb) override;
void hover(const std::string &uri, Position pos,
HoverCallback cb) override;
void definition(const std::string &uri, Position pos,
LocationCallback cb) override;
bool isRunning() const override;
std::string getServerName() const override;
void setDiagnosticsHandler(DiagnosticsHandler h) override
{
diagnosticsHandler_ = std::move(h);
}
private:
std::string command_;
std::vector<std::string> args_;
std::unique_ptr<JsonRpcTransport> transport_;
bool running_ = false;
bool debug_ = false;
int inFd_ = -1; // read from server (server stdout)
int outFd_ = -1; // write to server (server stdin)
pid_t childPid_ = -1;
int nextRequestIntId_ = 1;
std::string pendingInitializeId_{}; // echo exactly as sent (string form)
// Incoming processing
std::thread reader_;
std::atomic<bool> stopReader_{false};
DiagnosticsHandler diagnosticsHandler_{};
// Simple request dispatcher: map request id -> callback
struct PendingRequest {
std::string method;
// If error is present, errorJson points to it; otherwise nullptr
std::function<void(const nlohmann::json & result, const nlohmann::json * errorJson)> callback;
// Optional timeout
std::chrono::steady_clock::time_point deadline{}; // epoch if no timeout
// Order tracking for LRU eviction
std::list<std::string>::iterator orderIt{};
};
std::unordered_map<std::string, PendingRequest> pending_;
// Maintain insertion order (oldest at front)
std::list<std::string> pendingOrder_;
std::mutex pendingMutex_;
// Timeout/watchdog for pending requests
std::thread timeoutThread_;
std::atomic<bool> stopTimeout_{false};
int64_t requestTimeoutMs_ = 0; // 0 = disabled
size_t maxPending_ = 0; // 0 = unlimited
bool spawnServerProcess();
void terminateProcess();
static std::string toFileUri(const std::string &path);
void sendInitialize(const std::string &rootPath);
void startReader();
void stopReader();
void readerLoop();
void handleIncoming(const std::string &json);
// Helper to send a request with params and register a response callback
int sendRequest(const std::string &method, const nlohmann::json &params,
std::function<void(const nlohmann::json & result, const nlohmann::json * errorJson)> cb);
// Start/stop timeout thread
void startTimeoutWatchdog();
void stopTimeoutWatchdog();
public:
// Test hook: inject a raw JSON message as if received from server
void debugInjectMessageForTest(const std::string &raw)
{
handleIncoming(raw);
}
// Test hook: add a pending request entry manually
void debugAddPendingForTest(const std::string &id, const std::string &method,
std::function<void(const nlohmann::json & result,
const nlohmann::json *errorJson)
>
cb
)
{
std::lock_guard<std::mutex> lk(pendingMutex_);
pendingOrder_.push_back(id);
auto it = pendingOrder_.end();
--it;
PendingRequest pr{method, std::move(cb), {}, it};
pending_[id] = std::move(pr);
}
// Test hook: override timeout
void setRequestTimeoutMsForTest(int64_t ms)
{
requestTimeoutMs_ = ms;
}
// Test hook: set max pending
void setMaxPendingForTest(size_t maxPending)
{
maxPending_ = maxPending;
}
// Test hook: set running flag (to allow sendRequest in tests without spawning)
void setRunningForTest(bool r)
{
running_ = r;
}
// Test hook: send a raw request using internal machinery
int debugSendRequestForTest(const std::string &method, const nlohmann::json &params,
std::function<void(const nlohmann::json & result,
const nlohmann::json *errorJson)
>
cb
)
{
return sendRequest(method, params, std::move(cb));
}
};
} // namespace kte::lsp
#endif // KTE_LSP_PROCESS_CLIENT_H

47
lsp/LspServerConfig.h Normal file
View File

@@ -0,0 +1,47 @@
/*
* LspServerConfig.h - per-language LSP server configuration
*/
#ifndef KTE_LSP_SERVER_CONFIG_H
#define KTE_LSP_SERVER_CONFIG_H
#include <string>
#include <unordered_map>
#include <vector>
namespace kte::lsp {
enum class LspSyncMode {
None = 0,
Full = 1,
Incremental = 2,
};
struct LspServerConfig {
std::string command; // executable name/path
std::vector<std::string> args; // CLI args
std::vector<std::string> filePatterns; // e.g. {"*.rs"}
std::string rootPatterns; // e.g. "Cargo.toml"
LspSyncMode preferredSyncMode = LspSyncMode::Incremental;
bool autostart = true;
std::unordered_map<std::string, std::string> initializationOptions; // placeholder
std::unordered_map<std::string, std::string> settings; // placeholder
};
// Provide a small set of defaults; callers may ignore
inline std::vector<LspServerConfig>
GetDefaultServerConfigs()
{
return std::vector<LspServerConfig>{
LspServerConfig{
.command = "rust-analyzer", .args = {}, .filePatterns = {"*.rs"}, .rootPatterns = "Cargo.toml"
},
LspServerConfig{
.command = "clangd", .args = {"--background-index"},
.filePatterns = {"*.c", "*.cc", "*.cpp", "*.h", "*.hpp"},
.rootPatterns = "compile_commands.json"
},
LspServerConfig{.command = "gopls", .args = {}, .filePatterns = {"*.go"}, .rootPatterns = "go.mod"},
};
}
} // namespace kte::lsp
#endif // KTE_LSP_SERVER_CONFIG_H

55
lsp/LspTypes.h Normal file
View File

@@ -0,0 +1,55 @@
/*
* LspTypes.h - minimal LSP-related data types for initial integration
*/
#ifndef KTE_LSP_TYPES_H
#define KTE_LSP_TYPES_H
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace kte::lsp {
// NOTE on coordinates:
// - Internal editor coordinates use UTF-8 columns counted by Unicode scalars.
// - LSP wire protocol uses UTF-16 code units for the `character` field.
// Conversions are performed in higher layers via `lsp/UtfCodec.h` helpers.
struct Position {
int line = 0;
int character = 0;
};
struct Range {
Position start;
Position end;
};
struct TextDocumentContentChangeEvent {
std::optional<Range> range; // if not set, represents full document change
std::string text; // new text for the given range
};
// Minimal feature result types for phase 1
struct CompletionItem {
std::string label;
std::optional<std::string> detail; // optional extra info
std::optional<std::string> insertText; // if present, use instead of label
};
struct CompletionList {
bool isIncomplete = false;
std::vector<CompletionItem> items;
};
struct HoverResult {
std::string contents; // concatenated plaintext/markdown for now
std::optional<Range> range; // optional range
};
struct Location {
std::string uri;
Range range;
};
} // namespace kte::lsp
#endif // KTE_LSP_TYPES_H

View File

@@ -0,0 +1,53 @@
/*
* TerminalDiagnosticDisplay.cc - minimal stub implementation
*/
#include "TerminalDiagnosticDisplay.h"
#include "../TerminalRenderer.h"
namespace kte::lsp {
TerminalDiagnosticDisplay::TerminalDiagnosticDisplay(TerminalRenderer *renderer)
: renderer_(renderer) {}
void
TerminalDiagnosticDisplay::updateDiagnostics(const std::string &uri,
const std::vector<Diagnostic> &diagnostics)
{
(void) uri;
(void) diagnostics;
// Stub: no rendering yet. Future: gutter markers, underlines, virtual text.
}
void
TerminalDiagnosticDisplay::showInlineDiagnostic(const Diagnostic &diagnostic)
{
(void) diagnostic;
// Stub: show as message line in future.
}
void
TerminalDiagnosticDisplay::showDiagnosticList(const std::vector<Diagnostic> &diagnostics)
{
(void) diagnostics;
// Stub: open a panel/list in future.
}
void
TerminalDiagnosticDisplay::hideDiagnosticList()
{
// Stub
}
void
TerminalDiagnosticDisplay::updateStatusBar(int errorCount, int warningCount)
{
(void) errorCount;
(void) warningCount;
// Stub: integrate with status bar rendering later.
}
} // namespace kte::lsp

View File

@@ -0,0 +1,35 @@
/*
* TerminalDiagnosticDisplay.h - Terminal (ncurses) diagnostics visualization stub
*/
#ifndef KTE_LSP_TERMINAL_DIAGNOSTIC_DISPLAY_H
#define KTE_LSP_TERMINAL_DIAGNOSTIC_DISPLAY_H
#include <string>
#include <vector>
#include "DiagnosticDisplay.h"
class TerminalRenderer; // fwd
namespace kte::lsp {
class TerminalDiagnosticDisplay final : public DiagnosticDisplay {
public:
explicit TerminalDiagnosticDisplay(TerminalRenderer *renderer);
void updateDiagnostics(const std::string &uri,
const std::vector<Diagnostic> &diagnostics) override;
void showInlineDiagnostic(const Diagnostic &diagnostic) override;
void showDiagnosticList(const std::vector<Diagnostic> &diagnostics) override;
void hideDiagnosticList() override;
void updateStatusBar(int errorCount, int warningCount) override;
private:
[[maybe_unused]] TerminalRenderer *renderer_{}; // non-owning
};
} // namespace kte::lsp
#endif // KTE_LSP_TERMINAL_DIAGNOSTIC_DISPLAY_H

155
lsp/UtfCodec.cc Normal file
View File

@@ -0,0 +1,155 @@
/*
* UtfCodec.cc - UTF-8 <-> UTF-16 code unit position conversions
*/
#include "UtfCodec.h"
#include <cassert>
namespace kte::lsp {
// Decode next code point from a UTF-8 string.
// On invalid input, consumes 1 byte and returns U+FFFD.
// Returns: (codepoint, bytesConsumed)
static inline std::pair<uint32_t, size_t>
decodeUtf8(std::string_view s, size_t i)
{
if (i >= s.size())
return {0, 0};
unsigned char c0 = static_cast<unsigned char>(s[i]);
if (c0 < 0x80) {
return {c0, 1};
}
// Determine sequence length
if ((c0 & 0xE0) == 0xC0) {
if (i + 1 >= s.size())
return {0xFFFD, 1};
unsigned char c1 = static_cast<unsigned char>(s[i + 1]);
if ((c1 & 0xC0) != 0x80)
return {0xFFFD, 1};
uint32_t cp = ((c0 & 0x1F) << 6) | (c1 & 0x3F);
// Overlong check: must be >= 0x80
if (cp < 0x80)
return {0xFFFD, 1};
return {cp, 2};
}
if ((c0 & 0xF0) == 0xE0) {
if (i + 2 >= s.size())
return {0xFFFD, 1};
unsigned char c1 = static_cast<unsigned char>(s[i + 1]);
unsigned char c2 = static_cast<unsigned char>(s[i + 2]);
if ((c1 & 0xC0) != 0x80 || (c2 & 0xC0) != 0x80)
return {0xFFFD, 1};
uint32_t cp = ((c0 & 0x0F) << 12) | ((c1 & 0x3F) << 6) | (c2 & 0x3F);
// Overlong / surrogate range check
if (cp < 0x800 || (cp >= 0xD800 && cp <= 0xDFFF))
return {0xFFFD, 1};
return {cp, 3};
}
if ((c0 & 0xF8) == 0xF0) {
if (i + 3 >= s.size())
return {0xFFFD, 1};
unsigned char c1 = static_cast<unsigned char>(s[i + 1]);
unsigned char c2 = static_cast<unsigned char>(s[i + 2]);
unsigned char c3 = static_cast<unsigned char>(s[i + 3]);
if ((c1 & 0xC0) != 0x80 || (c2 & 0xC0) != 0x80 || (c3 & 0xC0) != 0x80)
return {0xFFFD, 1};
uint32_t cp = ((c0 & 0x07) << 18) | ((c1 & 0x3F) << 12) | ((c2 & 0x3F) << 6) | (c3 & 0x3F);
// Overlong / max range check
if (cp < 0x10000 || cp > 0x10FFFF)
return {0xFFFD, 1};
return {cp, 4};
}
return {0xFFFD, 1};
}
static inline size_t
utf16UnitsForCodepoint(uint32_t cp)
{
return (cp <= 0xFFFF) ? 1 : 2;
}
size_t
utf8ColToUtf16Units(std::string_view lineUtf8, size_t utf8Col)
{
// Count by Unicode scalars up to utf8Col; clamp at EOL
size_t units = 0;
size_t col = 0;
size_t i = 0;
while (i < lineUtf8.size()) {
if (col >= utf8Col)
break;
auto [cp, n] = decodeUtf8(lineUtf8, i);
if (n == 0)
break;
units += utf16UnitsForCodepoint(cp);
i += n;
++col;
}
return units;
}
size_t
utf16UnitsToUtf8Col(std::string_view lineUtf8, size_t utf16Units)
{
// Traverse code points until consuming utf16Units (or reaching EOL)
size_t units = 0;
size_t col = 0;
size_t i = 0;
while (i < lineUtf8.size()) {
auto [cp, n] = decodeUtf8(lineUtf8, i);
if (n == 0)
break;
size_t add = utf16UnitsForCodepoint(cp);
if (units + add > utf16Units)
break;
units += add;
i += n;
++col;
if (units == utf16Units)
break;
}
return col;
}
Position
toUtf16(const std::string &uri, const Position &pUtf8, const LineProvider &provider)
{
Position out = pUtf8;
std::string_view line = provider ? provider(uri, pUtf8.line) : std::string_view();
out.character = static_cast<int>(utf8ColToUtf16Units(line, static_cast<size_t>(pUtf8.character)));
return out;
}
Position
toUtf8(const std::string &uri, const Position &pUtf16, const LineProvider &provider)
{
Position out = pUtf16;
std::string_view line = provider ? provider(uri, pUtf16.line) : std::string_view();
out.character = static_cast<int>(utf16UnitsToUtf8Col(line, static_cast<size_t>(pUtf16.character)));
return out;
}
Range
toUtf16(const std::string &uri, const Range &rUtf8, const LineProvider &provider)
{
Range r;
r.start = toUtf16(uri, rUtf8.start, provider);
r.end = toUtf16(uri, rUtf8.end, provider);
return r;
}
Range
toUtf8(const std::string &uri, const Range &rUtf16, const LineProvider &provider)
{
Range r;
r.start = toUtf8(uri, rUtf16.start, provider);
r.end = toUtf8(uri, rUtf16.end, provider);
return r;
}
} // namespace kte::lsp

37
lsp/UtfCodec.h Normal file
View File

@@ -0,0 +1,37 @@
/*
* UtfCodec.h - Helpers for UTF-8 <-> UTF-16 code unit position conversions
*/
#ifndef KTE_LSP_UTF_CODEC_H
#define KTE_LSP_UTF_CODEC_H
#include <cstddef>
#include <functional>
#include <string>
#include <string_view>
#include "LspTypes.h"
namespace kte::lsp {
// Map between editor-internal UTF-8 columns (by Unicode scalar count)
// and LSP wire UTF-16 code units (per LSP spec).
// Convert a UTF-8 column index (in Unicode scalars) to UTF-16 code units for a given line.
size_t utf8ColToUtf16Units(std::string_view lineUtf8, size_t utf8Col);
// Convert a UTF-16 code unit count to a UTF-8 column index (in Unicode scalars) for a given line.
size_t utf16UnitsToUtf8Col(std::string_view lineUtf8, size_t utf16Units);
// Line text provider to allow conversions without giving the codec direct buffer access.
using LineProvider = std::function<std::string_view(const std::string & uri, int line)>;
// Convenience helpers for positions and ranges using a line provider.
Position toUtf16(const std::string &uri, const Position &pUtf8, const LineProvider &provider);
Position toUtf8(const std::string &uri, const Position &pUtf16, const LineProvider &provider);
Range toUtf16(const std::string &uri, const Range &rUtf8, const LineProvider &provider);
Range toUtf8(const std::string &uri, const Range &rUtf16, const LineProvider &provider);
} // namespace kte::lsp
#endif // KTE_LSP_UTF_CODEC_H

41
main.cc
View File

@@ -12,6 +12,7 @@
#include "Editor.h" #include "Editor.h"
#include "Frontend.h" #include "Frontend.h"
#include "TerminalFrontend.h" #include "TerminalFrontend.h"
#include "lsp/LspManager.h"
#if defined(KTE_BUILD_GUI) #if defined(KTE_BUILD_GUI)
#include "GUIFrontend.h" #include "GUIFrontend.h"
@@ -28,6 +29,8 @@ PrintUsage(const char *prog)
{ {
std::cerr << "Usage: " << prog << " [OPTIONS] [files]\n" std::cerr << "Usage: " << prog << " [OPTIONS] [files]\n"
<< "Options:\n" << "Options:\n"
<< " -c, --chdir DIR Change working directory before opening files\n"
<< " -d, --debug Enable LSP debug logging\n"
<< " -g, --gui Use GUI frontend (if built)\n" << " -g, --gui Use GUI frontend (if built)\n"
<< " -t, --term Use terminal (ncurses) frontend [default]\n" << " -t, --term Use terminal (ncurses) frontend [default]\n"
<< " -h, --help Show this help and exit\n" << " -h, --help Show this help and exit\n"
@@ -36,17 +39,25 @@ PrintUsage(const char *prog)
int int
main(int argc, const char *argv[]) main(const int argc, const char *argv[])
{ {
Editor editor; Editor editor;
// Wire up LSP manager (no diagnostic UI yet; frontends may provide later)
kte::lsp::LspManager lspMgr(&editor, nullptr);
editor.SetLspManager(&lspMgr);
// CLI parsing using getopt_long // CLI parsing using getopt_long
bool req_gui = false; bool req_gui = false;
bool req_term = false; bool req_term = false;
bool show_help = false; bool show_help = false;
bool show_version = false; bool show_version = false;
bool lsp_debug = false;
std::string nwd;
static struct option long_opts[] = { static struct option long_opts[] = {
{"chdir", required_argument, nullptr, 'c'},
{"debug", no_argument, nullptr, 'd'},
{"gui", no_argument, nullptr, 'g'}, {"gui", no_argument, nullptr, 'g'},
{"term", no_argument, nullptr, 't'}, {"term", no_argument, nullptr, 't'},
{"help", no_argument, nullptr, 'h'}, {"help", no_argument, nullptr, 'h'},
@@ -56,8 +67,14 @@ main(int argc, const char *argv[])
int opt; int opt;
int long_index = 0; int long_index = 0;
while ((opt = getopt_long(argc, const_cast<char *const *>(argv), "gthV", long_opts, &long_index)) != -1) { while ((opt = getopt_long(argc, const_cast<char *const *>(argv), "c:dgthV", long_opts, &long_index)) != -1) {
switch (opt) { switch (opt) {
case 'c':
nwd = optarg;
break;
case 'd':
lsp_debug = true;
break;
case 'g': case 'g':
req_gui = true; req_gui = true;
break; break;
@@ -90,6 +107,16 @@ main(int argc, const char *argv[])
(void) req_term; // suppress unused warning when GUI is not compiled in (void) req_term; // suppress unused warning when GUI is not compiled in
#endif #endif
// Apply LSP debug setting strictly based on -d flag
lspMgr.setDebugLogging(lsp_debug);
if (lsp_debug) {
// Ensure LSP subprocess client picks up debug via environment
::setenv("KTE_LSP_DEBUG", "1", 1);
} else {
// Prevent environment from enabling debug implicitly
::unsetenv("KTE_LSP_DEBUG");
}
// Determine frontend // Determine frontend
#if !defined(KTE_BUILD_GUI) #if !defined(KTE_BUILD_GUI)
if (req_gui) { if (req_gui) {
@@ -104,6 +131,9 @@ main(int argc, const char *argv[])
} else if (req_term) { } else if (req_term) {
use_gui = false; use_gui = false;
} else { } else {
// Default depends on build target: kge defaults to GUI, kte to terminal // Default depends on build target: kge defaults to GUI, kte to terminal
#if defined(KTE_DEFAULT_GUI) #if defined(KTE_DEFAULT_GUI)
use_gui = true; use_gui = true;
@@ -199,6 +229,13 @@ main(int argc, const char *argv[])
} }
#endif #endif
#if defined(KTE_BUILD_GUI)
if (!nwd.empty()) {
if (chdir(nwd.c_str()) != 0) {
std::cerr << "kge: failed to chdir to " << nwd << std::endl;
}
}
#endif
if (!fe->Init(editor)) { if (!fe->Init(editor)) {
std::cerr << "kte: failed to initialize frontend" << std::endl; std::cerr << "kte: failed to initialize frontend" << std::endl;
return 1; return 1;

View File

@@ -1,4 +1,4 @@
#include "TreeSitterHighlighter.h" #include "../TreeSitterHighlighter.h"
#ifdef KTE_ENABLE_TREESITTER #ifdef KTE_ENABLE_TREESITTER

119
test_lsp_decode.cc Normal file
View File

@@ -0,0 +1,119 @@
// test_lsp_decode.cc - tests for LspProcessClient JSON decoding/dispatch
#include <cassert>
#include <atomic>
#include <string>
#include <vector>
#include "lsp/LspProcessClient.h"
#include "lsp/Diagnostic.h"
using namespace kte::lsp;
int
main()
{
// Create client (won't start a process for these tests)
LspProcessClient client("/bin/echo", {});
// 1) Numeric id response should match string key "42"
{
std::atomic<bool> called{false};
client.debugAddPendingForTest("42", "dummy",
[&](const nlohmann::json &result, const nlohmann::json *err) {
(void) result;
(void) err;
called = true;
});
std::string resp = "{\"jsonrpc\":\"2.0\",\"id\":42,\"result\":null}";
client.debugInjectMessageForTest(resp);
assert(called.load());
}
// 2) String id response should resolve
{
std::atomic<bool> called{false};
client.debugAddPendingForTest("abc123", "dummy",
[&](const nlohmann::json &result, const nlohmann::json *err) {
(void) result;
(void) err;
called = true;
});
std::string resp = "{\"jsonrpc\":\"2.0\",\"id\":\"abc123\",\"result\":{}}";
client.debugInjectMessageForTest(resp);
assert(called.load());
}
// 3) Diagnostics notification decoding
{
std::atomic<bool> diagCalled{false};
client.setDiagnosticsHandler([&](const std::string &uri, const std::vector<Diagnostic> &d) {
assert(uri == "file:///tmp/x.rs");
assert(!d.empty());
diagCalled = true;
});
std::string notif = R"({
"jsonrpc":"2.0",
"method":"textDocument/publishDiagnostics",
"params":{
"uri":"file:///tmp/x.rs",
"diagnostics":[{
"range": {"start": {"line":0, "character":1}, "end": {"line":0, "character":2}},
"severity": 1,
"message": "oops"
}]
}
})";
client.debugInjectMessageForTest(notif);
assert(diagCalled.load());
}
// 4) ShowMessage notification should be safely handled (no crash)
{
std::string msg =
"{\"jsonrpc\":\"2.0\",\"method\":\"window/showMessage\",\"params\":{\"type\":2,\"message\":\"hi\"}}";
client.debugInjectMessageForTest(msg);
}
// 5) workspace/configuration request should be responded to (no crash)
{
std::string req = R"({
"jsonrpc":"2.0",
"id": 7,
"method":"workspace/configuration",
"params": {"items": [{"section":"x"},{"section":"y"}]}
})";
client.debugInjectMessageForTest(req);
}
// 6) Pending cap eviction: oldest request is dropped with -32001
{
LspProcessClient c2("/bin/echo", {});
c2.setRunningForTest(true);
c2.setMaxPendingForTest(2);
std::atomic<int> drops{0};
auto make_cb = [&](const char *tag) {
return [&, tag](const nlohmann::json &res, const nlohmann::json *err) {
(void) res;
if (err && err->is_object()) {
auto it = err->find("code");
if (it != err->end() && it->is_number_integer() && *it == -32001) {
// Only the oldest (first) should be dropped
if (std::string(tag) == "first")
drops.fetch_add(1);
}
}
};
};
// Enqueue 3 requests; cap=2 -> first should be dropped immediately when third is added
c2.debugSendRequestForTest("a", nlohmann::json::object(), make_cb("first"));
c2.debugSendRequestForTest("b", nlohmann::json::object(), make_cb("second"));
c2.debugSendRequestForTest("c", nlohmann::json::object(), make_cb("third"));
// Allow callbacks (none are async here, drop is invoked inline after send)
assert(drops.load() == 1);
}
std::puts("test_lsp_decode: OK");
return 0;
}

76
test_transport.cc Normal file
View File

@@ -0,0 +1,76 @@
// test_transport.cc - transport framing tests
#include <cassert>
#include <cstdio>
#include <cstring>
#include <string>
#include <optional>
#include <unistd.h>
#include "lsp/JsonRpcTransport.h"
using namespace kte::lsp;
static void
write_all(int fd, const void *bufv, size_t len)
{
const char *buf = static_cast<const char *>(bufv);
size_t left = len;
while (left > 0) {
ssize_t n = ::write(fd, buf, left);
if (n < 0) {
if (errno == EINTR)
continue;
std::perror("write");
std::abort();
}
buf += static_cast<size_t>(n);
left -= static_cast<size_t>(n);
}
}
int
main()
{
int p[2];
assert(pipe(p) == 0);
int readFd = p[0];
int writeFd = p[1];
JsonRpcTransport t;
// We only need inFd for read tests; pass writeFd for completeness
t.connect(readFd, writeFd);
auto sendMsg = [&](const std::string &payload, bool lowerHeader) {
std::string header = (lowerHeader ? std::string("content-length: ") : std::string("Content-Length: ")) +
std::to_string(payload.size()) + "\r\n\r\n";
write_all(writeFd, header.data(), header.size());
// Send body in two parts to exercise partial reads
size_t mid = payload.size() / 2;
write_all(writeFd, payload.data(), mid);
write_all(writeFd, payload.data() + mid, payload.size() - mid);
};
std::string p1 = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":null}";
std::string p2 = "{\"jsonrpc\":\"2.0\",\"method\":\"ping\"}";
sendMsg(p1, false);
sendMsg(p2, true); // case-insensitive header
auto m1 = t.read();
assert(m1.has_value());
assert(m1->raw == p1);
auto m2 = t.read();
assert(m2.has_value());
assert(m2->raw == p2);
// Close write end to signal EOF; next read should return nullopt
::close(writeFd);
auto m3 = t.read();
assert(!m3.has_value());
::close(readFd);
std::puts("test_transport: OK");
return 0;
}

101
test_utfcodec.cc Normal file
View File

@@ -0,0 +1,101 @@
// test_utfcodec.cc - simple tests for UtfCodec helpers
#include <cassert>
#include <cstdio>
#include <string>
#include <string_view>
#include "lsp/UtfCodec.h"
using namespace kte::lsp;
static std::string_view
lp(const std::string &, int)
{
return std::string_view();
}
int
main()
{
// ASCII: each scalar = 1 UTF-16 unit
{
std::string s = "hello"; // 5 ASCII
assert(utf8ColToUtf16Units(s, 0) == 0);
assert(utf8ColToUtf16Units(s, 3) == 3);
assert(utf16UnitsToUtf8Col(s, 3) == 3);
assert(utf16UnitsToUtf8Col(s, 10) == 5); // clamp to EOL
}
// BMP multibyte (e.g., ü U+00FC, α U+03B1) -> still 1 UTF-16 unit
{
std::string s = u8"αb"; // bytes: a [C3 BC] [CE B1] b
// columns by codepoints: a(0), ü(1), α(2), b(3)
assert(utf8ColToUtf16Units(s, 0) == 0);
assert(utf8ColToUtf16Units(s, 1) == 1);
assert(utf8ColToUtf16Units(s, 2) == 2);
assert(utf8ColToUtf16Units(s, 4) == 4); // past EOL clamps to 4 units
assert(utf16UnitsToUtf8Col(s, 0) == 0);
assert(utf16UnitsToUtf8Col(s, 2) == 2);
assert(utf16UnitsToUtf8Col(s, 4) == 4);
}
// Non-BMP (emoji) -> 2 UTF-16 units per code point
{
std::string s = u8"A😀B"; // U+1F600 between A and B
// codepoints: A, 😀, B => utf8 columns 0..3
// utf16 units: A(1), 😀(2), B(1) cumulative: 0,1,3,4
assert(utf8ColToUtf16Units(s, 0) == 0);
assert(utf8ColToUtf16Units(s, 1) == 1); // after A
assert(utf8ColToUtf16Units(s, 2) == 3); // after 😀 (2 units)
assert(utf8ColToUtf16Units(s, 3) == 4); // after B
assert(utf16UnitsToUtf8Col(s, 0) == 0);
assert(utf16UnitsToUtf8Col(s, 1) == 1); // A
assert(utf16UnitsToUtf8Col(s, 2) == 1); // mid-surrogate -> stays before 😀
assert(utf16UnitsToUtf8Col(s, 3) == 2); // end of 😀
assert(utf16UnitsToUtf8Col(s, 4) == 3); // after B
assert(utf16UnitsToUtf8Col(s, 10) == 3); // clamp
}
// Invalid UTF-8: treat invalid byte as U+FFFD (1 UTF-16 unit), consume 1 byte
{
std::string s;
s.push_back('X');
s.push_back(char(0xFF)); // invalid single byte
s.push_back('Y');
// Columns by codepoints as we decode: 'X', U+FFFD, 'Y'
assert(utf8ColToUtf16Units(s, 0) == 0);
assert(utf8ColToUtf16Units(s, 1) == 1);
assert(utf8ColToUtf16Units(s, 2) == 2);
assert(utf8ColToUtf16Units(s, 3) == 3);
assert(utf16UnitsToUtf8Col(s, 0) == 0);
assert(utf16UnitsToUtf8Col(s, 1) == 1);
assert(utf16UnitsToUtf8Col(s, 2) == 2);
assert(utf16UnitsToUtf8Col(s, 3) == 3);
}
// Position/Range helpers with a simple provider
{
std::string lines[] = {u8"A😀B"};
LineProvider provider = [&](const std::string &, int line) -> std::string_view {
return (line == 0) ? std::string_view(lines[0]) : std::string_view();
};
Position p8{0, 2}; // after 😀 in utf8 columns
Position p16 = toUtf16("file:///x", p8, provider);
assert(p16.line == 0 && p16.character == 3);
Position back = toUtf8("file:///x", p16, provider);
assert(back.line == 0 && back.character == 2);
Range r8{{0, 1}, {0, 3}}; // A|😀|B end
Range r16 = toUtf16("file:///x", r8, provider);
assert(r16.start.character == 1 && r16.end.character == 4);
}
std::puts("test_utfcodec: OK");
return 0;
}