Finch/src/lsp.cpp
Nexoniarz b872be3176 Finch 2.4.0: methods, maps, errors as values; str() of any value; macOS and ARM64 docs; Kate support
- fn Struct.method(...) with self by reference, also on C structs
- map[K]V: insertion-ordered hash maps, literals, m[k] += 1, has/get/remove/keys/values
- -> T! / -> !, return error(...), or <value>, or { err }, try; fallible int/float/read_file/write_file/delete_file
- for i, x in list / for k, v in map
- print and str() share one text formatter
- LSP: method and map completion, method signatures; grammar and snippets for VS Code and Kate
- tests: 74 (methods, errors, maps + 11 failure cases); docs at all three levels, en and pl

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:11:12 +02:00

580 lines
25 KiB
C++

// The Finch language server: `finch lsp` speaks the Language Server Protocol on stdin/stdout.
//
// Every time a document changes it is checked by the real compiler (lexer, parser, type
// checker and code generator, without optimizing or writing anything), with errors thrown
// instead of ending the process and a semantic index (index.h) collecting what each name is.
// Hover, go-to-definition, completion, signature help and document symbols read that index.
#include "builtins_doc.h"
#include "cimport.h"
#include "codegen.h"
#include "error.h"
#include "index.h"
#include "loader.h"
#include "target.h"
#include <llvm/MC/TargetRegistry.h>
#include <llvm/Support/FileSystem.h>
#include <llvm/Support/JSON.h>
#include <llvm/Support/Path.h>
#include <llvm/Support/TargetSelect.h>
#include <llvm/Target/TargetMachine.h>
#include <algorithm>
#include <cstdio>
#include <iostream>
#include <map>
#include <memory>
#include <set>
#include <string>
#include <vector>
#ifdef _WIN32
#include <fcntl.h>
#include <io.h>
#endif
using namespace llvm;
namespace {
// ---------- URIs ----------
std::string uriToPath(const std::string &uri) {
std::string s = uri.rfind("file://", 0) == 0 ? uri.substr(7) : uri;
std::string out;
for (size_t i = 0; i < s.size(); i++) {
if (s[i] == '%' && i + 2 < s.size()) {
out += (char)std::stoi(s.substr(i + 1, 2), nullptr, 16);
i += 2;
} else {
out += s[i];
}
}
#ifdef _WIN32
if (out.size() > 2 && out[0] == '/' && out[2] == ':') out = out.substr(1); // /c:/x -> c:/x
#endif
return normalizePath(out);
}
std::string pathToUri(const std::string &path) {
std::string p = normalizePath(path), out = "file://";
#ifdef _WIN32
for (char &c : p)
if (c == '\\') c = '/';
out += "/";
#endif
for (unsigned char c : p) {
if (isalnum(c) || c == '/' || c == '-' || c == '_' || c == '.' || c == '~' || c == ':') out += (char)c;
else {
char buf[4];
std::snprintf(buf, sizeof buf, "%%%02X", c);
out += buf;
}
}
return out;
}
// ---------- one check of a document ----------
struct Diagnostic {
std::string path;
int line, col;
std::string msg;
};
struct Analysis {
std::vector<SourceFile> files;
SemIndex index;
std::vector<Diagnostic> diags;
std::set<std::string> modules; // modules the document imports
const CImports *c = nullptr;
};
class Server {
public:
int run();
private:
std::map<std::string, std::string> docs; // normalized path -> text
std::map<std::string, std::shared_ptr<Analysis>> results; // last check of each open document
std::map<std::string, std::shared_ptr<Analysis>> lastGood; // last check without errors
std::map<std::string, std::unique_ptr<CImports>> headerCache;
std::set<std::string> diagnosed; // paths we published errors for
TargetMachine *tm = nullptr;
bool shutdown = false;
// protocol
bool readMessage(json::Value &out);
void send(const json::Value &v);
void reply(const json::Value &id, json::Value result);
void notify(const std::string &method, json::Value params);
void handle(const json::Object &msg);
// features
void check(const std::string &path);
json::Value hover(const std::string &path, int line, int col);
json::Value definition(const std::string &path, int line, int col);
json::Value completion(const std::string &path, int line, int col);
json::Value symbols(const std::string &path);
json::Value signatureHelp(const std::string &path, int line, int col);
const SymRef *refAt(const Analysis &a, const std::string &path, int line, int col);
std::string lineText(const std::string &path, int line);
};
bool Server::readMessage(json::Value &out) {
size_t length = 0;
std::string header;
while (std::getline(std::cin, header)) {
if (!header.empty() && header.back() == '\r') header.pop_back();
if (header.empty()) break;
if (header.rfind("Content-Length:", 0) == 0) length = std::stoul(header.substr(15));
}
if (!std::cin || length == 0) return false;
std::string body(length, '\0');
std::cin.read(&body[0], length);
Expected<json::Value> v = json::parse(body);
if (!v) {
consumeError(v.takeError());
return true; // skip a broken message
}
out = std::move(*v);
return true;
}
void Server::send(const json::Value &v) {
std::string body;
raw_string_ostream os(body);
os << v;
os.flush();
std::cout << "Content-Length: " << body.size() << "\r\n\r\n" << body;
std::cout.flush();
}
void Server::reply(const json::Value &id, json::Value result) {
send(json::Object{{"jsonrpc", "2.0"}, {"id", id}, {"result", std::move(result)}});
}
void Server::notify(const std::string &method, json::Value params) {
send(json::Object{{"jsonrpc", "2.0"}, {"method", method}, {"params", std::move(params)}});
}
int Server::run() {
#ifdef _WIN32
_setmode(_fileno(stdin), _O_BINARY);
_setmode(_fileno(stdout), _O_BINARY);
#endif
initTargets();
setTarget("");
std::string err;
const Target *target = TargetRegistry::lookupTarget(g_target.triple, err);
if (!target) return 1;
tm = target->createTargetMachine(g_target.triple, "generic", "", TargetOptions(), Reloc::PIC_);
json::Value msg(nullptr);
while (readMessage(msg)) {
if (const json::Object *o = msg.getAsObject()) handle(*o);
if (shutdown && !std::cin) break;
}
return 0;
}
static int intAt(const json::Object *o, const char *key) {
if (!o) return 0;
if (auto v = o->getInteger(key)) return (int)*v;
return 0;
}
void Server::handle(const json::Object &msg) {
std::string method = msg.getString("method").value_or("").str();
const json::Value *id = msg.get("id");
const json::Object *params = msg.getObject("params");
auto docPath = [&]() -> std::string {
if (!params) return "";
const json::Object *td = params->getObject("textDocument");
return td ? uriToPath(td->getString("uri").value_or("").str()) : "";
};
// positions: LSP is 0-based (line, UTF-16 column); Finch is 1-based (line, character)
auto position = [&](int &line, int &col) {
const json::Object *pos = params ? params->getObject("position") : nullptr;
line = intAt(pos, "line") + 1;
col = intAt(pos, "character") + 1;
};
if (method == "initialize") {
json::Object caps{
{"textDocumentSync", json::Object{{"openClose", true}, {"change", 1}, {"save", true}}},
{"hoverProvider", true},
{"definitionProvider", true},
{"documentSymbolProvider", true},
{"completionProvider", json::Object{{"triggerCharacters", json::Array{"."}}}},
{"signatureHelpProvider", json::Object{{"triggerCharacters", json::Array{"(", ","}}}},
};
reply(*id, json::Object{{"capabilities", std::move(caps)},
{"serverInfo", json::Object{{"name", "finch"}, {"version", FINCH_VERSION}}}});
} else if (method == "shutdown") {
shutdown = true;
reply(*id, nullptr);
} else if (method == "exit") {
std::exit(shutdown ? 0 : 1);
} else if (method == "textDocument/didOpen" || method == "textDocument/didChange") {
std::string path = docPath();
if (method == "textDocument/didOpen") {
docs[path] = params->getObject("textDocument")->getString("text").value_or("").str();
} else if (const json::Array *changes = params->getArray("contentChanges")) {
if (!changes->empty())
if (const json::Object *ch = changes->back().getAsObject()) docs[path] = ch->getString("text").value_or("").str();
}
check(path);
} else if (method == "textDocument/didSave") {
check(docPath());
} else if (method == "textDocument/didClose") {
std::string path = docPath();
docs.erase(path);
results.erase(path);
lastGood.erase(path);
notify("textDocument/publishDiagnostics", json::Object{{"uri", pathToUri(path)}, {"diagnostics", json::Array{}}});
} else if (id) {
int line = 0, col = 0;
position(line, col);
std::string path = docPath();
if (method == "textDocument/hover") reply(*id, hover(path, line, col));
else if (method == "textDocument/definition") reply(*id, definition(path, line, col));
else if (method == "textDocument/completion") reply(*id, completion(path, line, col));
else if (method == "textDocument/documentSymbol") reply(*id, symbols(path));
else if (method == "textDocument/signatureHelp") reply(*id, signatureHelp(path, line, col));
else send(json::Object{{"jsonrpc", "2.0"}, {"id", *id},
{"error", json::Object{{"code", -32601}, {"message", "not supported: " + method}}}});
}
}
// Check `path` with the real compiler, keep the index, publish errors.
void Server::check(const std::string &path) {
auto a = std::make_shared<Analysis>();
g_files.clear();
g_throwErrors = true;
g_index = &a->index;
try {
Loader loader;
loader.overrides = &docs;
loader.load(path, "", Pos{});
std::vector<Import> imports;
std::vector<std::string> dirs;
std::string key;
for (const Program &p : loader.progs) {
for (const Import &im : p.imports) {
imports.push_back(im);
if (im.isC) key += im.path + "\n";
else if (p.module.empty()) a->modules.insert(im.path);
}
std::string d = dirOf(p.path);
if (std::find(dirs.begin(), dirs.end(), d) == dirs.end()) dirs.push_back(d), key += "@" + d + "\n";
}
auto &cached = headerCache[key]; // headers are slow to read: once per set of imports
if (!cached) cached = std::make_unique<CImports>(importHeaders(imports, dirs));
a->c = cached.get();
LLVMContext ctx;
generate(loader.progs, *cached, ctx, *tm, false);
} catch (const FinchError &e) {
std::string file = e.file >= 0 && e.file < (int)g_files.size() ? normalizePath(g_files[e.file].path) : path;
a->diags.push_back({file, e.line, e.col, e.msg});
}
g_index = nullptr;
g_throwErrors = false;
a->files = g_files;
// While the code is half-typed (`p.`, `add(1, `) it doesn't compile; completion and signature
// help keep using what the last good version knew. Hover and definition use the newest refs.
if (a->diags.empty()) {
lastGood[path] = a;
} else if (auto good = lastGood.find(path); good != lastGood.end()) {
const Analysis &g = *good->second;
for (const FnInfo &f : g.index.fns) a->index.fns.push_back(f);
for (const StructIndex &st : g.index.structs) a->index.structs.push_back(st);
for (const VarInfo &v : g.index.vars) a->index.vars.push_back(v);
if (a->modules.empty()) a->modules = g.modules;
if (!a->c) a->c = g.c;
}
results[path] = a;
std::map<std::string, json::Array> byFile;
byFile[path];
for (const std::string &p : diagnosed) byFile[p]; // clear errors that went away
diagnosed.clear();
for (const Diagnostic &d : a->diags) {
int line = std::max(0, d.line - 1), col = std::max(0, d.col - 1);
byFile[d.path].push_back(json::Object{
{"range", json::Object{{"start", json::Object{{"line", line}, {"character", col}}},
{"end", json::Object{{"line", line}, {"character", col + 1}}}}},
{"severity", 1},
{"source", "finch"},
{"message", d.msg}});
diagnosed.insert(d.path);
}
for (auto &[p, list] : byFile)
notify("textDocument/publishDiagnostics", json::Object{{"uri", pathToUri(p)}, {"diagnostics", std::move(list)}});
}
const SymRef *Server::refAt(const Analysis &a, const std::string &path, int line, int col) {
for (const SymRef &r : a.index.refs) {
if (r.line != line || col < r.col || col > r.col + r.len) continue;
if (r.file < 0 || r.file >= (int)a.files.size() || normalizePath(a.files[r.file].path) != path) continue;
return &r;
}
return nullptr;
}
json::Value Server::hover(const std::string &path, int line, int col) {
auto it = results.find(path);
if (it == results.end()) return nullptr;
const SymRef *r = refAt(*it->second, path, line, col);
if (!r) return nullptr;
return json::Object{{"contents", json::Object{{"kind", "markdown"}, {"value", r->hover}}}};
}
json::Value Server::definition(const std::string &path, int line, int col) {
auto it = results.find(path);
if (it == results.end()) return nullptr;
const Analysis &a = *it->second;
const SymRef *r = refAt(a, path, line, col);
if (!r || r->defFile < 0 || r->defFile >= (int)a.files.size()) return nullptr;
int l = r->defLine - 1, c = r->defCol - 1;
return json::Object{{"uri", pathToUri(a.files[r->defFile].path)},
{"range", json::Object{{"start", json::Object{{"line", l}, {"character", c}}},
{"end", json::Object{{"line", l}, {"character", c}}}}}};
}
std::string Server::lineText(const std::string &path, int line) {
auto it = docs.find(path);
if (it == docs.end()) return "";
size_t start = 0;
for (int l = 1; l < line && start != std::string::npos; l++) {
start = it->second.find('\n', start);
if (start != std::string::npos) start++;
}
if (start == std::string::npos) return "";
return it->second.substr(start, it->second.find('\n', start) - start);
}
// LSP CompletionItemKind
enum { KMethod = 2, KFunction = 3, KField = 5, KVariable = 6, KModule = 9, KKeyword = 14, KSnippet = 15,
KConstant = 21, KStruct = 22, KTypeParam = 25 };
static json::Value item(const std::string &label, int kind, const std::string &detail = "", const std::string &doc = "") {
json::Object o{{"label", label}, {"kind", kind}};
if (!detail.empty()) o["detail"] = detail;
if (!doc.empty()) o["documentation"] = json::Object{{"kind", "markdown"}, {"value", doc}};
return o;
}
json::Value Server::completion(const std::string &path, int line, int col) {
json::Array items;
auto it = results.find(path);
std::shared_ptr<Analysis> a = it == results.end() ? nullptr : it->second;
// what's before the cursor: "name." or "name.partial"?
std::string text = lineText(path, line);
// columns count characters, but we only need the ASCII part right before the cursor
std::string before;
{
int c = 1;
size_t k = 0;
for (; k < text.size() && c < col; k++)
if (((unsigned char)text[k] & 0xC0) != 0x80) c++;
before = text.substr(0, k);
}
size_t end = before.size();
while (end > 0 && (isalnum((unsigned char)before[end - 1]) || before[end - 1] == '_')) end--;
bool afterDot = end > 0 && before[end - 1] == '.';
if (afterDot) {
size_t s = end - 1;
while (s > 0 && (isalnum((unsigned char)before[s - 1]) || before[s - 1] == '_' || before[s - 1] == ']' || before[s - 1] == '[')) s--;
std::string owner = before.substr(s, end - 1 - s);
if (size_t br = owner.find('['); br != std::string::npos) owner = owner.substr(0, br) + "[]";
if (!a) return json::Object{{"isIncomplete", false}, {"items", std::move(items)}};
// a module?
if (a->modules.count(owner)) {
std::set<std::string> seen;
for (const FnInfo &f : a->index.fns)
if (f.module == owner && seen.insert(f.name).second) items.push_back(item(f.name, KFunction, f.signature));
for (const StructIndex &s2 : a->index.structs)
if (s2.module == owner && seen.insert(s2.name).second) items.push_back(item(s2.name, KStruct, "struct " + s2.name));
return json::Object{{"isIncomplete", false}, {"items", std::move(items)}};
}
// a variable: find its type (the innermost one declared before the cursor)
bool element = owner.size() > 2 && owner.compare(owner.size() - 2, 2, "[]") == 0;
std::string name = element ? owner.substr(0, owner.size() - 2) : owner;
std::string type;
for (const VarInfo &v : a->index.vars)
if (v.name == name && v.line <= line && line <= v.fnEnd + 1) type = v.type;
if (element && type.rfind("[]", 0) == 0) type = type.substr(2);
if (element && type == "str") type = "char";
if (element && type.rfind("map[", 0) == 0) { // m[key]. -> the value type
int depth = 0;
for (size_t k = 3; k < type.size(); k++) {
if (type[k] == '[') depth++;
else if (type[k] == ']' && --depth == 0) {
type = type.substr(k + 1);
break;
}
}
}
if (type.rfind("ptr[", 0) == 0) type = type.substr(4, type.size() - 5); // fields through a pointer
if (type == "str") {
for (const BuiltinDoc &d : kStrMethods) items.push_back(item(d.name, d.name[0] == 'l' || d.name[0] == 'p' ? KField : KMethod, d.signature, d.doc));
} else if (type.rfind("[]", 0) == 0) {
for (const BuiltinDoc &d : kArrayMethods) items.push_back(item(d.name, std::string(d.name) == "len" || std::string(d.name) == "ptr" ? KField : KMethod, d.signature, d.doc));
} else if (type.rfind("map[", 0) == 0) {
for (const BuiltinDoc &d : kMapMethods) items.push_back(item(d.name, std::string(d.name) == "len" ? KField : KMethod, d.signature, d.doc));
} else if (!type.empty()) {
std::string mod, sname = type;
if (size_t dot = type.find('.'); dot != std::string::npos) mod = type.substr(0, dot), sname = type.substr(dot + 1);
for (const StructIndex &s2 : a->index.structs)
if (s2.name == sname && (mod.empty() || s2.module == mod))
for (auto &[fname, ftype] : s2.fields) items.push_back(item(fname, KField, ftype + " " + fname));
std::set<std::string> seen;
for (const FnInfo &f : a->index.fns) // methods: indexed as Struct.name
if (f.name.size() > sname.size() + 1 && f.name.compare(0, sname.size() + 1, sname + ".") == 0 &&
seen.insert(f.name).second)
items.push_back(item(f.name.substr(sname.size() + 1), KMethod, f.signature));
if (type.rfind("ptr", 0) == 0 || items.empty()) items.push_back(item("value", KField, "the value the pointer points to"));
}
return json::Object{{"isIncomplete", false}, {"items", std::move(items)}};
}
std::string prefix = before.substr(end);
for (const char *k : kKeywords) items.push_back(item(k, KKeyword));
for (const char *t : kTypeNames) items.push_back(item(t, KTypeParam, "type"));
for (const BuiltinDoc &d : kBuiltins) items.push_back(item(d.name, KFunction, d.signature, d.doc));
if (a) {
std::set<std::string> seen;
for (const VarInfo &v : a->index.vars) // variables of the function around the cursor
if (v.fnLine <= line && line <= v.fnEnd && v.line <= line && seen.insert(v.name).second)
items.push_back(item(v.name, KVariable, v.type + " " + v.name));
for (const FnInfo &f : a->index.fns)
if (f.module.empty() && seen.insert(f.name).second) items.push_back(item(f.name, KFunction, f.signature));
for (const StructIndex &s : a->index.structs)
if (s.module.empty() && seen.insert(s.name).second) items.push_back(item(s.name, KStruct, "struct " + s.name));
for (const std::string &m : a->modules) items.push_back(item(m, KModule, "module " + m));
// C names only once something is typed: headers declare thousands
if (a->c && !prefix.empty()) {
for (auto &[name, f] : a->c->fns)
if (name.rfind(prefix, 0) == 0 && f.unsupported.empty()) items.push_back(item(name, KFunction, "C: " + f.header));
for (auto &[name, k] : a->c->consts)
if (name.rfind(prefix, 0) == 0) items.push_back(item(name, KConstant, "C constant"));
for (auto &[name, s] : a->c->structs)
if (name.rfind(prefix, 0) == 0) items.push_back(item(name, KStruct, "C struct"));
}
}
return json::Object{{"isIncomplete", !prefix.empty()}, {"items", std::move(items)}};
}
json::Value Server::symbols(const std::string &path) {
json::Array out;
auto it = results.find(path);
if (it == results.end()) return out;
const Analysis &a = *it->second;
auto inDoc = [&](int file) { return file >= 0 && file < (int)a.files.size() && normalizePath(a.files[file].path) == path; };
auto range = [](int l1, int c1, int l2) {
return json::Object{{"start", json::Object{{"line", l1 - 1}, {"character", c1 - 1}}},
{"end", json::Object{{"line", std::max(l1, l2) - 1}, {"character", 0}}}};
};
for (const FnInfo &f : a.index.fns)
if (inDoc(f.file))
out.push_back(json::Object{{"name", f.name}, {"detail", f.signature}, {"kind", 12},
{"range", range(f.line, f.col, f.endLine + 1)}, {"selectionRange", range(f.line, f.col, f.line)}});
for (const StructIndex &s : a.index.structs)
if (inDoc(s.file)) {
json::Array kids;
for (auto &[n, t] : s.fields) kids.push_back(json::Object{{"name", n}, {"detail", t}, {"kind", 8},
{"range", range(s.line, s.col, s.line)}, {"selectionRange", range(s.line, s.col, s.line)}});
out.push_back(json::Object{{"name", s.name}, {"kind", 23}, {"range", range(s.line, s.col, s.line)},
{"selectionRange", range(s.line, s.col, s.line)}, {"children", std::move(kids)}});
}
return out;
}
// Inside `name(a, b|`: which function, and which argument.
json::Value Server::signatureHelp(const std::string &path, int line, int col) {
auto it = results.find(path);
std::shared_ptr<Analysis> a = it == results.end() ? nullptr : it->second;
std::string text;
for (int l = std::max(1, line - 5); l < line; l++) text += lineText(path, l) + "\n";
std::string cur = lineText(path, line);
text += cur.substr(0, std::min<size_t>(cur.size(), col - 1));
int depth = 0, commas = 0;
size_t k = text.size();
while (k > 0) {
char ch = text[--k];
if (ch == ')' || ch == ']') depth++;
else if (ch == '[') depth--;
else if (ch == '(') {
if (depth == 0) break;
depth--;
} else if (ch == ',' && depth == 0) commas++;
if (k == 0) return nullptr;
}
size_t e = k;
while (k > 0 && (isalnum((unsigned char)text[k - 1]) || text[k - 1] == '_' || text[k - 1] == '.')) k--;
std::string callee = text.substr(k, e - k);
if (callee.empty()) return nullptr;
std::string name = callee.substr(callee.rfind('.') == std::string::npos ? 0 : callee.rfind('.') + 1);
std::string mod = callee.find('.') == std::string::npos ? "" : callee.substr(0, callee.find('.'));
std::string label, doc;
std::vector<std::string> params;
if (a) {
for (const FnInfo &f : a->index.fns) {
bool method = f.name.size() > name.size() && f.name.compare(f.name.size() - name.size() - 1, name.size() + 1, "." + name) == 0;
if ((f.name == name && (f.module == mod || (mod.empty() && f.module.empty()) || !a->modules.count(mod))) ||
(method && !mod.empty() && !a->modules.count(mod))) // p.move( -> Point.move
label = f.signature, params = f.params;
}
if (label.empty())
for (const StructIndex &s : a->index.structs)
if (s.name == name) {
label = name + "(";
for (size_t i = 0; i < s.fields.size(); i++) {
params.push_back(s.fields[i].second + " " + s.fields[i].first);
label += (i ? ", " : "") + params.back();
}
label += ")";
}
if (label.empty() && a->c)
if (auto f = a->c->fns.find(name); f != a->c->fns.end()) {
label = name + "(";
for (size_t i = 0; i < f->second.params.size(); i++) {
params.push_back(f->second.params[i].show());
label += (i ? ", " : "") + params.back();
}
label += f->second.variadic ? ", ...)" : ")";
doc = "C function from " + f->second.header;
}
}
if (label.empty()) {
auto look = [&](const BuiltinDoc *f, const BuiltinDoc *l) {
for (; f != l && label.empty(); ++f)
if (name == f->name) label = f->signature, doc = f->doc;
};
look(std::begin(kBuiltins), std::end(kBuiltins));
if (!mod.empty()) {
look(std::begin(kArrayMethods), std::end(kArrayMethods));
look(std::begin(kStrMethods), std::end(kStrMethods));
look(std::begin(kMapMethods), std::end(kMapMethods));
}
}
if (label.empty()) return nullptr;
json::Array ps;
for (const std::string &p : params) ps.push_back(json::Object{{"label", p}});
json::Object sig{{"label", label}, {"parameters", std::move(ps)}};
if (!doc.empty()) sig["documentation"] = doc;
return json::Object{{"signatures", json::Array{std::move(sig)}}, {"activeSignature", 0}, {"activeParameter", commas}};
}
} // namespace
int runLanguageServer() { return Server().run(); }