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.
@@ -16,36 +16,36 @@ option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF) option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
if (CMAKE_HOST_UNIX) if (CMAKE_HOST_UNIX)
message(STATUS "Build system is POSIX.") message(STATUS "Build system is POSIX.")
else () else ()
message(STATUS "Build system is NOT POSIX.") message(STATUS "Build system is NOT POSIX.")
endif () endif ()
if (MSVC) if (MSVC)
add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>") add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
else () else ()
add_compile_options( add_compile_options(
"-Wall" "-Wall"
"-Wextra" "-Wextra"
"-Werror" "-Werror"
"$<$<CONFIG:DEBUG>:-g>" "$<$<CONFIG:DEBUG>:-g>"
"$<$<CONFIG:RELEASE>:-O2>") "$<$<CONFIG:RELEASE>:-O2>")
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang") if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
add_compile_options("-stdlib=libc++") add_compile_options("-stdlib=libc++")
else () else ()
# nothing special for gcc at the moment # nothing special for gcc at the moment
endif () endif ()
endif () endif ()
add_compile_definitions(KGE_PLATFORM=${CMAKE_HOST_SYSTEM_NAME}) add_compile_definitions(KGE_PLATFORM=${CMAKE_HOST_SYSTEM_NAME})
add_compile_definitions(KTE_VERSION_STR="v${KTE_VERSION}") add_compile_definitions(KTE_VERSION_STR="v${KTE_VERSION}")
if (KTE_ENABLE_TREESITTER) if (KTE_ENABLE_TREESITTER)
add_compile_definitions(KTE_ENABLE_TREESITTER) add_compile_definitions(KTE_ENABLE_TREESITTER)
endif () endif ()
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}") message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
if (${BUILD_GUI}) if (${BUILD_GUI})
include(cmake/imgui.cmake) include(cmake/imgui.cmake)
endif () endif ()
# NCurses for terminal mode # NCurses for terminal mode
@@ -54,262 +54,368 @@ 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 endif ()
syntax/HighlighterEngine.cc if (KTE_HAVE_GET_WCH_IN_NCURSES OR KTE_HAVE_GET_WCH_IN_CURSES)
syntax/RustHighlighter.cc add_compile_definitions(KTE_HAVE_GET_WCH)
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 ()
set(COMMON_SOURCES set(SYNTAX_SOURCES
GapBuffer.cc syntax/HighlighterEngine.cc
PieceTable.cc syntax/CppHighlighter.cc
Buffer.cc syntax/HighlighterRegistry.cc
Editor.cc syntax/NullHighlighter.cc
Command.cc syntax/JsonHighlighter.cc
HelpText.cc syntax/MarkdownHighlighter.cc
KKeymap.cc syntax/ShellHighlighter.cc
TerminalInputHandler.cc syntax/GoHighlighter.cc
TerminalRenderer.cc syntax/PythonHighlighter.cc
TerminalFrontend.cc syntax/RustHighlighter.cc
TestInputHandler.cc syntax/LispHighlighter.cc
TestRenderer.cc syntax/SqlHighlighter.cc
TestFrontend.cc syntax/ErlangHighlighter.cc
UndoNode.cc syntax/ForthHighlighter.cc
UndoTree.cc
UndoSystem.cc
${SYNTAX_SOURCES}
) )
set(COMMON_SOURCES
GapBuffer.cc
PieceTable.cc
Buffer.cc
Editor.cc
Command.cc
HelpText.cc
KKeymap.cc
TerminalInputHandler.cc
TerminalRenderer.cc
TerminalFrontend.cc
TestInputHandler.cc
TestRenderer.cc
TestFrontend.cc
UndoNode.cc
UndoTree.cc
UndoSystem.cc
lsp/UtfCodec.cc
lsp/BufferChangeTracker.cc
lsp/JsonRpcTransport.cc
lsp/LspProcessClient.cc
lsp/DiagnosticStore.cc
lsp/TerminalDiagnosticDisplay.cc
lsp/LspManager.cc
set(SYNTAX_HEADERS ${SYNTAX_SOURCES}
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/Nord.h
themes/Solarized.h themes/Plan9.h
themes/Plan9.h themes/Solarized.h
themes/Nord.h themes/ThemeHelpers.h
) )
set(COMMON_HEADERS set(SYNTAX_HEADERS
GapBuffer.h syntax/LanguageHighlighter.h
PieceTable.h syntax/HighlighterEngine.h
Buffer.h syntax/CppHighlighter.h
Editor.h syntax/HighlighterRegistry.h
AppendBuffer.h syntax/NullHighlighter.h
Command.h syntax/JsonHighlighter.h
HelpText.h syntax/MarkdownHighlighter.h
KKeymap.h syntax/ShellHighlighter.h
InputHandler.h syntax/GoHighlighter.h
TerminalInputHandler.h syntax/PythonHighlighter.h
Renderer.h syntax/RustHighlighter.h
TerminalRenderer.h syntax/LispHighlighter.h
Frontend.h )
TerminalFrontend.h
TestInputHandler.h
TestRenderer.h
TestFrontend.h
UndoNode.h
UndoTree.h
UndoSystem.h
Highlight.h
${SYNTAX_HEADERS} if (KTE_ENABLE_TREESITTER)
${THEME_HEADERS} list(APPEND SYNTAX_HEADERS
syntax/TreeSitterHighlighter.h)
endif ()
set(COMMON_HEADERS
GapBuffer.h
PieceTable.h
Buffer.h
Editor.h
AppendBuffer.h
Command.h
HelpText.h
KKeymap.h
InputHandler.h
TerminalInputHandler.h
Renderer.h
TerminalRenderer.h
Frontend.h
TerminalFrontend.h
TestInputHandler.h
TestRenderer.h
TestFrontend.h
UndoNode.h
UndoTree.h
UndoSystem.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
${THEME_HEADERS}
${SYNTAX_HEADERS}
) )
# kte (terminal-first) executable # kte (terminal-first) executable
add_executable(kte add_executable(kte
main.cc main.cc
${COMMON_SOURCES} ${COMMON_SOURCES}
${COMMON_HEADERS} ${COMMON_HEADERS}
) )
if (KTE_USE_PIECE_TABLE) if (KTE_USE_PIECE_TABLE)
target_compile_definitions(kte PRIVATE KTE_USE_PIECE_TABLE=1) target_compile_definitions(kte PRIVATE KTE_USE_PIECE_TABLE=1)
endif () endif ()
if (KTE_UNDO_DEBUG) if (KTE_UNDO_DEBUG)
target_compile_definitions(kte PRIVATE KTE_UNDO_DEBUG=1) target_compile_definitions(kte PRIVATE KTE_UNDO_DEBUG=1)
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
set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory") set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory")
set(TREESITTER_LIBRARY "" CACHE FILEPATH "Path to tree-sitter library (.a/.dylib)") set(TREESITTER_LIBRARY "" CACHE FILEPATH "Path to tree-sitter library (.a/.dylib)")
if (TREESITTER_INCLUDE_DIR) if (TREESITTER_INCLUDE_DIR)
target_include_directories(kte PRIVATE ${TREESITTER_INCLUDE_DIR}) target_include_directories(kte PRIVATE ${TREESITTER_INCLUDE_DIR})
endif () endif ()
if (TREESITTER_LIBRARY) if (TREESITTER_LIBRARY)
target_link_libraries(kte ${TREESITTER_LIBRARY}) target_link_libraries(kte ${TREESITTER_LIBRARY})
endif () endif ()
endif () endif ()
install(TARGETS kte install(TARGETS kte
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
) )
# Man pages # Man pages
install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1) install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
if (BUILD_TESTS) if (BUILD_TESTS)
# test_undo executable for testing undo/redo system # test_undo executable for testing undo/redo system
add_executable(test_undo add_executable(test_undo
test_undo.cc test_undo.cc
${COMMON_SOURCES} ${COMMON_SOURCES}
${COMMON_HEADERS} ${COMMON_HEADERS}
) )
if (KTE_USE_PIECE_TABLE) if (KTE_USE_PIECE_TABLE)
target_compile_definitions(test_undo PRIVATE KTE_USE_PIECE_TABLE=1) target_compile_definitions(test_undo PRIVATE KTE_USE_PIECE_TABLE=1)
endif () endif ()
if (KTE_UNDO_DEBUG) if (KTE_UNDO_DEBUG)
target_compile_definitions(test_undo PRIVATE KTE_UNDO_DEBUG=1) target_compile_definitions(test_undo PRIVATE KTE_UNDO_DEBUG=1)
endif () endif ()
target_link_libraries(test_undo ${CURSES_LIBRARIES}) target_link_libraries(test_undo ${CURSES_LIBRARIES})
if (KTE_ENABLE_TREESITTER) # Ensure vendored headers (e.g., ext/json.h) are on the include path for tests as well
if (TREESITTER_INCLUDE_DIR) target_include_directories(test_undo PRIVATE ${CMAKE_SOURCE_DIR}/ext)
target_include_directories(test_undo PRIVATE ${TREESITTER_INCLUDE_DIR}) if (KTE_ENABLE_TREESITTER)
endif () if (TREESITTER_INCLUDE_DIR)
if (TREESITTER_LIBRARY) target_include_directories(test_undo PRIVATE ${TREESITTER_INCLUDE_DIR})
target_link_libraries(test_undo ${TREESITTER_LIBRARY}) endif ()
endif () if (TREESITTER_LIBRARY)
endif () target_link_libraries(test_undo ${TREESITTER_LIBRARY})
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(); target_sources(kte PRIVATE
# ImGuiIO& io = ImGui::GetIO(); Font.h
GUIConfig.cc
GUIConfig.h
GUIRenderer.cc
GUIRenderer.h
GUIInputHandler.cc
GUIInputHandler.h
GUIFrontend.cc
GUIFrontend.h)
target_compile_definitions(kte PRIVATE KTE_BUILD_GUI=1)
target_link_libraries(kte imgui)
# // Set custom ini filename path to ~/.config/kte/imgui.ini # kge (GUI-first) executable
# if (const char* home = std::getenv("HOME")) { add_executable(kge
# static std::string ini_path = std::string(home) + "/.config/kte/imgui.ini"; main.cc
# io.IniFilename = ini_path.c_str(); ${COMMON_SOURCES}
# } ${COMMON_HEADERS}
GUIConfig.cc
GUIConfig.h
GUIRenderer.cc
GUIRenderer.h
GUIInputHandler.cc
GUIInputHandler.h
GUIFrontend.cc
GUIFrontend.h)
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
if (KTE_UNDO_DEBUG)
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
endif ()
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
target_include_directories(kge PRIVATE ${CMAKE_SOURCE_DIR}/ext)
# io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls # On macOS, build kge as a proper .app bundle
# io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls if (APPLE)
target_sources(kte PRIVATE # Define the icon file
Font.h set(MACOSX_BUNDLE_ICON_FILE kge.icns)
GUIConfig.cc set(kge_ICON "${CMAKE_CURRENT_SOURCE_DIR}/${MACOSX_BUNDLE_ICON_FILE}")
GUIConfig.h
GUIRenderer.cc
GUIRenderer.h
GUIInputHandler.cc
GUIInputHandler.h
GUIFrontend.cc
GUIFrontend.h)
target_compile_definitions(kte PRIVATE KTE_BUILD_GUI=1)
target_link_libraries(kte imgui)
# kge (GUI-first) executable # Add icon to the target sources and mark it as a resource
add_executable(kge target_sources(kge PRIVATE ${kge_ICON})
main.cc set_source_files_properties(${kge_ICON} PROPERTIES
${COMMON_SOURCES} MACOSX_PACKAGE_LOCATION Resources)
${COMMON_HEADERS}
GUIConfig.cc
GUIConfig.h
GUIRenderer.cc
GUIRenderer.h
GUIInputHandler.cc
GUIInputHandler.h
GUIFrontend.cc
GUIFrontend.h)
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
if (KTE_UNDO_DEBUG)
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
endif ()
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
# On macOS, build kge as a proper .app bundle # Configure Info.plist with version and identifiers
if (APPLE) set(KGE_BUNDLE_ID "dev.wntrmute.kge")
# Define the icon file configure_file(
set(MACOSX_BUNDLE_ICON_FILE kge.icns) ${CMAKE_CURRENT_LIST_DIR}/cmake/Info.plist.in
set(kge_ICON "${CMAKE_CURRENT_SOURCE_DIR}/${MACOSX_BUNDLE_ICON_FILE}") ${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist
@ONLY)
# Add icon to the target sources and mark it as a resource set_target_properties(kge PROPERTIES
target_sources(kge PRIVATE ${kge_ICON}) MACOSX_BUNDLE TRUE
set_source_files_properties(${kge_ICON} PROPERTIES MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID}
MACOSX_PACKAGE_LOCATION Resources) MACOSX_BUNDLE_BUNDLE_NAME "kge"
MACOSX_BUNDLE_ICON_FILE ${MACOSX_BUNDLE_ICON_FILE}
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist")
# Configure Info.plist with version and identifiers add_dependencies(kge kte)
set(KGE_BUNDLE_ID "dev.wntrmute.kge") add_custom_command(TARGET kge POST_BUILD
configure_file( COMMAND ${CMAKE_COMMAND} -E copy
${CMAKE_CURRENT_LIST_DIR}/cmake/Info.plist.in $<TARGET_FILE:kte>
${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist $<TARGET_FILE_DIR:kge>/kte
@ONLY) COMMENT "Copying kte binary into kge.app bundle")
set_target_properties(kge PROPERTIES install(TARGETS kge
MACOSX_BUNDLE TRUE BUNDLE DESTINATION .
MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID} )
MACOSX_BUNDLE_BUNDLE_NAME "kge"
MACOSX_BUNDLE_ICON_FILE ${MACOSX_BUNDLE_ICON_FILE}
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist")
add_dependencies(kge kte) install(TARGETS kte
add_custom_command(TARGET kge POST_BUILD RUNTIME DESTINATION kge.app/Contents/MacOS
COMMAND ${CMAKE_COMMAND} -E copy )
$<TARGET_FILE:kte> else ()
$<TARGET_FILE_DIR:kge>/kte install(TARGETS kge
COMMENT "Copying kte binary into kge.app bundle") RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
install(TARGETS kge endif ()
BUNDLE DESTINATION . # Install kge man page only when GUI is built
) install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
install(TARGETS kte
RUNTIME DESTINATION kge.app/Contents/MacOS
)
else ()
install(TARGETS kge
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
endif ()
# Install kge man page only when GUI is built
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
endif () endif ()

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;
} }
@@ -282,4 +302,4 @@ Editor::Reset()
quit_confirm_pending_ = false; quit_confirm_pending_ = false;
buffers_.clear(); buffers_.clear();
curbuf_ = 0; curbuf_ = 0;
} }

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
@@ -337,4 +318,4 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, float size_px)
} }
// No runtime font reload or system font resolution in this simplified build. // No runtime font reload or system font resolution in this simplified build.

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;
@@ -598,4 +597,4 @@ GUIInputHandler::Poll(MappedInput &out)
out = q_.front(); out = q_.front();
q_.pop(); q_.pop();
return true; return true;
} }

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 float prev_scroll_y = -1.0f; // previous frame's ImGui scroll Y in pixels static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs
static float prev_scroll_x = -1.0f; // previous frame's ImGui scroll X in pixels 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_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)
@@ -243,11 +256,11 @@ GUIRenderer::Draw(Editor &ed)
const std::size_t coloffs_now = buf->Coloffs(); const std::size_t coloffs_now = buf->Coloffs();
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,9 +320,9 @@ 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;
ImU32 col = is_current ImU32 col = is_current
@@ -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,9 +421,9 @@ 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 ||
kind == Editor::PromptKind::Chdir) { kind == Editor::PromptKind::Chdir) {
const char *home_c = std::getenv("HOME"); const char *home_c = std::getenv("HOME");
@@ -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),
@@ -757,4 +770,4 @@ GUIRenderer::Draw(Editor &ed)
ed.SetFilePickerVisible(false); ed.SetFilePickerVisible(false);
} }
} }
} }

