The LuaJIT-first language server.
4.8 ms to diagnostics on a 63-line file · 14.6 MiB resident · one standalone binary.
lye is a language server for LuaJIT: inferred types, completion, hover,
declaration and definition, references, rename, document links, linked editing, monikers, document
and workspace symbols, signature help, type hierarchy, inlay hints, inline values, code actions and
lenses, semantic tokens and diagnostics. It is written in LuaJIT: it parses the engine's 3.0 syntax
backports and needs no Lua runtime, no lde, and no packages to install.
| Capability | lye | LuaLS | EmmyLua-RS |
|---|---|---|---|
LuaJIT FFI: ffi.cdef parsed into typed C declarations |
✅ | partial 1 | — |
| Generic classes and generic functions | ✅ | partial 2 | ✅ |
| Overloads with call-site signature narrowing | ✅ | partial 3 | ✅ |
| Type mismatches are LSP Errors by default | ✅ | config 4 | config 4 |
Dialect plugins: lowering, tokens, types, require completion |
✅ | partial 5 | — |
lde target/ module resolution |
✅ | — | — |
lye's type system is built for LuaJIT:
- inference from assignments, calls, tables and returns; a local's type evolves as it is reassigned
- flow narrowing for truthiness,
nilcomparisons,type(x)guards, discriminated unions and early returns - generic classes and functions with constraints; overloads are checked at the call site and narrowed in signature help
- LuaLS
---@annotations: classes, aliases, literal/union/never/consttypes, casts andas - type-level forms: intersections (
A & B),keyof, indexed access (T["k"]), conditional types (T extends U ? X : Y), mapped types ({ [K in keyof T]: U }) and template literal types - assignment-aware field narrowing, optional-chain (
?.) typing, and correlatedpcallvalues - LuaJIT's and Lua 5.1's standard library, from eighteen definition files, drives hover and completion
- FFI types are ordinary types, not
cdataboxes - type mismatches are LSP Errors and fail
lye --check; they are never downgraded to hints
LuaLS still tracks generics in #1861;
lye implements generics and overloads now, and neither competitor makes FFI-derived types
first-class.
---@generic T
---@param value T
---@return T
local function id(value) return value end
---@param n number
local function takesNumber(n) end
local s = id("lye") -- string
takesNumber(s) -- error: takesNumber expects number, got stringThe shipped ffi plugin parses every ffi.cdef block in a file:
- structs, unions, enums, typedefs, function pointers, arrays and bitfields
- fixed arrays as
ffi.array<T, N>, scalar pointers asffi.ptr<T>, 64-bit scalars asffi.size_t/ffi.int64_t/…, callbacks asffi.cb ffi.new,ffi.cast,ffi.typeof,ffi.loadandffi.Ctyped from the file's declarationsffi.typeofgives a typed constructor: calling it makes the C type, so a constructor already cached for a hot path stays a constructor- inline callbacks get the header's parameter types, and their returns are checked
- semantic tokens colour the C inside the cdef block
local ffi = require("ffi")
ffi.cdef[[
typedef unsigned long size_t;
typedef struct { double x, y; } Vec;
typedef size_t (*write_cb)(char *ptr, size_t size, void *userdata);
]]
local v = ffi.new("Vec") -- Vec
v.x = 1.5 -- ok
v.nope = 1 -- error: `nope` is not a field of Vec
local write = ffi.cast("write_cb", function(ptr, size, _)
return size * 2 -- ptr: ffi.ptr<ffi.char>, size: ffi.size_t
end)
local ints = ffi.new("int[4]") -- ffi.array<ffi.int, 4>
local first = ints[0] -- ffi.int!ffi turns it off; the same file still reads as plain Lua.
Measured on one machine with one harness; medians:
| lye | LuaLS | EmmyLua-RS | |
|---|---|---|---|
| keystroke → diagnostics (63-line fixture) | 4.78 ms | 201.73 ms | 503.37 ms |
| resident (63-line fixture) | 14.6 MiB | 35.0 MiB | 66.0 MiB |
| keystroke → diagnostics (2331-line file) | 82.6 ms | 207.52 ms | 514.06 ms |
| resident (2331-line file) | 42.4 MiB | 47.9 MiB | 76.9 MiB |
A workspace lists plugins in lye.json — a path, an installed module name, or !name to disable
a shipped plugin (patterns, formats, ffi). The file has a schema at
schemas/lye.schema.json; point $schema at it for editor completion
and validation:
{
"$schema": "https://raw.githubusercontent.com/lde-org/lye/master/schemas/lye.schema.json",
"plugins": ["./tools/moonscript.lua", "lye-plugin-moonscript", "!patterns"]
}A plugin answers one or more hooks:
| Hook | Adds |
|---|---|
lower |
edits that turn another language into Lua, with positions mapped back to the original |
tokens |
semantic tokens; a plugin's span wins over the built-in walk |
types |
classes, aliases and providers for calls, fields, parameters and callback returns |
complete |
module names inside require("...") |
A type a plugin contributes through types is indexed with the workspace's own annotations, so a
class or alias one file's plugin declares is a type another file can name — the same rule a
---@class written in the source follows.
The built-in lye-plugin-require supplies the require completion hook. A plugin's
extensions = { "moon" } makes lye check . walk a dialect's files, and an editor that supports
LSP dynamic registration receives those extensions as document selectors, so a .moon document gets
the same diagnostics, hover and completion as a .lua one; one plugin can carry syntax, colour,
types and autocomplete. packages/lye-plugin documents the interface.
lye is lde-first. lde run -C <package> resolves a module against
<package>/target/?.lua and <package>/target/?/init.lua, and target/ holds a link per
dependency. lye finds the nearest lde.json, resolves require through the same target layout,
indexes the types linked into target/, and offers those modules in completion — with nothing to
configure. A document resolves against its own package, so siblings, dependencies and monorepo
packages all work. A window may open several folders; each document is read against the folder
that contains it, and workspace symbols and pull diagnostics cover all of them.
For a project not laid out that way, lye.json can name search paths the way package.path does:
{ "requirePaths": ["lib/?.lua", "vendor/?/init.lua"] }A diagnostic has a stable code — unused-local, type, missing-field, operator,
redundant-return, deprecated, discard, and so on. A workspace can set a code's severity or
turn it off in lye.json, and a file can control a line or a range with a directive:
{
"diagnostics": {
"severity": { "redundant-return": "off", "undefined-global": "warning" },
"disable": ["visibility"]
}
}---@diagnostic disable-next-line: unused-local
local deliberate = 1
---@diagnostic disable
local quiet = 2
---@diagnostic enableerror, warning, hint and off are the severity names. A directive with no codes applies to
every diagnostic on the line or range. The editor and lye check read the same policy.
lye check [paths...] checks a tree in CI, handing each package to a worker (LYE_WORKERS=1 for
one). Results are cached under ./.lye; --cache <dir> or LYE_CACHE_DIR moves it, --cold and
LYE_CACHE=off disable reuse. lye init writes lye.json; lye extensions [dir] prints the
file extensions the workspace's plugins claim, which is what an editor wrapper needs to attach a
dialect's buffers.
lye format [paths...] lays out Lua files in place: indentation from the parser, one space
between the tokens of an expression, no trailing whitespace, one blank line between things and a
final newline. --check prints the files that would change and exits 1 without writing anything;
lye format - reads stdin and writes the result to stdout. .editorconfig indentation and
insert_final_newline are honoured, and a line inside a long string is left alone.
An editor that supports LSP pull diagnostics gets textDocument/diagnostic for the open document
and workspace/diagnostic for every Lua file in the workspace; either is a full check of the files
it names. Clients without workspace pull still publish the open document's diagnostics, and
lye check is the whole-tree pass for CI.
The lye-lsp package is also a library. main.lua is the executable; src/init.lua is what an
lde package gets from require("lye-lsp"), and it starts no server and reads no arguments. Add the
package as a dependency and drive the same check lye check runs:
{ "dependencies": { "lye-lsp": { "path": "../lye/packages/lye-lsp" } } }local lye = require("lye-lsp")
local reporter = lye.report.collect()
local isOk, err = lye.check.file("src/main.lua", nil, nil, reporter)
for _, finding in ipairs(reporter.collect) do
print(string.format("%s:%d:%d: %s", finding.path, finding.line, finding.column, finding.message))
endcheck.files checks a batch the way the command line does, and check.discover, check.expand and
check.extensions name the files and dialect extensions a run would use. A reporter from
report.collect() keeps findings as { path, line, column, severity, message } tables; the default
reporter prints them.
The editor packages use a standalone executable. Build it from a snapshot of the working tree:
tools/compile.sh # packages/lye-lsp/lye-lsp
tools/compile.sh --target linux-aarch64 --outfile lye-linux-aarch64The wrapper exists because lde compile -C packages/lye-lsp reads the package directory itself:
after lde test, generated fixtures under packages/lye-lsp/target/ are picked up as source and
the build fails. The wrapper copies the tracked tree (local edits included, ignored build output
excluded) to a temporary directory and compiles there, leaving your target/ alone. It needs
lde on PATH.
Install editors/zed/ as a dev extension.
Run npm install && npm run package in editors/vscode.
vim.lsp.config("lye", {
cmd = { "lye", "--stdio" },
root_markers = { "lde.json", "lye.json" },
filetypes = { "lua" }
})
vim.lsp.enable("lye")Speak LSP to lye --stdio.
Footnotes
-
LuaLS parses cdefs through the manual
Lua: Reload luajit ffi metacommand and maps 64-bit scalars tointeger; lye parses every block automatically and keeps the C types. ↩ -
Both servers expose configurable diagnostic severities; lye defaults type mismatches to Error. ↩ ↩2
-
LuaLS has plugin hooks for text/AST transforms and require resolution, but no unified lower/tokens/types/completion pipeline. ↩