/* * 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 );