aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorRobin Haberkorn <rhaberkorn@fmsbw.de>2026-08-19 01:53:55 +0200
committerRobin Haberkorn <rhaberkorn@fmsbw.de>2026-10-04 23:07:47 +0200
commite7e539f20c50bd276d63a1c279e2bc8706df8a5c (patch)
tree10496f7efbaf97309e045cf3c639b8d4dab5428e
parentbfafc42fc3e6ae33eec68f0eae07739df6ae5c2b (diff)
LSP: auto-complete symbols via the textDocument/completion request
This currently only works in insert commands after enabling the immediate editing modifier via CTRL+G and if the LSP was already launched (e.g. by an explicit `FT$`). File name completions are still performed with an activated ^G modifier only if the LSP is shut down. Just like for file names, the modifier is automatically cancelled after a successful completion - there is little sense in completing several symbols in a row.
-rw-r--r--README3
-rw-r--r--doc/sciteco.7.template13
-rw-r--r--src/cmdline.c22
-rw-r--r--src/lsp.c206
-rw-r--r--src/lsp.h4
5 files changed, 244 insertions, 4 deletions
diff --git a/README b/README
index 90a4e82..a4a713d 100644
--- a/README
+++ b/README
@@ -86,6 +86,9 @@ Features
in single letter Q-Register names.
* 8-bit clean: SciTECO can be used to edit binary files if the encoding is changed to
ANSI and automatic EOL conversion is turned off (easiest with `--8bit`).
+* Supports lookups and auto-completions via
+ [language servers](https://microsoft.github.io/language-server-protocol/) (LSP).
+ Use [ctags-lsp](https://microsoft.github.io/language-server-protocol/) to support Ctags.
* Self-documenting: An integrated indexed help system allows browsing formatted documentation
about commands, macros and concepts within SciTECO (`?` command).
Macro packages can be documented with the `tedoc` tool, generating man pages.
diff --git a/doc/sciteco.7.template b/doc/sciteco.7.template
index b3b2aaa..4b6987a 100644
--- a/doc/sciteco.7.template
+++ b/doc/sciteco.7.template
@@ -654,9 +654,22 @@ effectively opening the corresponding \fIwomanpage\fP,
except if the \(lq{\(rq terminator is used.
T}
T{
+Auto complete via LSP
+T};9;^I, Tab;T{
+Text insertion arguments
+.br
+(modifier \fIenabled\fP and language server started)
+T};T{
+Auto completes the symbol at the current buffer position using the
+language server protocol (LSP).
+See also \fBFT\fP command.
+T}
+T{
Indent to next tab stop
T};9;^I, Tab;T{
Text insertion arguments
+.br
+(modifier \fIdisabled\fP)
T};T{
Expands to spaces so that following text is inserted
at the next tab stop into the buffer.
diff --git a/src/cmdline.c b/src/cmdline.c
index a4e2189..1abf8df 100644
--- a/src/cmdline.c
+++ b/src/cmdline.c
@@ -911,8 +911,28 @@ teco_state_insert_process_edit_cmd(teco_machine_main_t *ctx, teco_machine_t *par
return stringbuilding_current->process_edit_cmd_cb(&stringbuilding_ctx->parent, &ctx->parent, key, error);
switch (key) {
- case '\t': { /* insert <TAB> indention */
+ case '\t': { /* insert <TAB> indention or auto complete */
+ if (teco_cmdline.modifier_enabled && !teco_qreg_current && teco_lsp_is_running()) {
+ if (teco_interface_popup_is_shown()) {
+ /* cycle through popup pages */
+ teco_interface_popup_scroll();
+ return TRUE;
+ }
+
+ g_auto(teco_string_t) new_chars, new_chars_escaped;
+ gboolean unambiguous = teco_lsp_buffer_auto_complete(teco_ring_current, &new_chars);
+ teco_machine_stringbuilding_escape(stringbuilding_ctx, new_chars.data,
+ new_chars.len, &new_chars_escaped);
+ if (unambiguous) {
+ teco_string_append_c(&new_chars_escaped, ' ');
+ teco_cmdline_modifier_set(FALSE);
+ }
+
+ return teco_cmdline_insert(new_chars_escaped.data, new_chars_escaped.len, error);
+ }
+
if (teco_cmdline.modifier_enabled || teco_interface_ssm(SCI_GETUSETABS, 0, 0))
+ /* perhaps auto-complete file name */
break;
teco_interface_popup_clear();
diff --git a/src/lsp.c b/src/lsp.c
index ff7ddc7..38af49a 100644
--- a/src/lsp.c
+++ b/src/lsp.c
@@ -193,6 +193,12 @@ teco_json_unescape(sj_Value val)
return str;
}
+gboolean
+teco_lsp_is_running(void)
+{
+ return teco_lsp.pid != 0;
+}
+
static teco_lsp_result_t *
teco_lsp_result_new(const gchar *filename, guint line, guint column)
{
@@ -1023,7 +1029,7 @@ teco_lsp_symbol_auto_complete(const gchar *symbol, teco_string_t *insert)
!teco_json_eq(key, "name"));
gchar *name = teco_json_unescape(val);
- if (strncmp(name, symbol, symbol_len) != 0) {
+ if (!g_str_has_prefix(name, symbol)) {
g_free(name);
continue;
}
@@ -1033,8 +1039,8 @@ teco_lsp_symbol_auto_complete(const gchar *symbol, teco_string_t *insert)
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);
+ gsize len = teco_string_diff(list_str, (gchar *)name + symbol_len,
+ strlen(name) - symbol_len);
if (len < prefix_len)
prefix_len = len;
} else {
@@ -1211,6 +1217,196 @@ teco_lsp_lookup_references(teco_buffer_t *buffer, teco_int_t pos, GError **error
}
static gboolean
+teco_lsp_parse_range(teco_buffer_t *buffer, sj_Reader *reader,
+ sj_Value obj, Sci_Position *start, Sci_Position *end)
+{
+ if (obj.type != SJ_OBJECT)
+ return FALSE;
+
+ sj_Value key, val;
+ while (sj_iter_object(reader, obj, &key, &val)) {
+ Sci_Position *p = NULL;
+
+ if (teco_json_eq(key, "start"))
+ p = start;
+ else if (teco_json_eq(key, "end"))
+ p = end;
+ else
+ continue;
+
+ sj_Value obj = val;
+ if (obj.type != SJ_OBJECT)
+ return FALSE;
+
+ gint character = 0, line = 0;
+ while (sj_iter_object(reader, obj, &key, &val))
+ if (teco_json_eq(key, "character"))
+ character = atoi(val.start);
+ else if (teco_json_eq(key, "line"))
+ line = atoi(val.start);
+
+ *p = teco_view_ssm(buffer->view, SCI_POSITIONFROMLINE, line, 0) + character;
+ }
+
+ return TRUE;
+}
+
+/**
+ * Auto complete a symbol from a buffer at dot.
+ *
+ * This is not using the native Scintilla autocompletion popups
+ * because we cannot reliably inhibit mouse interactions and we
+ * cannot programmatically scroll through the list as well.
+ */
+gboolean
+teco_lsp_buffer_auto_complete(teco_buffer_t *buffer, teco_string_t *insert)
+{
+ memset(insert, 0, sizeof(*insert));
+
+ /* server is started on demand */
+ if (G_UNLIKELY(!teco_lsp.pid) && !teco_lsp_launch(NULL))
+ return FALSE;
+
+ gsize dot_bytes = teco_view_ssm(buffer->view, SCI_GETCURRENTPOS, 0, 0);
+ guint line = teco_view_ssm(buffer->view, SCI_LINEFROMPOSITION, dot_bytes, 0);
+ guint column = dot_bytes - teco_view_ssm(buffer->view, SCI_POSITIONFROMLINE, line, 0);
+
+ g_autofree gchar *uri = g_filename_to_uri(buffer->filename, NULL, NULL);
+ 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\":" TECO_LSP_ID ","
+ "\"method\":\"textDocument/completion\","
+ "\"params\":{"
+ "\"textDocument\":{"
+ "\"uri\":\"%s\""
+ "},"
+ "\"position\":{\"line\":%u,\"character\":%u},"
+ "\"context\":{"
+ "\"triggerKind\":1" /* manual */
+ "}"
+ "}"
+ "}", uri_escaped, line, column);
+ 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"));
+ if (val.type != SJ_OBJECT)
+ return FALSE;
+
+ obj = val;
+ while (sj_iter_object(&reader, obj, &key, &val) &&
+ !teco_json_eq(key, "items"));
+ sj_Value arr = val;
+ if (arr.type != SJ_ARRAY)
+ /* can also be `null` in some LSPs */
+ return FALSE;
+
+ GSList *list = NULL;
+ guint list_len = 0;
+ gsize symbol_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, "textEdit"));
+ sj_Value obj = val;
+ if (obj.type != SJ_OBJECT)
+ continue;
+
+ g_autofree gchar *name = NULL;
+ struct Sci_TextRangeFull range = {0};
+
+ /*
+ * Will usually contain the entire symbol name in "newText"
+ * and a "range" to delete first.
+ * We cannot really delete anything before dot, so we just try
+ * to skip the prefix that is already in the buffer.
+ */
+ while (sj_iter_object(&reader, obj, &key, &val))
+ if (teco_json_eq(key, "newText"))
+ name = teco_json_unescape(val);
+ else if (teco_json_eq(key, "range"))
+ teco_lsp_parse_range(buffer, &reader, val,
+ &range.chrg.cpMin, &range.chrg.cpMax);
+ if (!name || range.chrg.cpMin > range.chrg.cpMax ||
+ range.chrg.cpMax - range.chrg.cpMin >= strlen(name))
+ continue;
+
+ /*
+ * We are supposed to delete the range from rang.chrg,
+ * but this doesn't fit into SciTECO's command line editing where
+ * we can only guarantee inserting strings (`I` argument could still be empty).
+ * clangd will offer completions that are not suffixes of what's
+ * under dot, so we have to filter those out.
+ */
+ symbol_len = range.chrg.cpMax - range.chrg.cpMin;
+ g_autofree gchar *range_buf = g_malloc(symbol_len + 1);
+ range.lpstrText = range_buf;
+ teco_view_ssm(buffer->view, SCI_GETTEXTRANGEFULL, 0, (sptr_t)&range);
+ if (!g_str_has_prefix(name, range_buf))
+ 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_diff(list_str, (gchar *)name + symbol_len,
+ strlen(name) - symbol_len);
+ if (!prefix_len || len < prefix_len)
+ prefix_len = len;
+ } else {
+ prefix_len = strlen(name) - symbol_len;
+ }
+
+ /*
+ * There are also "label" keys, but the "labels" contain
+ * entire function signatures.
+ */
+ list = g_slist_prepend(list, g_steal_pointer(&name));
+ list_len++;
+ }
+
+ if (prefix_len > 0) {
+ teco_string_init(insert, (gchar *)list->data + symbol_len, prefix_len);
+ } else if (list_len > 1) {
+ /*
+ * Assume that the order we receive completions is significant,
+ * so we don't just alphabetically sort here.
+ */
+ list = g_slist_reverse(list);
+
+ for (GSList *entry = list; entry != NULL; entry = g_slist_next(entry)) {
+ const gchar *name = entry->data;
+ teco_interface_popup_add(TECO_POPUP_PLAIN, name, strlen(name), FALSE);
+ }
+
+ teco_interface_popup_show(symbol_len);
+ }
+
+ g_slist_free_full(list, g_free);
+ return list_len == 1;
+}
+
+static gboolean
teco_lsp_shutdown(GError **error)
{
if (!teco_lsp.pid)
@@ -1380,6 +1576,10 @@ gboolean teco_state_lsp_lookup_insert_completion(teco_machine_main_t *ctx, teco_
* 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.
+ *
+ * Once the language server has been launched, you can
+ * auto-complete symbols in buffers by pressing Tab after activating
+ * the immediate editing modifier (CTRL+G).
*/
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,
diff --git a/src/lsp.h b/src/lsp.h
index 325c204..f1fc3fa 100644
--- a/src/lsp.h
+++ b/src/lsp.h
@@ -19,8 +19,11 @@
#include <glib.h>
#include "parser.h"
+#include "string-utils.h"
#include "ring.h"
+gboolean teco_lsp_is_running(void);
+
gboolean teco_lsp_didopen(teco_buffer_t *buffer, GError **error);
gboolean teco_lsp_didchange_insert(teco_buffer_t *buffer, gsize pos, gsize len,
const gchar *text, GError **error);
@@ -31,5 +34,6 @@ gboolean teco_lsp_didclose(teco_buffer_t *buffer, GError **error);
gboolean teco_lsp_sync(teco_buffer_t *buffer, GError **error);
gboolean teco_lsp_symbol_auto_complete(const gchar *symbol, teco_string_t *insert);
+gboolean teco_lsp_buffer_auto_complete(teco_buffer_t *buffer, teco_string_t *insert);
extern teco_state_t teco_state_lsp_lookup;