From 44f5bf677282308b959411c416da2b5b08db2062 Mon Sep 17 00:00:00 2001 From: Robin Haberkorn Date: Sun, 2 Aug 2026 15:24:45 +0200 Subject: support lookups via language servers (LSP) * The main interface is the `FT` command. `FT` was an undocumented Video TECO command for etags/ctags lookups. I don't want to exactly copy its interface, though. * `FT` allows looking up symbol names, definitions and references. * ctags will be supported via ctags-lsp. LSP support is more powerful though and works without regenerating TAGS files all the time. The LSP will also allow you to customize auto-completions using SciTECO itself (i.e. by writing a language server in SciTECO). * For multiple results, `FT$` can be used to cycle through results - this should mimic repeated `S$` or `N$`. * `:FT...$` does a fuzzy search. IMHO it's not important to return a status integer instead. `FT` will only really be used in interactive mode. * Document synchronization is supported via hooks from ring.c and via Scintilla notifications. * Currently, the LSP communication is based on blocking GIOChannels. This means that a misbehaving hanging server could "lock up" the entire editor (FIXME). Only on ncurses you can always kill the subprocess by pressing CTRL+C. We need helper functions in spawn.c to read and write with interruptions. * The textDocument/didChange notification transmits not only all edits, but all files' contents as well during initial synchronization. Therefore it is optimized to write and JSON-escape data without copying them around in memory and without destroying the buffer gap. * Use $SCITECO_LSP to configure the language server. You can also use `tee` to capture stdin and stdout. Perhaps $SCITECO_LSP should be saved in .teco_session, so you can change it between projects? * $SCITECO_LSP_ROOT is used to point to the project's root directory. session.tes will set it up automatically. --- .gitignore | 4 + doc/sciteco.1.in | 10 + doc/sciteco.7.template | 5 + lib/session.tes | Bin 3180 -> 3299 bytes src/Makefile.am | 4 +- src/cmdline.c | 57 ++ src/core-commands.c | 3 + src/help.c | 5 + src/lsp.c | 1380 ++++++++++++++++++++++++++++++++++++++++++++++++ src/lsp.h | 35 ++ src/ring.c | 41 +- src/ring.h | 8 + src/spawn.c | 3 + src/view.c | 64 ++- src/view.h | 2 +- 15 files changed, 1604 insertions(+), 17 deletions(-) create mode 100644 src/lsp.c create mode 100644 src/lsp.h diff --git a/.gitignore b/.gitignore index 823c690..6447f82 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ testsuite.dir /doc/Doxyfile /doc/doxygen + +# Created e.g. by `bear -- gmake` for clangd. +# You may also have to create a symbolic link, so clangd finds it. +compile_commands.json diff --git a/doc/sciteco.1.in b/doc/sciteco.1.in index 9b4b112..858bf6d 100644 --- a/doc/sciteco.1.in +++ b/doc/sciteco.1.in @@ -391,6 +391,16 @@ for an example of how to integrate the X11 clipboard via Integrating with Wayland and the Mac OS clipboards is of course also possible. . .LP +.SCITECO_TOPIC "$SCITECO_LSP" "$SCITECO_LSP_ROOT" +The \fBSCITECO_LSP\fP environment variable must be set to a language +server command line if you would like to use the \fBFT\fP command for looking up +symbols, definitions or references. +The language server spawning works with UNIX98 shell emulation as well (bit 8 +in \fBED\fP flags), but will otherwise use the shell from \fBSHELL\fP or \fBCOMSPEC\fP. +\fBSCITECO_LSP_ROOT\fP can be set to the root directory of the language server's +workspace (defaults to current directory at the time of spawning). +. +.LP .SCITECO_TOPIC "$GTK_CSD" \# NOTE: Contrary to the GTK documentation, this is _not_ supported \# natively yet. We have to implement it ourselves. diff --git a/doc/sciteco.7.template b/doc/sciteco.7.template index b62a371..14aee77 100644 --- a/doc/sciteco.7.template +++ b/doc/sciteco.7.template @@ -2591,6 +2591,11 @@ Suitable terminal fonts for icon support in Curses (see \fBED\fP flags): Nerd Fonts .UE .TP +The language server protocol (LSP): +.UR https://microsoft.github.io/language-server-protocol/ +LSP specification and implementations +.UE +.TP Gtk+ 3 documentation, containg details about its CSS support and syntax: .UR https://docs.gtk.org/gtk3/css-overview.html Overview of CSS in GTK diff --git a/lib/session.tes b/lib/session.tes index 845db25..6f3c7cc 100644 Binary files a/lib/session.tes and b/lib/session.tes differ diff --git a/src/Makefile.am b/src/Makefile.am index 8ac58c7..fa6c0e4 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -13,7 +13,8 @@ include $(top_srcdir)/contrib/scintilla.am # FIXME: Common flags should be in configure.ac AM_CFLAGS = -std=gnu11 -Wall -Wno-initializer-overrides -Wno-unused-value -AM_CPPFLAGS += -I$(top_srcdir)/contrib/rb3ptr -I$(top_srcdir)/contrib/terex +AM_CPPFLAGS += -I$(top_srcdir)/contrib/rb3ptr -I$(top_srcdir)/contrib/terex \ + -I$(top_srcdir)/contrib/sj.h AM_LDFLAGS = if STATIC_EXECUTABLES @@ -54,6 +55,7 @@ libsciteco_base_la_SOURCES = main.c sciteco.h list.h \ help.c help.h \ rb3str.c rb3str.h \ symbols.c symbols.h \ + lsp.c lsp.h \ lexer.c lexer.h \ view.c view.h \ interface.c interface.h diff --git a/src/cmdline.c b/src/cmdline.c index 7fe044c..572b159 100644 --- a/src/cmdline.c +++ b/src/cmdline.c @@ -53,6 +53,7 @@ #include "error.h" #include "qreg.h" #include "glob.h" +#include "lsp.h" #include "cmdline.h" #if defined(HAVE_MALLOC_TRIM) && !defined(HAVE_DECL_MALLOC_TRIM) @@ -1506,6 +1507,62 @@ teco_state_help_insert_completion(teco_machine_main_t *ctx, teco_string_t str, G return teco_cmdline_insert(str_escaped.data, str_escaped.len, error); } +gboolean +teco_state_lsp_lookup_process_edit_cmd(teco_machine_main_t *ctx, teco_machine_t *parent_ctx, + gunichar key, GError **error) +{ + teco_machine_stringbuilding_t *stringbuilding_ctx = &ctx->expectstring.machine; + teco_state_t *stringbuilding_current = stringbuilding_ctx->parent.current; + + /* + * NOTE: We don't just define teco_state_stringbuilding_start_process_edit_cmd(), + * as it would be hard to subclass/overwrite for different main machine states. + */ + if (!stringbuilding_current->is_start) + return stringbuilding_current->process_edit_cmd_cb(&stringbuilding_ctx->parent, &ctx->parent, key, error); + + switch (key) { + case '\t': { /* autocomplete LSP symbol */ + if (teco_cmdline.modifier_enabled) + break; + + if (teco_interface_popup_is_shown()) { + /* cycle through popup pages */ + teco_interface_popup_scroll(); + return TRUE; + } + + if (teco_string_contains(ctx->expectstring.string, '\0')) + /* LSP symbol must not contain null-byte */ + return TRUE; + + g_auto(teco_string_t) new_chars, new_chars_escaped; + gboolean unambiguous = teco_lsp_symbol_auto_complete(ctx->expectstring.string.data, &new_chars); + teco_machine_stringbuilding_escape(stringbuilding_ctx, new_chars.data, new_chars.len, &new_chars_escaped); + if (unambiguous && ctx->expectstring.nesting == 1) + teco_string_append_wc(&new_chars_escaped, + ctx->expectstring.machine.escape_char == '{' ? '}' : ctx->expectstring.machine.escape_char); + + return new_chars_escaped.len ? teco_cmdline_insert(new_chars_escaped.data, new_chars_escaped.len, error) : TRUE; + } + } + + return stringbuilding_current->process_edit_cmd_cb(&stringbuilding_ctx->parent, &ctx->parent, key, error); +} + +gboolean +teco_state_lsp_lookup_insert_completion(teco_machine_main_t *ctx, teco_string_t str, GError **error) +{ + teco_machine_stringbuilding_t *stringbuilding_ctx = &ctx->expectstring.machine; + + g_auto(teco_string_t) str_escaped; + teco_machine_stringbuilding_escape(stringbuilding_ctx, str.data, str.len, &str_escaped); + if (ctx->expectstring.nesting == 1) + teco_string_append_wc(&str_escaped, + ctx->expectstring.machine.escape_char == '{' ? '}' : ctx->expectstring.machine.escape_char); + return teco_cmdline_insert(str_escaped.data, str_escaped.len, error); +} + /* * Command states */ diff --git a/src/core-commands.c b/src/core-commands.c index 6c4ab58..db3ae9d 100644 --- a/src/core-commands.c +++ b/src/core-commands.c @@ -39,6 +39,7 @@ #include "spawn.h" #include "glob.h" #include "help.h" +#include "lsp.h" #include "cmdline.h" #include "error.h" #include "memory.h" @@ -1068,6 +1069,8 @@ teco_state_fcommand_input(teco_machine_main_t *ctx, gunichar chr, GError **error .modifier_at = TRUE, .modifier_colon = 2}, ['N'] = {&teco_state_replace_default_all, .modifier_at = TRUE, .modifier_colon = 1}, + ['T'] = {&teco_state_lsp_lookup, + .modifier_at = TRUE, .modifier_colon = 1}, ['G'] = {&teco_state_changedir, .modifier_at = TRUE}, diff --git a/src/help.c b/src/help.c index c64538e..b7b5fd0 100644 --- a/src/help.c +++ b/src/help.c @@ -282,6 +282,11 @@ teco_state_help_done(teco_machine_main_t *ctx, teco_string_t str, GError **error "Help topic must not contain null-byte"); return NULL; } + + /* + * FIXME: Perhaps ?$ could toggle through all topics with the + * same name. + */ const gchar *topic_name = str.data ? : ""; teco_help_topic_t *topic = teco_help_find(topic_name); if (!topic) { diff --git a/src/lsp.c b/src/lsp.c new file mode 100644 index 0000000..0b33885 --- /dev/null +++ b/src/lsp.c @@ -0,0 +1,1380 @@ +/* + * Copyright (C) 2012-2026 Robin Haberkorn + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include +#include + +#include + +#ifdef HAVE_WINDOWS_H +#define WIN32_LEAN_AND_MEAN +#include +#endif + +#define SJ_IMPL +#include + +#include "sciteco.h" +#include "string-utils.h" +#include "interface.h" +#include "expressions.h" +#include "error.h" +#include "view.h" +#include "undo.h" +#include "parser.h" +#include "core-commands.h" +#include "spawn.h" +#include "ring.h" +#include "list.h" +#include "qreg.h" +#include "lsp.h" + +static gboolean teco_lsp_shutdown(GError **error); + +/* + * FIXME: Should perhaps be an array. + * But then we'd have to iterate results array once + * to get the number of results. + */ +typedef struct { + teco_stailq_entry_t entry; + + guint line, column; + gchar filename[]; +} teco_lsp_result_t; + +static struct { + /** Pid of the language server */ + GPid pid; + GIOChannel *stdin_chan, *stdout_chan; + + teco_stailq_head_t list; + teco_lsp_result_t *current; +} teco_lsp = { + .pid = -1, + .list = TECO_STAILQ_HEAD_INITIALIZER(&teco_lsp.list) +}; + +/** + * Compare JSON value to string. + * If value is a SJ_STRING, it is \b not unescaped first, + * so this makes sense for plain string keys only. + */ +static inline gboolean +teco_json_eq(sj_Value val, const char *str) +{ + return !strncmp(str, val.start, val.end - val.start); +} + +static gsize +teco_json_escape_len(const gchar *str, gsize len) +{ + gsize ret = 0; + + while (len > 0) { + /* + * NOTE: Perhaps it would be more efficient to just escape + * everything with \u00XX. + * This overallocates in teco_json_escape(), but avoids + * redundancies with teco_lsp_send_escaped(). + */ + if (*str && strchr("\"\\\b\f\n\r\t", *str)) + ret += 2; + else if (TECO_IS_CTL(*str)) + ret += 6; + else + ret++; + str++; + len--; + } + + return ret; +} + +static gchar * +teco_json_escape(const gchar *str, gsize len) +{ + gsize escaped_len = teco_json_escape_len(str, len); + gchar *escaped = g_malloc(escaped_len+1); + gchar *p = escaped; + + while (len > 0) { + switch (*str) { + case '"': + case '\\': + *p++ = '\\'; + *p++ = *str; + break; + case '\b': + *p++ = '\\'; + *p++ = 'b'; + break; + case '\f': + *p++ = '\\'; + *p++ = 'f'; + break; + case '\n': + *p++ = '\\'; + *p++ = 'n'; + break; + case '\r': + *p++ = '\\'; + *p++ = 'r'; + break; + case '\t': + *p++ = '\\'; + *p++ = 't'; + break; + default: + if (TECO_IS_CTL(*str)) + p += sprintf(p, "\\u%04X", *str); + else + *p++ = *str; + } + str++; + len--; + } + *p = '\0'; + + return escaped; +} + +static gchar * +teco_json_unescape(sj_Value val) +{ + g_assert(val.type == SJ_STRING); + + gchar *str = g_malloc(val.end - val.start + 1); + gchar *p = str; + + while (val.start < val.end) { + if (*val.start == '\'' && *++val.start == 'u') { + val.start++; + gchar buf[4+1]; + gsize len = MIN(val.end-val.start, 4); + strncpy(buf, val.start, len); + buf[len] = '\0'; + // FIXME: validate? + gunichar c = strtoul(buf, NULL, 16); + /* there will be 6 bytes reserved in str (\uXXXX) */ + p += g_unichar_to_utf8(c, p); + } else { + *p++ = *val.start++; + } + } + *p = '\0'; + + return str; +} + +static teco_lsp_result_t * +teco_lsp_result_new(const gchar *filename, guint line, guint column) +{ + teco_lsp_result_t *result = g_malloc(sizeof(teco_lsp_result_t) + strlen(filename) + 1); + strcpy(result->filename, filename); + result->line = line; + result->column = column; + return result; +} + +static inline void +teco_lsp_result_free(teco_lsp_result_t *result) +{ + g_free(result); +} + +static inline void +teco_lsp_list_clear(teco_stailq_head_t *list) +{ + teco_stailq_entry_t *entry; + while ((entry = teco_stailq_remove_head(list))) + teco_lsp_result_free((teco_lsp_result_t *)entry); +} + +/* + * We better always shut down the LSP, + * even in optimized builds. + */ +static void __attribute__((destructor)) +teco_lsp_cleanup(void) +{ + teco_lsp_shutdown(NULL); + + if (teco_lsp.stdin_chan) + g_io_channel_unref(teco_lsp.stdin_chan); + teco_lsp.stdin_chan = NULL; + if (teco_lsp.stdout_chan) + g_io_channel_unref(teco_lsp.stdout_chan); + teco_lsp.stdout_chan = NULL; + + if (teco_lsp.pid >= 0) { + /* + * Sometimes, clangd will refuse to exit gracefully + * even after the shutdown procedure, so we kill it + * explicitly here. + * The process should be reaped automatically. + */ +#ifdef G_OS_UNIX + kill(teco_lsp.pid, SIGKILL); +#elif defined(G_OS_WIN32) + TerminateProcess(teco_lsp.pid, 1); +#endif + g_spawn_close_pid(teco_lsp.pid); + teco_lsp.pid = -1; + } + + teco_lsp_list_clear(&teco_lsp.list); +} + +static void +teco_undo_restore_lsp_list_action(teco_stailq_head_t *ctx, gboolean run) +{ + if (run) + teco_lsp.list = *ctx; + else + teco_lsp_list_clear(ctx); +} + +/** + * Restore teco_lsp_list on rubout. + * Ownership is passed to the undo token. + * + * @fixme Replace with TECO_DEFINE_UNDO_OBJECT_OWN()? + */ +static void +teco_undo_restore_lsp_list(void) +{ + teco_stailq_head_t *ctx = teco_undo_push_size((teco_undo_action_t)teco_undo_restore_lsp_list_action, + sizeof(teco_lsp.list)); + if (ctx) + *ctx = teco_lsp.list; + else + teco_lsp_list_clear(&teco_lsp.list); +} + +/** + * Send preformatted request to server. + * + * @todo What if the LSP hangs? + * Use non-blocking I/O and allow interruptions. + */ +static gboolean +teco_lsp_send(const gchar *req, GError **error) +{ + gsize req_len = strlen(req); + + gchar header[256]; + gsize header_len = g_snprintf(header, sizeof(header), "Content-Length: %zu\r\n\r\n", req_len); + + if (g_io_channel_write_chars(teco_lsp.stdin_chan, header, header_len, + NULL, error) == G_IO_STATUS_ERROR || + g_io_channel_write_chars(teco_lsp.stdin_chan, req, req_len, + NULL, error) == G_IO_STATUS_ERROR || + g_io_channel_flush(teco_lsp.stdin_chan, error) == G_IO_STATUS_ERROR) + return FALSE; + + return TRUE; +} + +/** + * Escape and escape string to server + * + * Call teco_json_escape_len() to find out how much bytes this will write. + * + * @see teco_json_escape + */ +static gboolean +teco_lsp_send_escaped(const gchar *str, gsize len, GError **error) +{ + while (len > 0) { + gchar buf[6+1] = {'\\', 0, 0}; + + switch (*str) { + case '"': + case '\\': + buf[1] = *str; + break; + case '\b': + buf[1] = 'b'; + break; + case '\f': + buf[1] = 'f'; + break; + case '\n': + buf[1] = 'n'; + break; + case '\r': + buf[1] = 'r'; + break; + case '\t': + buf[1] = 't'; + break; + default: + if (TECO_IS_CTL(*str)) + g_snprintf(buf+1, sizeof(buf)-1, "u%04X", *str); + else + buf[0] = *str; + } + + if (g_io_channel_write_chars(teco_lsp.stdin_chan, buf, -1, + NULL, error) == G_IO_STATUS_ERROR) + return FALSE; + + str++; + len--; + } + + return TRUE; +} + +/** + * Receive response from server. + * All notifications are ignored. + * + * @todo What if the LSP hangs? + * Use non-blocking I/O and allow interruptions. + * Currently, only on UNIX we can interrupt since SIGINT + * is passed down to the child processes. + */ +static gboolean +teco_lsp_recv(teco_string_t *resp, GError **error) +{ + for (;;) { + memset(resp, 0, sizeof(*resp)); + + for (;;) { + g_autofree gchar *line = NULL; + gsize len; + if (g_io_channel_read_line(teco_lsp.stdout_chan, &line, + NULL, &len, error) == G_IO_STATUS_ERROR) + return FALSE; + if (len == 0) + break; + sscanf(line, "Content-Length: %zu", &resp->len); + } + + if (!resp->len) { + g_set_error_literal(error, TECO_ERROR, TECO_ERROR_FAILED, + "Missing Content-Length field."); + return FALSE; + } + + resp->data = g_malloc(resp->len+1); + gsize read_len; + + if (g_io_channel_read_chars(teco_lsp.stdout_chan, resp->data, + resp->len, &read_len, error) == G_IO_STATUS_ERROR) + return FALSE; + if (read_len != resp->len) { + /* can only mean end of LSP's stdout */ + g_free(resp->data); + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Unexpected end of response (%zu bytes instead of %zu)", + read_len, resp->len); + return FALSE; + } + + resp->data[resp->len] = '\0'; + + /* + * Check for notifications. + */ + sj_Reader reader = sj_reader(resp->data, resp->len); + sj_Value obj = sj_read(&reader); + if (obj.type != SJ_OBJECT) { + g_free(resp->data); + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Expected object in response (at byte %zu)", + obj.start - reader.data); + return FALSE; + } + + sj_Value key, val; + while (sj_iter_object(&reader, obj, &key, &val)) + if (teco_json_eq(key, "id")) + /* it's a proper response */ + return TRUE; + + /* it's a notification - ignore for the time being */ + g_free(resp->data); + } + + return TRUE; +} + +static gboolean +teco_lsp_launch(GError **error) +{ + /* + * NOTE: With G_SPAWN_LEAVE_DESCRIPTORS_OPEN and without G_SPAWN_SEARCH_PATH_FROM_ENVP, + * Glib offers an "optimized codepath" on UNIX. + * G_SPAWN_SEARCH_PATH_FROM_ENVP does not appear to work on Windows, anyway. + * On the other hand, this means you cannot overwrite $PATH via Q-Registers. + */ + static const GSpawnFlags flags = G_SPAWN_SEARCH_PATH | +#ifdef G_OS_UNIX + G_SPAWN_LEAVE_DESCRIPTORS_OPEN | +#endif + G_SPAWN_STDERR_TO_DEV_NULL; + + static const gchar lsp_reg_name[] = "$SCITECO_LSP"; + teco_qreg_t *reg = teco_qreg_table_find(&teco_qreg_table_globals, + lsp_reg_name, strlen(lsp_reg_name)); + if (!reg) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Q-Register %s is undefined.", lsp_reg_name); + return FALSE; + } + + g_auto(teco_string_t) command = {NULL, 0}; + if (!reg->vtable->get_string(reg, &command.data, &command.len, NULL, error)) + return FALSE; + if (teco_string_contains(command, '\0')) { + teco_error_qregcontainsnull_set(error, lsp_reg_name, strlen(lsp_reg_name), FALSE); + return FALSE; + } + + /* + * FIXME: This allows POSIX shell emulation. + * But how to do that only for launching the LSP? + */ + g_auto(GStrv) argv = teco_parse_shell_command_line(command.data, error); + if (!argv) + return FALSE; + + g_auto(GStrv) envp = teco_qreg_table_get_environ(&teco_qreg_table_globals, error); + if (!envp) + return FALSE; + + gint stdin_fd, stdout_fd; + + if (!g_spawn_async_with_pipes(NULL, argv, envp, flags, NULL, NULL, &teco_lsp.pid, + &stdin_fd, &stdout_fd, NULL, error)) + return FALSE; + +#ifdef G_OS_WIN32 + teco_lsp.stdin_chan = g_io_channel_win32_new_fd(stdin_fd); + teco_lsp.stdout_chan = g_io_channel_win32_new_fd(stdout_fd); +#else + /* the UNIX constructors should work everywhere else */ + teco_lsp.stdin_chan = g_io_channel_unix_new(stdin_fd); + teco_lsp.stdout_chan = g_io_channel_unix_new(stdout_fd); +#endif + + //g_io_channel_set_flags(teco_lsp.stdin_chan, G_IO_FLAG_NONBLOCK, NULL); + g_io_channel_set_encoding(teco_lsp.stdin_chan, NULL, NULL); + g_io_channel_set_buffered(teco_lsp.stdin_chan, TRUE); + //g_io_channel_set_flags(teco_lsp.stdout_chan, G_IO_FLAG_NONBLOCK, NULL); + g_io_channel_set_encoding(teco_lsp.stdout_chan, NULL, NULL); + g_io_channel_set_buffered(teco_lsp.stdout_chan, TRUE); + + g_auto(teco_string_t) root = {NULL, 0}; + + static const gchar root_reg_name[] = "$SCITECO_LSP_ROOT"; + reg = teco_qreg_table_find(&teco_qreg_table_globals, + root_reg_name, strlen(root_reg_name)); + if (reg) { + if (!reg->vtable->get_string(reg, &root.data, &root.len, NULL, error)) + return FALSE; + if (teco_string_contains(root, '\0')) { + teco_error_qregcontainsnull_set(error, root_reg_name, strlen(root_reg_name), FALSE); + return FALSE; + } + } else { + root.data = g_get_current_dir(); + root.len = strlen(root.data); + } + + g_autofree gchar *root_uri = g_filename_to_uri(root.data, NULL, error); + if (!root_uri) + return FALSE; + g_autofree gchar *root_uri_escaped = teco_json_escape(root_uri, strlen(root_uri)); + g_autofree gchar *req = g_strdup_printf("{" + "\"jsonrpc\":\"2.0\"," + "\"id\":1," + "\"method\":\"initialize\"," + "\"params\":{" + /* + * FIXME: Is there any advantage in passing the real pid? + */ + "\"processId\":null," + "\"clientInfo\":{" + "\"name\":\"%s\"," + "\"version\":\"%s\"" + "}," + "\"capabilities\":{" + "\"general\":{" + "\"positionEncodings\":[\"utf-8\"]" + "}" + "}," + "\"rootUri\":\"%s\"" + "}" + "}", PACKAGE_NAME, PACKAGE_VERSION, root_uri_escaped); + if (!teco_lsp_send(req, error)) + return FALSE; + + g_auto(teco_string_t) resp = {NULL, 0}; + if (!teco_lsp_recv(&resp, error)) + return FALSE; + + sj_Reader reader = sj_reader(resp.data, resp.len); + sj_Value obj = sj_read(&reader); + g_assert(obj.type == SJ_OBJECT); + + sj_Value key, val; + while (sj_iter_object(&reader, obj, &key, &val)) { + if (teco_json_eq(key, "method") && !teco_json_eq(val, "initialized")) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "\"initialized\" method not found"); + return FALSE; + } + } + + static const gchar initialized[] = "{" + "\"jsonrpc\":\"2.0\"," + "\"method\":\"initialized\"," + "\"params\":{}" + "}"; + if (!teco_lsp_send(initialized, error)) + return FALSE; + + /* + * Sends textDocument/didOpen for all buffers. + * This could also be moved here if we'd export teco_ring_head. + */ + return teco_ring_sync_lsp(error); +} + +gboolean +teco_lsp_didopen(teco_buffer_t *buffer, GError **error) +{ + if (teco_lsp.pid < 0) + /* do nothing until the user queries something */ + return TRUE; + + /* + * FIXME: Can we somehow handle the unnamed buffer? + * clangd doesn't accept `untitled:` URI schemes. + */ + if (!buffer->filename) + return TRUE; + + /* + * FIXME: The Lexilla lexer names aren't always identical to the + * LSP lanuageIds. + * Without lexing, this will just pass the empty string. + */ + gsize language_len = teco_view_ssm(buffer->view, SCI_GETLEXERLANGUAGE, 0, 0); + g_autofree gchar *language = g_malloc(language_len+1); + teco_view_ssm(buffer->view, SCI_GETLEXERLANGUAGE, 0, (sptr_t)language); + language[language_len] = '\0'; + g_autofree gchar *language_escaped = teco_json_escape(language, language_len); + + g_autofree gchar *uri = g_filename_to_uri(buffer->filename, NULL, error); + if (!uri) + return FALSE; + g_autofree gchar *uri_escaped = teco_json_escape(uri, strlen(uri)); + g_autofree gchar *req = g_strdup_printf("{" + "\"jsonrpc\":\"2.0\"," + "\"method\":\"textDocument/didOpen\"," + "\"params\":{" + "\"textDocument\":{" + "\"languageId\":\"%s\"," + "\"version\":%u," + "\"uri\":\"%s\"," + /* all text will be added with textDocument/didChange */ + "\"text\":\"\"" + "}" + "}" + "}", language_escaped, buffer->version, uri_escaped); + return teco_lsp_send(req, error); +} + +gboolean +teco_lsp_didchange_insert(teco_buffer_t *buffer, gsize pos, gsize len, + const gchar *text, GError **error) +{ + if (teco_lsp.pid < 0) + /* do nothing until the user queries something */ + return TRUE; + + /* + * FIXME: Can we somehow handle the unnamed buffer? + * clangd doesn't accept `untitled:` URI schemes. + */ + if (!buffer->filename) + return TRUE; + + guint line = teco_view_ssm(buffer->view, SCI_LINEFROMPOSITION, pos, 0); + guint column = pos - teco_view_ssm(buffer->view, SCI_POSITIONFROMLINE, line, 0); + + g_autofree gchar *uri = g_filename_to_uri(buffer->filename, NULL, error); + if (!uri) + return FALSE; + g_autofree gchar *uri_escaped = teco_json_escape(uri, strlen(uri)); + g_autofree gchar *req_prefix = g_strdup_printf("{" + "\"jsonrpc\":\"2.0\"," + "\"method\":\"textDocument/didChange\"," + "\"params\":{" + "\"textDocument\":{" + "\"version\":%u," + "\"uri\":\"%s\"" + "}," + "\"contentChanges\":[{" + "\"range\":{" + "\"start\":{\"line\":%u,\"character\":%u}," + "\"end\":{\"line\":%u,\"character\":%u}" + "}," + "\"text\":\"", + buffer->version+1, uri_escaped, + line, column, line, column); + static const gchar req_suffix[] = "\"" + "}]" + "}" + "}"; + + /* + * Count the escaped size of the text buffer. + * We need to do this in advance to send a correct "Content-Length" header. + * We do this to avoid copying the entire buffer around several times as + * would be necessary when using teco_json_escape() and teco_lsp_send(). + */ + gsize req_prefix_len = strlen(req_prefix); + gsize req_len = req_prefix_len + teco_json_escape_len(text, len) + sizeof(req_suffix)-1; + + gchar header[256]; + gsize header_len = g_snprintf(header, sizeof(header), "Content-Length: %zu\r\n\r\n", req_len); + + if (g_io_channel_write_chars(teco_lsp.stdin_chan, header, header_len, + NULL, error) == G_IO_STATUS_ERROR || + g_io_channel_write_chars(teco_lsp.stdin_chan, req_prefix, req_prefix_len, + NULL, error) == G_IO_STATUS_ERROR || + !teco_lsp_send_escaped(text, len, error) || + g_io_channel_write_chars(teco_lsp.stdin_chan, req_suffix, sizeof(req_suffix)-1, + NULL, error) == G_IO_STATUS_ERROR || + g_io_channel_flush(teco_lsp.stdin_chan, error) == G_IO_STATUS_ERROR) + return FALSE; + + buffer->version++; + return TRUE; +} + +gboolean +teco_lsp_didchange_delete(teco_buffer_t *buffer, gsize pos, gsize len, GError **error) +{ + if (teco_lsp.pid < 0) + /* do nothing until the user queries something */ + return TRUE; + + /* + * FIXME: Can we somehow handle the unnamed buffer? + * clangd doesn't accept `untitled:` URI schemes. + */ + if (!buffer->filename) + return TRUE; + + guint start_line = teco_view_ssm(buffer->view, SCI_LINEFROMPOSITION, pos, 0); + guint start_column = pos - teco_view_ssm(buffer->view, SCI_POSITIONFROMLINE, start_line, 0); + guint end_line = teco_view_ssm(buffer->view, SCI_LINEFROMPOSITION, pos+len, 0); + guint end_column = pos+len - teco_view_ssm(buffer->view, SCI_POSITIONFROMLINE, end_line, 0); + + g_autofree gchar *uri = g_filename_to_uri(buffer->filename, NULL, error); + if (!uri) + return FALSE; + g_autofree gchar *uri_escaped = teco_json_escape(uri, strlen(uri)); + g_autofree gchar *req = g_strdup_printf("{" + "\"jsonrpc\":\"2.0\"," + "\"method\":\"textDocument/didChange\"," + "\"params\":{" + "\"textDocument\":{" + "\"version\":%u," + "\"uri\":\"%s\"" + "}," + "\"contentChanges\":[{" + "\"range\":{" + "\"start\":{\"line\":%u,\"character\":%u}," + "\"end\":{\"line\":%u,\"character\":%u}" + "}," + "\"text\":\"\"" + "}]" + "}" + "}", buffer->version+1, uri_escaped, + start_line, start_column, end_line, end_column); + + if (!teco_lsp_send(req, error)) + return FALSE; + + buffer->version++; + return TRUE; +} + +/** + * Send new file to LSP server. + * + * This only makes sense after launching the LSP server or + * when saving the unnamed buffer. + */ +gboolean +teco_lsp_sync(teco_buffer_t *buffer, GError **error) +{ + g_assert(buffer->filename != NULL); + + if (teco_lsp.pid < 0) + /* do nothing until the user queries something */ + return TRUE; + + if (!teco_lsp_didopen(buffer, error)) + return FALSE; + + /* + * After LSP startup, all document contents will be sent via + * teco_lsp_didchange_insert(). + * Therefore teco_lsp_didopen() does not send document contents. + */ + gsize gap = teco_view_ssm(buffer->view, SCI_GETGAPPOSITION, 0, 0); + if (gap) { + const gchar *pre_gap = (const gchar *)teco_view_ssm(buffer->view, SCI_GETRANGEPOINTER, + 0, gap); + if (!teco_lsp_didchange_insert(buffer, 0, gap, pre_gap, error)) + return FALSE; + } + gsize post_gap_len = teco_view_ssm(buffer->view, SCI_GETLENGTH, 0, 0) - gap; + if (post_gap_len) { + const gchar *post_gap = (const gchar *)teco_view_ssm(buffer->view, SCI_GETRANGEPOINTER, + gap, post_gap_len); + if (!teco_lsp_didchange_insert(buffer, gap, post_gap_len, post_gap, error)) + return FALSE; + } + + return TRUE; +} + +gboolean +teco_lsp_didclose(teco_buffer_t *buffer, GError **error) +{ + if (teco_lsp.pid < 0) + /* do nothing until the user queries something */ + return TRUE; + + /* + * FIXME: Can we somehow handle the unnamed buffer? + * clangd doesn't accept `untitled:` URI schemes. + */ + if (!buffer->filename) + return TRUE; + + g_autofree gchar *uri = g_filename_to_uri(buffer->filename, NULL, error); + if (!uri) + return FALSE; + g_autofree gchar *uri_escaped = teco_json_escape(uri, strlen(uri)); + g_autofree gchar *req = g_strdup_printf("{" + "\"jsonrpc\":\"2.0\"," + "\"method\":\"textDocument/didClose\"," + "\"params\":{" + "\"textDocument\":{" + "\"uri\":\"%s\"" + "}" + "}" + "}", uri_escaped); + + return teco_lsp_send(req, error); +} + +static teco_lsp_result_t * +teco_lsp_parse_location(sj_Reader reader, sj_Value obj, GError **error) +{ + if (obj.type != SJ_OBJECT) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Expected object in response (at byte %zu)", + obj.start - reader.data); + return NULL; + } + + g_autofree gchar *uri = NULL; + gint line = -1, column = 0; + + sj_Value key, val; + while (sj_iter_object(&reader, obj, &key, &val)) { + if (teco_json_eq(key, "uri")) { + uri = teco_json_unescape(val); + } else if (teco_json_eq(key, "range")) { + sj_Value obj = val; + if (obj.type != SJ_OBJECT) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Expected object in response (at byte %zu)", + obj.start - reader.data); + return NULL; + } + + while (sj_iter_object(&reader, obj, &key, &val)) { + if (teco_json_eq(key, "start")) { + /* descend into object value */ + obj = val; + if (obj.type != SJ_OBJECT) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Expected object in response (at byte %zu)", + obj.start - reader.data); + return NULL; + } + } else if (teco_json_eq(key, "line")) { + if (val.type != SJ_NUMBER) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Expected number in response (at byte %zu)", + val.start - reader.data); + return NULL; + } + line = atoi(val.start); + } else if (teco_json_eq(key, "character")) { + /* + * Column is optional. + * FIXME: Is it really in glyphs? + */ + if (val.type != SJ_NUMBER) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Expected number in response (at byte %zu)", + val.start - reader.data); + return NULL; + } + column = atoi(val.start); + } + } + } + } + + if (!uri || line < 0) { + g_set_error_literal(error, TECO_ERROR, TECO_ERROR_FAILED, + "URI and start position expected in result"); + return NULL; + } + + g_autofree gchar *hostname = NULL; + g_autofree gchar *filename = g_filename_from_uri(uri, &hostname, error); + if (!filename) + return NULL; + if (hostname) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Hostname \"%s\" unexpected in URI", hostname); + return NULL; + } + + return teco_lsp_result_new(filename, line, column); +} + +static gboolean +teco_lsp_lookup_symbol(teco_string_t str, gboolean match_exact, GError **error) +{ + g_assert(teco_lsp.pid >= 0); + + teco_undo_restore_lsp_list(); + teco_lsp.list = TECO_STAILQ_HEAD_INITIALIZER(&teco_lsp.list); + + g_autofree gchar *symbol_escaped = teco_json_escape(str.data, str.len); + g_autofree gchar *req = g_strdup_printf("{" + "\"jsonrpc\":\"2.0\"," + "\"id\":1," + "\"method\":\"workspace/symbol\"," + "\"params\":{" + "\"query\":\"%s\"" + "}" + "}", symbol_escaped); + if (!teco_lsp_send(req, error)) + return FALSE; + + g_auto(teco_string_t) resp = {NULL, 0}; + if (!teco_lsp_recv(&resp, error)) + return FALSE; + + sj_Reader reader = sj_reader(resp.data, resp.len); + sj_Value obj = sj_read(&reader); + g_assert(obj.type == SJ_OBJECT); + + sj_Value key, val = {0}; + while (sj_iter_object(&reader, obj, &key, &val) && + !teco_json_eq(key, "result")); + sj_Value arr = val; + if (arr.type != SJ_ARRAY) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Expected array in response (at byte %zu)", + arr.start - reader.data); + return FALSE; + } + + guint results = 0; + while (sj_iter_array(&reader, arr, &obj)) { + if (obj.type != SJ_OBJECT) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Expected object in response (at byte %zu)", + obj.start - reader.data); + return FALSE; + } + + gboolean skip_result = FALSE; + teco_lsp_result_t *result = NULL; + while (sj_iter_object(&reader, obj, &key, &val)) { + if (teco_json_eq(key, "name") && match_exact) { + g_autofree gchar *name = teco_json_unescape(val); + skip_result = strcmp(str.data, name) != 0; + if (skip_result) + break; + } else if (teco_json_eq(key, "location")) { + result = teco_lsp_parse_location(reader, val, error); + if (!result) + return FALSE; + } + } + if (skip_result) { + g_free(result); + continue; + } + if (!result) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "\"location\" missing in LSP response"); + return FALSE; + } + + teco_stailq_insert_tail(&teco_lsp.list, &result->entry); + results++; + } + if (results > 1) + teco_interface_msg(TECO_MSG_INFO, "Found %u references", results); + + teco_undo_ptr(teco_lsp.current) = (teco_lsp_result_t *)teco_lsp.list.first; + return TRUE; +} + +/** + * Auto-complete a workspace symbol. + * + * @param symbol The symbol to auto-complete or NULL. + * @param insert String to initialize with the completion. + * @return TRUE in case of an unambiguous completion. + */ +gboolean +teco_lsp_symbol_auto_complete(const gchar *symbol, teco_string_t *insert) +{ + memset(insert, 0, sizeof(*insert)); + + /* server is started on demand */ + if (G_UNLIKELY(teco_lsp.pid < 0) && !teco_lsp_launch(NULL)) + return FALSE; + + if (!symbol) + symbol = ""; + gsize symbol_len = strlen(symbol); + + g_autofree gchar *symbol_escaped = teco_json_escape(symbol, symbol_len); + g_autofree gchar *req = g_strdup_printf("{" + "\"jsonrpc\":\"2.0\"," + "\"id\":1," + "\"method\":\"workspace/symbol\"," + "\"params\":{" + "\"query\":\"%s\"" + "}" + "}", symbol_escaped); + if (!teco_lsp_send(req, NULL)) + return FALSE; + + g_auto(teco_string_t) resp = {NULL, 0}; + if (!teco_lsp_recv(&resp, NULL)) + return FALSE; + + sj_Reader reader = sj_reader(resp.data, resp.len); + sj_Value obj = sj_read(&reader); + g_assert(obj.type == SJ_OBJECT); + + sj_Value key, val = {0}; + while (sj_iter_object(&reader, obj, &key, &val) && + !teco_json_eq(key, "result")); + sj_Value arr = val; + if (arr.type != SJ_ARRAY) + return FALSE; + + GSList *list = NULL; + guint list_len = 0; + /** length of common prefix among all matching results */ + gsize prefix_len = 0; + + while (sj_iter_array(&reader, arr, &obj)) { + if (obj.type != SJ_OBJECT) + /* shouldn't happen */ + continue; + + while (sj_iter_object(&reader, obj, &key, &val) && + !teco_json_eq(key, "name")); + + gchar *name = teco_json_unescape(val); + if (strncmp(name, symbol, symbol_len) != 0) { + g_free(name); + continue; + } + + if (list) { + teco_string_t list_str; + list_str.data = (gchar *)list->data + symbol_len; + list_str.len = strlen(list_str.data); + + gsize len = teco_string_casediff(list_str, (gchar *)name + symbol_len, + strlen(name) - symbol_len); + if (len < prefix_len) + prefix_len = len; + } else { + prefix_len = strlen(name) - symbol_len; + } + + /* ownership of name is passed to the list */ + list = g_slist_prepend(list, name); + list_len++; + } + + if (prefix_len > 0) { + teco_string_init(insert, (gchar *)list->data + symbol_len, prefix_len); + } else if (list_len > 1) { + list = g_slist_sort(list, (GCompareFunc)strcmp); + + for (GSList *entry = list; entry != NULL; entry = g_slist_next(entry)) + teco_interface_popup_add(TECO_POPUP_PLAIN, entry->data, + strlen(entry->data), FALSE); + + teco_interface_popup_show(symbol_len); + } + + g_slist_free_full(list, g_free); + return list_len == 1; +} + +static gboolean +teco_lsp_lookup_definition(teco_buffer_t *buffer, teco_int_t pos, GError **error) +{ + g_assert(buffer->filename != NULL); + g_assert(teco_lsp.pid >= 0); + + teco_undo_restore_lsp_list(); + teco_lsp.list = TECO_STAILQ_HEAD_INITIALIZER(&teco_lsp.list); + + gsize dot_bytes = teco_view_ssm(buffer->view, SCI_GETCURRENTPOS, 0, 0); + gssize pos_bytes = teco_view_glyphs2bytes_rel(buffer->view, buffer->dot, dot_bytes, pos - buffer->dot); + guint line = teco_view_ssm(buffer->view, SCI_LINEFROMPOSITION, pos_bytes, 0); + guint column = pos_bytes - teco_view_ssm(buffer->view, SCI_POSITIONFROMLINE, line, 0); + + g_autofree gchar *uri = g_filename_to_uri(buffer->filename, NULL, error); + if (!uri) + return FALSE; + g_autofree gchar *uri_escaped = teco_json_escape(uri, strlen(uri)); + g_autofree gchar *req = g_strdup_printf("{" + "\"jsonrpc\":\"2.0\"," + "\"id\":1," + "\"method\":\"textDocument/definition\"," + "\"params\":{" + "\"textDocument\":{" + "\"uri\":\"%s\"" + "}," + "\"position\":{\"line\":%u,\"character\":%u}" + "}" + "}", uri_escaped, line, column); + if (!teco_lsp_send(req, error)) + return FALSE; + + g_auto(teco_string_t) resp = {NULL, 0}; + if (!teco_lsp_recv(&resp, error)) + return FALSE; + + sj_Reader reader = sj_reader(resp.data, resp.len); + sj_Value obj = sj_read(&reader); + g_assert(obj.type == SJ_OBJECT); + + sj_Value key, val = {0}; + while (sj_iter_object(&reader, obj, &key, &val) && + !teco_json_eq(key, "result")); + if (val.type == SJ_OBJECT) { + teco_lsp_result_t *result = teco_lsp_parse_location(reader, val, error); + if (!result) + return FALSE; + teco_stailq_insert_tail(&teco_lsp.list, &result->entry); + } else if (val.type != SJ_ARRAY) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Expected array in response (at byte %zu)", + val.start - reader.data); + return FALSE; + } + sj_Value arr = val; + + guint results = 0; + while (sj_iter_array(&reader, arr, &obj)) { + teco_lsp_result_t *result = teco_lsp_parse_location(reader, obj, error); + if (!result) + return FALSE; + teco_stailq_insert_tail(&teco_lsp.list, &result->entry); + results++; + } + if (results > 1) + teco_interface_msg(TECO_MSG_INFO, "Found %u definitions", results); + + teco_undo_ptr(teco_lsp.current) = (teco_lsp_result_t *)teco_lsp.list.first; + return TRUE; +} + +static gboolean +teco_lsp_lookup_references(teco_buffer_t *buffer, teco_int_t pos, GError **error) +{ + g_assert(buffer->filename != NULL); + g_assert(teco_lsp.pid >= 0); + + teco_undo_restore_lsp_list(); + teco_lsp.list = TECO_STAILQ_HEAD_INITIALIZER(&teco_lsp.list); + + gsize dot_bytes = teco_view_ssm(buffer->view, SCI_GETCURRENTPOS, 0, 0); + gssize pos_bytes = teco_view_glyphs2bytes_rel(buffer->view, buffer->dot, dot_bytes, pos - buffer->dot); + guint line = teco_view_ssm(buffer->view, SCI_LINEFROMPOSITION, pos_bytes, 0); + guint column = pos_bytes - teco_view_ssm(buffer->view, SCI_POSITIONFROMLINE, line, 0); + + g_autofree gchar *uri = g_filename_to_uri(buffer->filename, NULL, error); + if (!uri) + return FALSE; + g_autofree gchar *uri_escaped = teco_json_escape(uri, strlen(uri)); + g_autofree gchar *req = g_strdup_printf("{" + "\"jsonrpc\":\"2.0\"," + "\"id\":1," + "\"method\":\"textDocument/references\"," + "\"params\":{" + "\"textDocument\":{" + "\"uri\":\"%s\"" + "}," + "\"position\":{\"line\":%u,\"character\":%u}," + "\"context\":{" + "\"includeDeclaration\":true" + "}" + "}" + "}", uri_escaped, line, column); + if (!teco_lsp_send(req, error)) + return FALSE; + + g_auto(teco_string_t) resp = {NULL, 0}; + if (!teco_lsp_recv(&resp, error)) + return FALSE; + + sj_Reader reader = sj_reader(resp.data, resp.len); + sj_Value obj = sj_read(&reader); + g_assert(obj.type == SJ_OBJECT); + + sj_Value key, val = {0}; + while (sj_iter_object(&reader, obj, &key, &val) && + !teco_json_eq(key, "result")); + sj_Value arr = val; + if (arr.type != SJ_ARRAY) { + g_set_error(error, TECO_ERROR, TECO_ERROR_FAILED, + "Expected array in response (at byte %zu)", + arr.start - reader.data); + return FALSE; + } + + guint results = 0; + while (sj_iter_array(&reader, arr, &obj)) { + teco_lsp_result_t *result = teco_lsp_parse_location(reader, obj, error); + if (!result) + return FALSE; + teco_stailq_insert_tail(&teco_lsp.list, &result->entry); + results++; + } + if (results > 1) + teco_interface_msg(TECO_MSG_INFO, "Found %u references", results); + + teco_undo_ptr(teco_lsp.current) = (teco_lsp_result_t *)teco_lsp.list.first; + return TRUE; +} + +static gboolean +teco_lsp_shutdown(GError **error) +{ + if (teco_lsp.pid < 0) + return TRUE; + + static const gchar req[] = "{" + "\"jsonrpc\":\"2.0\"," + "\"id\":1," + "\"method\":\"shutdown\"," + "\"params\":null" + "}"; + if (!teco_lsp_send(req, error)) + return FALSE; + + g_auto(teco_string_t) resp = {NULL, 0}; + if (!teco_lsp_recv(&resp, error)) + return FALSE; + /* FIXME: Do we need to check the response? */ + + static const gchar notification[] = "{" + "\"jsonrpc\":\"2.0\"," + "\"method\":\"exit\"," + "\"params\":null" + "}"; + return teco_lsp_send(notification, error); +} + +static teco_state_t * +teco_state_lsp_lookup_done(teco_machine_main_t *ctx, teco_string_t str, GError **error) +{ + if (ctx->flags.mode > TECO_MODE_NORMAL) + return &teco_state_start; + + gboolean have_colon = teco_machine_main_eval_colon(ctx) > 0; + + if (!teco_expressions_eval(FALSE, error)) + return FALSE; + + if (!teco_expressions_args()) { + /* look up symbol */ + if (teco_num_sign < 0) { + /* terminate language server */ + teco_lsp_cleanup(); + return &teco_state_start; + } + + /* server is started on demand */ + if (G_UNLIKELY(teco_lsp.pid < 0) && !teco_lsp_launch(error)) + return NULL; + + if (str.len && !teco_lsp_lookup_symbol(str, !have_colon, error)) + return NULL; + } else { + /* look up definition or references at */ + if (str.len) { + g_set_error_literal(error, TECO_ERROR, TECO_ERROR_FAILED, + "String argument must be empty when " + "looking up definitions or references"); + return NULL; + } + + teco_int_t v; + if (!teco_expressions_pop_num_calc(&v, 0, error)) + return NULL; + + if (v < 0) { + /* terminate language server */ + teco_lsp_cleanup(); + return &teco_state_start; + } + + if (teco_qreg_current || !teco_ring_current->filename) { + g_set_error_literal(error, TECO_ERROR, TECO_ERROR_FAILED, + "Q-Registers and unnamed buffers not allowed"); + return NULL; + } + + /* server is started on demand */ + if (G_UNLIKELY(teco_lsp.pid < 0) && !teco_lsp_launch(error)) + return NULL; + + gboolean rc = have_colon ? teco_lsp_lookup_references(teco_ring_current, v, error) + : teco_lsp_lookup_definition(teco_ring_current, v, error); + if (!rc) + return NULL; + } + + if (!teco_lsp.current) { + /* mimics an unsuccessful search */ + teco_interface_msg(TECO_MSG_ERROR, "No tags found"); + return &teco_state_start; +#if 0 + g_set_error_literal(error, TECO_ERROR, TECO_ERROR_FAILED, + "No tags found"); + return NULL; +#endif + } + + /* + * ED hooks with the default lexer framework + * will usually load the styling SciTECO script + * when editing the buffer for the first time. + */ + if (!teco_current_doc_undo_edit(error) || + !teco_ring_edit(teco_lsp.current->filename, error)) + return NULL; + + undo__teco_interface_ssm(SCI_GOTOPOS, + teco_interface_ssm(SCI_GETCURRENTPOS, 0, 0), 0); + + sptr_t pos = teco_interface_ssm(SCI_POSITIONFROMLINE, teco_lsp.current->line, 0) + + teco_lsp.current->column; + teco_current_doc_set_dot(teco_interface_bytes2glyphs_absdot(pos)); + teco_interface_ssm(SCI_GOTOPOS, pos, 0); + + teco_undo_ptr(teco_lsp.current) = (teco_lsp_result_t *)teco_lsp.current->entry.next + ? : (teco_lsp_result_t *)teco_lsp.list.first; + return &teco_state_start; +} + +/* in cmdline.c */ +gboolean teco_state_lsp_lookup_process_edit_cmd(teco_machine_main_t *ctx, teco_machine_t *parent_ctx, + gunichar key, GError **error); +gboolean teco_state_lsp_lookup_insert_completion(teco_machine_main_t *ctx, teco_string_t str, + GError **error); + +/*$ FT :FT LSP lookup definition + * FT[symbol]$ -- Look up symbol via language server + * :FT[symbol]$ + * FT$ + * :FT$ + * FT$ + * -FT$ + * + * When called with a string argument, it looks up the given + * in the language server's workspace and jumps to the + * corresponding position. + * If colon-modified (\(lq:FT\(rq) the symbol will be fuzzy-matched. + * A message is logged if there is more than one result. + * You can toggle through these results by calling \(lqFT\fB$\fP\(rq. + * Since all \*(ST buffers are automatically synchronized with the + * language server the matches should always be up to date. + * This may not be the case when modifying buffers between \(lqFT\fB$\fP\(rq + * calls. + * The symbol name can be auto-completed, but you may not be offered + * all possible symbols. I.e. it may be possible to find a + * even it was not offered as an auto-completion. + * + * It is also possible to look up the definition of the construct + * at buffer position (i.e. when providing a numeric argument). + * If colon-modified, the command will look up all references to the + * construct at buffer position instead. + * So \(lq.FT\fB$\fP\(rq looks up the definition of the construct + * at dot. With the standard macros from \fBfnkeys.tes\fP you can also + * right click to insert a buffer position. + * + * The language server binary and arguments are configured via the \fB$SCITECO_LSP\fP + * environment variable (and corresponding Q-Register). + * The program from this register is spawned as a permanent subprocess \(em + * communication takes place using \fBstdin\fP and \fBstdout\fP. + * \fB$SCITECO_LSP_ROOT\fP can be used to point the language server + * to the root of the project, which may be necessary e.g. to find + * \fBcompile_commands.json\fP when using clangd. + * It will already be set by \fBsession.tes\fP when using a VCS. + * Language servers are launched on demand \(em only when first + * looking up a symbol. + * Therefore the first lookup may well fail. You can use + * \(lqFT\fB$\fP\(rq to force a language server startup. + * \(lq\-FT\fB$\fP\(rq will shut down any running language server. + */ +TECO_DEFINE_STATE_EXPECTSTRING(teco_state_lsp_lookup, + .process_edit_cmd_cb = (teco_state_process_edit_cmd_cb_t)teco_state_lsp_lookup_process_edit_cmd, + .insert_completion_cb = (teco_state_insert_completion_cb_t)teco_state_lsp_lookup_insert_completion, + .expectstring.done_cb = teco_state_lsp_lookup_done +); diff --git a/src/lsp.h b/src/lsp.h new file mode 100644 index 0000000..325c204 --- /dev/null +++ b/src/lsp.h @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2012-2026 Robin Haberkorn + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include + +#include "parser.h" +#include "ring.h" + +gboolean teco_lsp_didopen(teco_buffer_t *buffer, GError **error); +gboolean teco_lsp_didchange_insert(teco_buffer_t *buffer, gsize pos, gsize len, + const gchar *text, GError **error); +gboolean teco_lsp_didchange_delete(teco_buffer_t *buffer, gsize pos, gsize len, + GError **error); +gboolean teco_lsp_didclose(teco_buffer_t *buffer, GError **error); + +gboolean teco_lsp_sync(teco_buffer_t *buffer, GError **error); + +gboolean teco_lsp_symbol_auto_complete(const gchar *symbol, teco_string_t *insert); + +extern teco_state_t teco_state_lsp_lookup; diff --git a/src/ring.c b/src/ring.c index cda15c4..dc43ac5 100644 --- a/src/ring.c +++ b/src/ring.c @@ -37,6 +37,7 @@ #include "glob.h" #include "error.h" #include "list.h" +#include "lsp.h" #include "ring.h" /** @private @static @memberof teco_buffer_t */ @@ -79,13 +80,20 @@ teco_buffer_undo_edit(teco_buffer_t *ctx) static gboolean teco_buffer_load(teco_buffer_t *ctx, const gchar *filename, GError **error) { + teco_buffer_set_filename(ctx, filename); + + if (!teco_lsp_didopen(ctx, error)) + return FALSE; + + /* + * Will also call teco_lsp_didchange_insert() following + * Scintilla notifications. + */ if (!teco_view_load(ctx->view, filename, TRUE, error)) return FALSE; /* currently buffer cannot be dirty */ g_assert(ctx->state == TECO_BUFFER_CLEAN); - - teco_buffer_set_filename(ctx, filename); return TRUE; } @@ -93,7 +101,9 @@ teco_buffer_load(teco_buffer_t *ctx, const gchar *filename, GError **error) static gboolean teco_buffer_save(teco_buffer_t *ctx, const gchar *filename, GError **error) { - if (!filename && !ctx->filename) { + gboolean is_unnamed = !ctx->filename; + + if (!filename && is_unnamed) { g_set_error_literal(error, TECO_ERROR, TECO_ERROR_FAILED, "Cannot save the unnamed file " "without providing a file name"); @@ -127,6 +137,13 @@ teco_buffer_save(teco_buffer_t *ctx, const gchar *filename, GError **error) teco_undo_cstring(ctx->filename); teco_buffer_set_filename(ctx, filename ? : ctx->filename); + /* + * Unnamed buffers were not synced with the LSP server, + * so we do this now. + */ + if (is_unnamed && !teco_lsp_sync(ctx, error)) + return FALSE; + return TRUE; } @@ -400,6 +417,9 @@ teco_ring_edit_by_name(const gchar *filename, GError **error) else teco_interface_msg(TECO_MSG_INFO, "Added new unnamed file to ring."); + + if (!teco_lsp_didopen(buffer, error)) + return FALSE; } return teco_ed_hook(TECO_ED_HOOK_ADD, error); @@ -467,6 +487,9 @@ teco_ring_close(teco_buffer_t *buffer, GError **error) teco_ring_remove_buffer(buffer); } + if (!teco_lsp_didclose(buffer, error)) + return FALSE; + /* transfer responsibility to the undo token object */ teco_undo_ring_reinsert(buffer); @@ -489,6 +512,18 @@ teco_ring_set_scintilla_undo(gboolean state) } } +gboolean +teco_ring_sync_lsp(GError **error) +{ + for (teco_tailq_entry_t *cur = teco_ring_head.first; cur != NULL; cur = cur->next) { + teco_buffer_t *buffer = (teco_buffer_t *)cur; + if (buffer->filename && !teco_lsp_sync(buffer, error)) + return FALSE; + } + + return TRUE; +} + void teco_ring_cleanup(void) { diff --git a/src/ring.h b/src/ring.h index 91ce3c9..126f455 100644 --- a/src/ring.h +++ b/src/ring.h @@ -43,6 +43,12 @@ typedef struct teco_buffer_t { gchar *filename; + /** + * Counter increasing with every change. + * It is maintained only if a language server (LSP) is running. + */ + guint version; + /** * A teco_buffer_state_t. * This is still a guint, so you can call teco_undo_guint(). @@ -117,6 +123,8 @@ void teco_ring_undo_close(void); void teco_ring_set_scintilla_undo(gboolean state); +gboolean teco_ring_sync_lsp(GError **error); + void teco_ring_cleanup(void); /* diff --git a/src/spawn.c b/src/spawn.c index 507d1cd..6289830 100644 --- a/src/spawn.c +++ b/src/spawn.c @@ -176,6 +176,8 @@ teco_parse_shell_command_line(const gchar *cmdline, GError **error) * As a workaround, only pass down absolute paths. * This is not perfect: It will use $PATH from the environment, * so you cannot overwrite it from within SciTECO. + * On the other hand we're not currently using + * G_SPAWN_SEARCH_PATH_FROM_ENVP anyway. */ gchar *program = g_find_program_in_path(argv[0]); if (!program) { @@ -270,6 +272,7 @@ teco_state_execute_done(teco_machine_main_t *ctx, teco_string_t str, GError **er * NOTE: With G_SPAWN_LEAVE_DESCRIPTORS_OPEN and without G_SPAWN_SEARCH_PATH_FROM_ENVP, * Glib offers an "optimized codepath" on UNIX. * G_SPAWN_SEARCH_PATH_FROM_ENVP does not appear to work on Windows, anyway. + * On the other hand, this means you cannot overwrite $PATH via Q-Registers. */ static const GSpawnFlags flags = G_SPAWN_DO_NOT_REAP_CHILD | G_SPAWN_SEARCH_PATH | #ifdef G_OS_UNIX diff --git a/src/view.c b/src/view.c index 7a9e0cc..43cdd3f 100644 --- a/src/view.c +++ b/src/view.c @@ -47,12 +47,23 @@ #include "eol.h" #include "memory.h" #include "lexer.h" +#include "ring.h" +#include "lsp.h" #include "view.h" +//#define DEBUG + /** @memberof teco_view_t */ void teco_view_setup(teco_view_t *ctx) { + /* + * Avoid unnecessary notifications. + */ + teco_view_ssm(ctx, SCI_SETCOMMANDEVENTS, FALSE, 0); + teco_view_ssm(ctx, SCI_SETMODEVENTMASK, + SC_MOD_INSERTTEXT | SC_MOD_BEFOREDELETE, 0); + /* * Start with or without undo collection, * depending on teco_undo_enabled. @@ -825,20 +836,49 @@ teco_view_get_character(teco_view_t *ctx, gsize pos, gsize len) } void -teco_view_process_notify(teco_view_t *ctx, SCNotification *notify) +teco_view_process_notify(teco_view_t *ctx, const SCNotification *notify) { #ifdef DEBUG - g_printf("SCINTILLA NOTIFY: code=%d\n", notify->nmhdr.code); + g_printf("SCINTILLA NOTIFY: code=%u\n", notify->nmhdr.code); #endif - /* - * Lexing in the container: only used for SciTECO. - * - * The "identifier" is abused to enable/disable lexing. - * It could be extended later on for several internal lexers. - * The alternative would be an ILexer5 wrapper, written in C++. - */ - if (notify->nmhdr.code == SCN_STYLENEEDED && - teco_view_ssm(ctx, SCI_GETIDENTIFIER, 0, 0) != 0) - teco_lexer_style(ctx, notify->position); + switch (notify->nmhdr.code) { + case SCN_STYLENEEDED: + /* + * Lexing in the container: only used for SciTECO. + * + * The "identifier" is abused to enable/disable lexing. + * It could be extended later on for several internal lexers. + * The alternative would be an ILexer5 wrapper, written in C++. + */ + if (teco_view_ssm(ctx, SCI_GETIDENTIFIER, 0, 0) != 0) + teco_lexer_style(ctx, notify->position); + break; + + case SCN_MODIFIED: +#ifdef DEBUG + g_printf("SCINTILLA NOTIFY: modification=0x%02X\n", notify->modificationType); +#endif + + if (!teco_ring_current || ctx != teco_ring_current->view) + break; + + if (notify->modificationType & SC_MOD_INSERTTEXT) { +#ifdef DEBUG + g_printf("SCINTILLA NOTIFY: INSERT: pos=%ld, text=\"%.*s\"\n", + notify->position, (gint)notify->length, notify->text); +#endif + teco_lsp_didchange_insert(teco_ring_current, notify->position, + notify->length, notify->text, NULL); + } + if (notify->modificationType & SC_MOD_BEFOREDELETE) { +#ifdef DEBUG + g_printf("SCINTILLA NOTIFY: BEFORE DELETE: pos=%ld, len=%ld\n", + notify->position, notify->length); +#endif + teco_lsp_didchange_delete(teco_ring_current, notify->position, + notify->length, NULL); + } + break; + } } diff --git a/src/view.h b/src/view.h index 86cc3dc..887642a 100644 --- a/src/view.h +++ b/src/view.h @@ -92,4 +92,4 @@ teco_int_t teco_view_bytes2glyphs_rel(teco_view_t *ctx, teco_int_t pos_glyphs, teco_int_t teco_view_get_character(teco_view_t *ctx, gsize pos, gsize len); -void teco_view_process_notify(teco_view_t *ctx, SCNotification *notify); +void teco_view_process_notify(teco_view_t *ctx, const SCNotification *notify); -- cgit v1.2.3