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