View File

@@ -412,4 +412,4 @@ SyntaxInk(const TokenKind k)
return def; return def;
} }
} }
} // namespace kte } // namespace kte

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;
} }
@@ -100,4 +138,4 @@ TerminalFrontend::Shutdown()
have_orig_tio_ = false; have_orig_tio_ = false;
} }
endwin(); endwin();
} }

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,77 @@ 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();
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;
}
#else
// Wide-character input not available in this curses; fall back to byte-wise getch
(void) utf8_enabled_;
int ch = getch(); int ch = getch();
if (ch == ERR) { if (ch == ERR) {
return false; // no input return false; // no input
@@ -292,6 +395,7 @@ TerminalInputHandler::decode_(MappedInput &out)
out); out);
if (!consumed) if (!consumed)
return false; 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
++render_col; if (render_col + 1 <= coloffs) {
++src_i; ++render_col;
continue; ++src_i;
continue;
}
wcw = 1;
advance_bytes = 1;
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;
} }
ch = static_cast<char>(c);
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

49
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,11 +131,14 @@ 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;
#else #else
use_gui = false; use_gui = false;
#endif #endif
} }
#endif #endif
@@ -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;
@@ -212,4 +249,4 @@ main(int argc, const char *argv[])
fe->Shutdown(); fe->Shutdown();
return 0; return 0;
} }

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;
}

View File

@@ -108,4 +108,4 @@ ApplyNordImGuiTheme()
colors[ImGuiCol_PlotLinesHovered] = nord9; colors[ImGuiCol_PlotLinesHovered] = nord9;
colors[ImGuiCol_PlotHistogram] = nord13; colors[ImGuiCol_PlotHistogram] = nord13;
colors[ImGuiCol_PlotHistogramHovered] = nord12; colors[ImGuiCol_PlotHistogramHovered] = nord12;
} }