diff options
Diffstat (limited to 'src/lsp.c')
| -rw-r--r-- | src/lsp.c | 1380 |
1 files changed, 1380 insertions, 0 deletions
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 <http://www.gnu.org/licenses/>. + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include <signal.h> +#include <stdlib.h> +#include <string.h> +#include <stdio.h> + +#include <glib.h> + +#ifdef HAVE_WINDOWS_H +#define WIN32_LEAN_AND_MEAN +#include <windows.h> +#endif + +#define SJ_IMPL +#include <sj.h> + +#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 <n> */ + 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]$ + * <n>FT$ + * <n>:FT$ + * FT$ + * -FT$ + * + * When called with a string argument, it looks up the given + * <symbol> 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 <symbol> + * even it was not offered as an auto-completion. + * + * It is also possible to look up the definition of the construct + * at buffer position <n> (i.e. when providing a numeric argument). + * If colon-modified, the command will look up all references to the + * construct at buffer position <n> 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 +); |
