diff --git a/Cargo.lock b/Cargo.lock index 2358bb9..5b4a083 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,12 +89,34 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "base64" version = "0.22.1" @@ -352,6 +374,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -854,6 +889,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tower-lsp", "wiremock", ] @@ -968,6 +1004,15 @@ dependencies = [ "semver", ] +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1012,6 +1057,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsp-types" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + [[package]] name = "mach2" version = "0.4.3" @@ -1083,12 +1141,45 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1227,6 +1318,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "regalloc2" version = "0.11.2" @@ -1316,7 +1416,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -1428,6 +1528,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sct" version = "0.7.1" @@ -1487,6 +1593,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1726,6 +1843,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower" version = "0.5.3" @@ -1754,7 +1885,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", ] @@ -1765,6 +1896,40 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" +[[package]] +name = "tower-lsp" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba052b54a6627628d9b3c34c176e7eda8359b7da9acd497b9f20998d118508" +dependencies = [ + "async-trait", + "auto_impl", + "bytes", + "dashmap", + "futures", + "httparse", + "lsp-types", + "memchr", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower 0.4.13", + "tower-lsp-macros", + "tracing", +] + +[[package]] +name = "tower-lsp-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tower-service" version = "0.3.3" @@ -1778,9 +1943,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1818,6 +1995,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1a73b87..219b6e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ http = ["dep:minreq"] cranelift = ["dep:cranelift-codegen", "dep:cranelift-frontend", "dep:cranelift-jit", "dep:cranelift-module", "dep:cranelift-native", "dep:cranelift-object", "dep:target-lexicon"] llvm = ["dep:inkwell"] tools = ["dep:tokio", "dep:reqwest"] +lsp = ["dep:tower-lsp", "dep:tokio"] [dependencies] logos = "0.16.1" @@ -38,8 +39,9 @@ cranelift-object = { version = "0.116", optional = true } target-lexicon = { version = "0.12", optional = true } inkwell = { version = "0.5", features = ["llvm18-0"], optional = true } minreq = { version = "2.14", default-features = false, features = ["https-rustls"], optional = true } -tokio = { version = "1", features = ["rt", "macros", "process", "io-util", "sync"], optional = true } +tokio = { version = "1", features = ["rt", "macros", "process", "io-util", "io-std", "sync"], optional = true } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"], optional = true } +tower-lsp = { version = "0.20", optional = true } clap = { version = "4", features = ["derive"] } fastrand = "2" regex = "1" diff --git a/editors/nvim/README.md b/editors/nvim/README.md new file mode 100644 index 0000000..de1e1b5 --- /dev/null +++ b/editors/nvim/README.md @@ -0,0 +1,155 @@ +# ilo.nvim — Neovim plugin for ilo + +Syntax highlighting, filetype detection, and LSP client configuration for the [ilo programming language](https://ilo-lang.ai). + +## Features + +- Filetype detection for `.ilo` files +- Syntax highlighting (keywords, types, builtins, operators, comments, strings, numbers) +- Filetype-local settings (comment string, 2-space indent) +- LSP client wiring for `ilo lsp` + +## Installation + +### lazy.nvim + +```lua +{ + "ilo-lang/ilo", + -- point at the nvim subdirectory + dir = vim.fn.expand("~/.local/share/nvim/site/pack/ilo/start/ilo-nvim"), + ft = "ilo", + config = function() + require("ilo.lsp").setup() + end, +} +``` + +Or install from the GitHub repo root with a subdirectory path: + +```lua +{ + "ilo-lang/ilo", + branch = "main", + -- lazy.nvim doesn't natively support subdirs; clone manually (see below) +} +``` + +Because this plugin lives inside `editors/nvim/` of the main ilo repo, the simplest approach is to symlink or copy the directory: + +```sh +# Symlink into your Neovim runtime path +ln -s /path/to/ilo/editors/nvim ~/.local/share/nvim/site/pack/ilo/start/ilo-nvim +``` + +### packer.nvim + +```lua +use { + "~/.local/share/nvim/site/pack/ilo/start/ilo-nvim", + ft = "ilo", + config = function() + require("ilo.lsp").setup() + end, +} +``` + +### Manual + +Copy or symlink `editors/nvim/` to a directory on your `runtimepath`: + +```sh +cp -r editors/nvim ~/.local/share/nvim/site/pack/ilo/start/ilo-nvim +# or +ln -s $(pwd)/editors/nvim ~/.local/share/nvim/site/pack/ilo/start/ilo-nvim +``` + +## LSP setup + +### Without nvim-lspconfig (built-in `vim.lsp.start`) + +The plugin ships a small wrapper around `vim.lsp.start`. Call `setup()` once in your config: + +```lua +require("ilo.lsp").setup() +``` + +Options: + +```lua +require("ilo.lsp").setup({ + -- Path to the ilo binary (default: "ilo", must be on $PATH) + cmd = "ilo", + -- Or provide the full command table: + -- cmd = { "/usr/local/bin/ilo", "lsp" }, + + -- Files that mark a project root (default: { ".git" }) + root_markers = { ".git", "*.ilo" }, + + -- Optional: pass nvim-cmp or blink.nvim capabilities + capabilities = require("cmp_nvim_lsp").default_capabilities(), + + -- Optional: on_attach callback + on_attach = function(client, bufnr) + -- your keymaps here + end, +}) +``` + +### With nvim-lspconfig (if a config is added in future) + +```lua +require("lspconfig").ilo.setup({ + cmd = { "ilo", "lsp" }, + filetypes = { "ilo" }, + root_dir = require("lspconfig.util").root_pattern(".git"), +}) +``` + +## Example keybindings + +Add these inside an `on_attach` callback or a `LspAttach` autocmd: + +```lua +vim.api.nvim_create_autocmd("LspAttach", { + callback = function(ev) + local buf = ev.buf + local opts = { buffer = buf, silent = true } + vim.keymap.set("n", "K", vim.lsp.buf.hover, opts) + vim.keymap.set("n", "gd", vim.lsp.buf.definition, opts) + vim.keymap.set("n", "gr", vim.lsp.buf.references, opts) + vim.keymap.set("n", "rn", vim.lsp.buf.rename, opts) + vim.keymap.set("n", "ca", vim.lsp.buf.code_action, opts) + vim.keymap.set("n", "[d", vim.diagnostic.goto_prev, opts) + vim.keymap.set("n", "]d", vim.diagnostic.goto_next, opts) + end, +}) +``` + +## Screenshot + + + +## Language overview + +ilo uses **prefix notation** — the operator comes first: + +```ilo +-- Function: double a number +dbl x:n>n;*x 2 + +-- Guard (early return if condition is true) +cls sp:n>t;>=sp 1000 "gold";>=sp 500 "silver";"bronze" + +-- While loop +wh-sum>n;i=0;s=0;wh n;s=0;@i 0..5{s=+s i};+s 0 + +-- Record type +type point{x:n;y:n} +make-pt>n;p=pt x:3 y:4;p.x +``` + +See the full [language spec](https://github.com/ilo-lang/ilo/blob/main/SPEC.md) for details. diff --git a/editors/nvim/after/ftplugin/ilo.lua b/editors/nvim/after/ftplugin/ilo.lua new file mode 100644 index 0000000..da3f8b0 --- /dev/null +++ b/editors/nvim/after/ftplugin/ilo.lua @@ -0,0 +1,5 @@ +-- ilo filetype settings +vim.bo.commentstring = "-- %s" +vim.bo.tabstop = 2 +vim.bo.shiftwidth = 2 +vim.bo.expandtab = true diff --git a/editors/nvim/ftdetect/ilo.lua b/editors/nvim/ftdetect/ilo.lua new file mode 100644 index 0000000..3b45fcd --- /dev/null +++ b/editors/nvim/ftdetect/ilo.lua @@ -0,0 +1,5 @@ +vim.filetype.add({ + extension = { + ilo = "ilo", + }, +}) diff --git a/editors/nvim/lua/ilo/lsp.lua b/editors/nvim/lua/ilo/lsp.lua new file mode 100644 index 0000000..4d6baaf --- /dev/null +++ b/editors/nvim/lua/ilo/lsp.lua @@ -0,0 +1,27 @@ +local M = {} + +--- Configure the ilo LSP client. +--- +--- @param opts? table Options: +--- - cmd: string|string[] Path to the ilo binary (default: "ilo") +--- - root_markers: string[] Files that mark a project root (default: {".git"}) +function M.setup(opts) + opts = opts or {} + local cmd = opts.cmd or "ilo" + local root_markers = opts.root_markers or { ".git" } + + vim.api.nvim_create_autocmd("FileType", { + pattern = "ilo", + callback = function(ev) + vim.lsp.start({ + name = "ilo", + cmd = type(cmd) == "string" and { cmd, "lsp" } or cmd, + root_dir = vim.fs.root(ev.buf, root_markers), + capabilities = opts.capabilities, + on_attach = opts.on_attach, + }) + end, + }) +end + +return M diff --git a/editors/nvim/syntax/ilo.vim b/editors/nvim/syntax/ilo.vim new file mode 100644 index 0000000..55e8edb --- /dev/null +++ b/editors/nvim/syntax/ilo.vim @@ -0,0 +1,93 @@ +" Vim syntax file for ilo +" Language: ilo +" Maintainer: ilo-lang +" URL: https://github.com/ilo-lang/ilo + +if exists("b:current_syntax") + finish +endif + +let b:current_syntax = "ilo" + +" Comments: -- to end of line +syntax match iloComment "--.*$" + +" Strings: "..." (no interpolation at lex level — {} inside strings is literal) +syntax region iloString start=/"/ skip=/\\"/ end=/"/ contains=iloEscape +syntax match iloEscape /\\[ntr"\\]/ contained + +" Numbers: integers and floats, including negative +syntax match iloNumber "-\?\d\+\(\.\d\+\)\?" + +" Boolean literals +syntax keyword iloBoolean true false + +" Nil literal +syntax keyword iloNil nil + +" Keywords +syntax keyword iloKeyword type tool use with timeout retry + +" Control flow +syntax keyword iloControl wh brk cnt ret +syntax match iloControl "@" + +" Type constructors (standalone uppercase letters used as types) +syntax match iloType "\<\(L\|R\|F\|O\|M\|S\)\>" + +" Primitive type annotations (after : in parameter/return position) +" Match :n :t :b and >n >t >b patterns +syntax match iloType ":\(n\|t\|b\|number\|text\|bool\)\>" +syntax match iloType ">\(n\|t\|b\|number\|text\|bool\)\>" + +" Operators: multi-char first (greedy) +syntax match iloOperator ">=" +syntax match iloOperator "<=" +syntax match iloOperator "!=" +syntax match iloOperator "+=" +syntax match iloOperator ">>" +syntax match iloOperator "??" +syntax match iloOperator "\.\." +syntax match iloOperator "\.?" + +" Single-char operators (excludes ? which is highlighted as iloControl) +syntax match iloOperator "[-+*/><&|!^~$]" + +" Builtins — all canonical names from builtins.rs +syntax keyword iloBuiltin str num abs flr cel rou min max mod sum avg +syntax keyword iloBuiltin len hd tl rev srt slc unq flat has spl cat +syntax keyword iloBuiltin map flt fld grp rnd now +syntax keyword iloBuiltin rd rdl rdb wr wrl prnt env +syntax keyword iloBuiltin trm fmt rgx +syntax keyword iloBuiltin jpth jdmp jpar +syntax keyword iloBuiltin get post +syntax keyword iloBuiltin mmap mget mset mhas mkeys mvals mdel + +" Function declarations: identifier at start of line (before params) +" ilo functions start at column 0 followed by space or param/return syntax +syntax match iloFunction "^\([a-z][a-z0-9]*\(-[a-z0-9]\+\)*\)\ze\s*[a-z:>]" +syntax match iloFunction "^\([a-z][a-z0-9]*\(-[a-z0-9]\+\)*\)\ze>" + +" Type definitions: identifier after 'type' keyword +syntax match iloTypeDef "\ret`) + - Function definitions + - Control flow keywords (`wh`, `brk`, `cnt`, `ret`, `@`) + - Result operators (`~` ok, `^` err) + - All operators including `>=`, `<=`, `!=`, `+=`, `>>`, `??` + - Built-in functions (`len`, `str`, `map`, `flt`, etc.) + - Type constructors (`L`, `R`, `F`, `O`, `M`, `S`) and primitives (`n`, `t`, `b`) + - Reserved words highlighted as invalid + +- **LSP integration** — connects to `ilo lsp` for diagnostics, hover, and completions (requires ilo >= 0.10.0 with LSP support) + +## Requirements + +- `ilo` installed and on `PATH` (for LSP features) +- Install ilo: `cargo install ilo` or `npx ilo-lang` + +## File Extension + +`.ilo` files are automatically recognised. + +## Building + +```bash +npm install +npm run compile +``` + +## Packaging + +```bash +npm install -g @vscode/vsce +vsce package +``` diff --git a/editors/vscode/language-configuration.json b/editors/vscode/language-configuration.json new file mode 100644 index 0000000..78c39a3 --- /dev/null +++ b/editors/vscode/language-configuration.json @@ -0,0 +1,27 @@ +{ + "comments": { + "lineComment": "--" + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"" } + ], + "surroundingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"" } + ], + "wordPattern": "[a-z][a-z0-9]*(-[a-z0-9]+)*", + "indentationRules": { + "increaseIndentPattern": "\\{[^}]*$", + "decreaseIndentPattern": "^\\s*\\}" + } +} diff --git a/editors/vscode/package.json b/editors/vscode/package.json new file mode 100644 index 0000000..ec850cc --- /dev/null +++ b/editors/vscode/package.json @@ -0,0 +1,53 @@ +{ + "name": "ilo-lang", + "displayName": "ilo", + "description": "Language support for ilo — a token-optimised programming language for AI agents", + "version": "0.1.0", + "publisher": "ilo-lang", + "icon": "icons/ilo-icon.png", + "repository": { + "type": "git", + "url": "https://github.com/ilo-lang/ilo" + }, + "license": "MIT", + "engines": { + "vscode": "^1.75.0" + }, + "categories": ["Programming Languages"], + "activationEvents": ["onLanguage:ilo"], + "main": "./out/extension.js", + "contributes": { + "languages": [ + { + "id": "ilo", + "aliases": ["ilo", "ilo-lang"], + "extensions": [".ilo"], + "configuration": "./language-configuration.json", + "icon": { + "light": "./icons/ilo-icon.png", + "dark": "./icons/ilo-icon.png" + } + } + ], + "grammars": [ + { + "language": "ilo", + "scopeName": "source.ilo", + "path": "./syntaxes/ilo.tmLanguage.json" + } + ] + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "dependencies": { + "vscode-languageclient": "^9.0.1" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.75.0", + "typescript": "^5.0.0" + } +} diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts new file mode 100644 index 0000000..1570052 --- /dev/null +++ b/editors/vscode/src/extension.ts @@ -0,0 +1,39 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { + LanguageClient, + LanguageClientOptions, + ServerOptions, +} from 'vscode-languageclient/node'; + +let client: LanguageClient | undefined; + +export function activate(context: vscode.ExtensionContext): void { + const serverOptions: ServerOptions = { + command: 'ilo', + args: ['lsp'], + }; + + const clientOptions: LanguageClientOptions = { + documentSelector: [{ scheme: 'file', language: 'ilo' }], + synchronize: { + fileEvents: vscode.workspace.createFileSystemWatcher('**/*.ilo'), + }, + }; + + client = new LanguageClient( + 'ilo', + 'ilo Language Server', + serverOptions, + clientOptions + ); + + client.start(); +} + +export function deactivate(): Thenable | undefined { + if (!client) { + return undefined; + } + return client.stop(); +} diff --git a/editors/vscode/syntaxes/ilo.tmLanguage.json b/editors/vscode/syntaxes/ilo.tmLanguage.json new file mode 100644 index 0000000..6290e09 --- /dev/null +++ b/editors/vscode/syntaxes/ilo.tmLanguage.json @@ -0,0 +1,239 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "ilo", + "scopeName": "source.ilo", + "patterns": [ + { "include": "#comment" }, + { "include": "#string" }, + { "include": "#number" }, + { "include": "#boolean" }, + { "include": "#nil" }, + { "include": "#type-declaration" }, + { "include": "#tool-declaration" }, + { "include": "#use-declaration" }, + { "include": "#function-definition" }, + { "include": "#control-flow" }, + { "include": "#result-operators" }, + { "include": "#type-annotations" }, + { "include": "#operators" }, + { "include": "#builtins" }, + { "include": "#keywords" }, + { "include": "#identifier" } + ], + "repository": { + "comment": { + "name": "comment.line.double-dash.ilo", + "match": "--[^\\n]*" + }, + + "string": { + "name": "string.quoted.double.ilo", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.ilo", + "match": "\\\\[ntr\"\\\\]" + }, + { + "name": "punctuation.definition.template-expression.ilo", + "match": "\\{[^}]*\\}" + } + ] + }, + + "number": { + "name": "constant.numeric.ilo", + "match": "-?[0-9]+(\\.[0-9]+)?" + }, + + "boolean": { + "name": "constant.language.boolean.ilo", + "match": "\\b(true|false)\\b" + }, + + "nil": { + "name": "constant.language.nil.ilo", + "match": "\\bnil\\b" + }, + + "type-declaration": { + "comment": "type Name{field:type;...}", + "patterns": [ + { + "match": "\\btype\\b\\s+([a-z][a-z0-9]*(?:-[a-z0-9]+)*)", + "captures": { + "0": { "name": "meta.type-declaration.ilo" }, + "1": { "name": "entity.name.type.ilo" } + } + }, + { + "name": "keyword.other.ilo", + "match": "\\btype\\b" + } + ] + }, + + "tool-declaration": { + "comment": "tool name\"description\" params>return timeout:n,retry:n", + "patterns": [ + { + "match": "\\btool\\b\\s+([a-z][a-z0-9]*(?:-[a-z0-9]+)*)", + "captures": { + "0": { "name": "meta.tool-declaration.ilo" }, + "1": { "name": "entity.name.function.ilo" } + } + }, + { + "name": "keyword.other.ilo", + "match": "\\btool\\b" + }, + { + "name": "keyword.other.ilo", + "match": "\\b(timeout|retry)\\b" + } + ] + }, + + "use-declaration": { + "match": "\\b(use)\\b", + "captures": { + "1": { "name": "keyword.control.import.ilo" } + } + }, + + "function-definition": { + "comment": "name params:type...>rettype;body — function name at start of declaration", + "match": "^([a-z][a-z0-9]*(?:-[a-z0-9]+)*)(?=\\s+[a-z_]|>|\\()", + "captures": { + "1": { "name": "entity.name.function.ilo" } + } + }, + + "control-flow": { + "patterns": [ + { + "name": "keyword.control.ilo", + "match": "\\b(wh|brk|cnt|ret)\\b" + }, + { + "comment": "@ is foreach operator", + "name": "keyword.control.ilo", + "match": "@" + }, + { + "comment": "with keyword for record update", + "name": "keyword.control.ilo", + "match": "\\bwith\\b" + } + ] + }, + + "result-operators": { + "patterns": [ + { + "comment": "~ is ok/result-wrap operator", + "name": "keyword.operator.result.ok.ilo", + "match": "~" + }, + { + "comment": "^ is err/result-wrap operator", + "name": "keyword.operator.result.err.ilo", + "match": "\\^" + } + ] + }, + + "type-annotations": { + "comment": "Type names after : in param/field definitions and in type position", + "patterns": [ + { + "comment": "Uppercase type constructors: L, R, F, O, M, S", + "name": "support.type.ilo", + "match": "\\b[LRFOMS]\\b" + }, + { + "comment": "Primitive types: n (number), t (text), b (bool)", + "name": "support.type.primitive.ilo", + "match": "(?<=:)\\s*[ntb](?=[\\s;>\\{\\}\\[\\],)]|$)" + }, + { + "comment": "Type variable (single lowercase letter, not n/t/b, in type context)", + "name": "support.type.variable.ilo", + "match": "(?<=:)\\s*[a-z](?=[\\s;>\\{\\}\\[\\],)]|$)" + }, + { + "comment": "Underscore wildcard type", + "name": "support.type.ilo", + "match": "\\b_\\b" + } + ] + }, + + "operators": { + "patterns": [ + { + "comment": "Multi-char operators (must be before single-char)", + "name": "keyword.operator.ilo", + "match": ">=|<=|!=|\\+=|>>|\\?\\?" + }, + { + "comment": "Safe field navigation", + "name": "keyword.operator.ilo", + "match": "\\.\\?" + }, + { + "comment": "Range operator", + "name": "keyword.operator.range.ilo", + "match": "\\.\\." + }, + { + "comment": "Single-char operators", + "name": "keyword.operator.ilo", + "match": "[+\\-*/><&|!?]" + }, + { + "comment": "Dollar sign (alias for get)", + "name": "keyword.operator.ilo", + "match": "\\$" + }, + { + "comment": "Equality / assignment", + "name": "keyword.operator.assignment.ilo", + "match": "==|(?=])=" + } + ] + }, + + "builtins": { + "comment": "Built-in functions", + "patterns": [ + { + "name": "support.function.builtin.ilo", + "match": "\\b(len|str|num|abs|min|max|mod|flr|cel|rnd|now|get|post|env|rd|rdl|rdb|wr|wrl|trm|spl|fmt|cat|has|hd|tl|rev|srt|unq|slc|jpth|jdmp|prnt|jpar|grp|flat|sum|avg|rgx|mmap|mget|mset|mhas|mkeys|mvals|mdel|map|flt|fld)\\b" + }, + { + "comment": "Long-form builtin aliases", + "name": "support.function.builtin.ilo", + "match": "\\b(floor|ceil|round|random|string|number|length|head|tail|reverse|sort|slice|unique|filter|fold|flatten|concat|contains|group|average|print|trim|split|format|regex|read|readlines|readbuf|write|writelines)\\b" + } + ] + }, + + "keywords": { + "comment": "Reserved words that should be highlighted as errors if used", + "patterns": [ + { + "name": "invalid.illegal.reserved.ilo", + "match": "\\b(if|return|let|fn|def|var|const)\\b" + } + ] + }, + + "identifier": { + "comment": "Regular identifiers — lowercase with hyphens", + "name": "variable.other.ilo", + "match": "[a-z][a-z0-9]*(?:-[a-z0-9]+)*" + } + } +} diff --git a/editors/vscode/test/sample.ilo b/editors/vscode/test/sample.ilo new file mode 100644 index 0000000..d051201 --- /dev/null +++ b/editors/vscode/test/sample.ilo @@ -0,0 +1,157 @@ +-- ilo sample file for syntax highlighting testing +-- Comments: everything after -- is a comment + +-- === Type declarations === +type point{x:n;y:n} +type person{name:t;age:n;active:b} +type color-tag{label:t;hex:t;score:n} + +-- === Tool declarations === +tool get-user"Retrieve user by ID" uid:t>R profile t timeout:5,retry:2 +tool send-email"Send notification email" to:t subject:t body:t>R _ t timeout:10,retry:1 + +-- === Use / imports === +use "math.ilo" +use "utils.ilo" [fmt-date parse-id] + +-- === Simple functions === +dbl x:n>n;*x 2 +inc x:n>n;+x 1 +greet name:t>t;+"hello " name + +-- === Multiple params === +add a:n b:n>n;+a b +clamp val:n lo:n hi:n>n;val hi hi;+val 0 + +-- === Boolean / nil / optional === +is-positive x:n>b;>x 0 +maybe-val>O n;nil +safe-val x:O n>n;??x 0 + +-- === Lists === +first-elem xs:L n>n;xs.0 +list-sum xs:L n>n;sum xs +list-len xs:L n>n;len xs +make-list>L n;[1 2 3] +range-sum>n;s=0;@i 0..5{s=+s i};s + +-- === Records: construct, access, update, destructure === +make-pt>n;p=point x:3 y:4;p.x +update-pt>n;p=point x:1 y:2;q=p with x:99;q.x +destruct>n;p=point x:7 y:8;{x;y}=p;+x y + +-- === Result types and error handling === +safe-div a:n b:n>R n t + =b 0{ret ^"division by zero"} + ~(/a b) + +-- Auto-unwrap +compute a:n b:n>R n t + r=safe-div! a b + ~r + +-- === Match expression === +classify sp:n>t;>=sp 1000 "gold";>=sp 500 "silver";"bronze" + +grade x:n>t + ?x{ + 100:"perfect" + 0:"zero" + _:"other" + } + +-- Result match +handle-result r:R n t>t + ?r{ + ~v:"ok: " + ^e:^+"error: " e + } + +-- === Guards (braceless and braced) === +abs-val x:n>n;>=x 0 x;-0 x +clamp2 x:n>n;<=x 0{ret 0};>=x 100{ret 100};+x 0 + +-- Ternary +sign x:n>t;>x 0{"pos"}{"non-pos"} + +-- === While loop === +count-to n:n>n + i=0 + wh n + @x xs{>=x 10{ret x}} + 0 + +-- === Pipe operator === +process x:n>t + x>>dbl>>inc>>str + +-- === Higher-order functions === +apply f:F n n x:n>n;f x +double-all xs:L n>L n;map dbl xs +evens xs:L n>L n;flt (>0) xs + +-- === Map operations === +scores>M t n + m=mmap + m=mset m "alice" 99 + m=mset m "bob" 87 + mget m "alice" + +-- === String operations === +join-words ws:L t>t;cat ws " " +split-csv s:t>L t;spl s "," +trim-ws s:t>t;trm s +format-msg name:t score:n>t;fmt "Player {} scored {}" name score + +-- === HTTP calls === +fetch-data url:t>R t t;get url +post-json url:t body:t>R t t;post url body + +-- === File I/O === +read-file path:t>R t t;rd path +write-file path:t content:t>R t t;wr path content + +-- === JSON === +parse-json s:t>R _ t;jpar s +dump-json v:_>t;jdmp v +path-lookup json:t path:t>R t t;jpth json path + +-- === Builtins showcase === +math-ops x:n>n + a=abs x + b=flr x + c=cel x + d=min a b + e=max c d + +d e + +list-ops xs:L n>L n + s=srt xs + r=rev s + u=unq r + slc u 0 3 + +-- === Error propagation (~ and ^) === +inner x:n>R n t;~x +outer x:n>R n t;d=inner! x;~d + +-- === Recursive functions === +fac n:n>n;<=n 1 1;r=fac -n 1;*n r +fib n:n>n;<=n 1 n;a=fib -n 1;b=fib -n 2;+a b + +-- === Type variables (generics) === +identity x:a>a;x +wrap-list x:a>L a;[x] + +-- === Reserved words (should be highlighted as invalid) === +-- if let fn def var const return +-- (these are in comments so they won't cause parse errors) + +-- === Nil-coalesce and safe field access === +with-default x:O n>n;x??0 +chain-access obj:_>_ + obj.?name??"unknown" diff --git a/editors/vscode/tsconfig.json b/editors/vscode/tsconfig.json new file mode 100644 index 0000000..b59323d --- /dev/null +++ b/editors/vscode/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./out", + "rootDir": "./src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", ".vscode-test"] +} diff --git a/src/cli/args.rs b/src/cli/args.rs index cfb2995..90cb0c5 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -75,6 +75,10 @@ pub enum Cmd { /// Print version. Version, + + /// Start the Language Server Protocol server (stdio). + #[cfg(feature = "lsp")] + Lsp, } // ── Run ──────────────────────────────────────────────────────────────────────── diff --git a/src/lib.rs b/src/lib.rs index 54cbb2d..284af64 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,8 @@ pub mod diagnostic; pub mod graph; pub mod interpreter; pub mod lexer; +#[cfg(feature = "lsp")] +pub mod lsp; pub mod parser; pub mod tools; pub mod verify; diff --git a/src/lsp/mod.rs b/src/lsp/mod.rs new file mode 100644 index 0000000..de4ab03 --- /dev/null +++ b/src/lsp/mod.rs @@ -0,0 +1,775 @@ +//! LSP server for ilo. +//! +//! Implements Language Server Protocol over stdio using tower-lsp. +//! Provides: diagnostics on change, hover, go-to-definition, completions, +//! and document symbols. + +use std::collections::HashMap; +use std::sync::Mutex; + +use tower_lsp::jsonrpc::Result as LspResult; +use tower_lsp::lsp_types::*; +use tower_lsp::{Client, LanguageServer, LspService, Server}; + +use crate::ast::{self, Decl, Expr, Program, Span, Spanned, Stmt, Type}; +use crate::lexer; +use crate::parser; +use crate::verify; + +// ── Builtin catalogue (name, signature, description) ───────────────────────── + +struct BuiltinInfo { + name: &'static str, + sig: &'static str, + desc: &'static str, +} + +const BUILTIN_INFO: &[BuiltinInfo] = &[ + BuiltinInfo { name: "len", sig: "len list_or_text → n", desc: "Length of a list, map, or text" }, + BuiltinInfo { name: "str", sig: "str n → t", desc: "Convert number to text" }, + BuiltinInfo { name: "num", sig: "num t → R n t", desc: "Parse text as number" }, + BuiltinInfo { name: "abs", sig: "abs n → n", desc: "Absolute value" }, + BuiltinInfo { name: "flr", sig: "flr n → n", desc: "Floor (round down)" }, + BuiltinInfo { name: "cel", sig: "cel n → n", desc: "Ceiling (round up)" }, + BuiltinInfo { name: "rou", sig: "rou n → n", desc: "Round to nearest integer" }, + BuiltinInfo { name: "min", sig: "min n n → n", desc: "Minimum of two numbers" }, + BuiltinInfo { name: "max", sig: "max n n → n", desc: "Maximum of two numbers" }, + BuiltinInfo { name: "mod", sig: "mod n n → n", desc: "Modulo" }, + BuiltinInfo { name: "sum", sig: "sum L n → n", desc: "Sum all numbers in a list" }, + BuiltinInfo { name: "avg", sig: "avg L n → n", desc: "Average of a list" }, + BuiltinInfo { name: "hd", sig: "hd list_or_text → any", desc: "First element" }, + BuiltinInfo { name: "tl", sig: "tl list_or_text → list_or_text", desc: "All but first element" }, + BuiltinInfo { name: "rev", sig: "rev list_or_text → list_or_text", desc: "Reverse" }, + BuiltinInfo { name: "srt", sig: "srt list_or_text → list_or_text", desc: "Sort (or srt fn list)" }, + BuiltinInfo { name: "slc", sig: "slc list_or_text n n → list_or_text", desc: "Slice from start to end index" }, + BuiltinInfo { name: "unq", sig: "unq list_or_text → list_or_text", desc: "Remove duplicates" }, + BuiltinInfo { name: "flat", sig: "flat L → L", desc: "Flatten one level" }, + BuiltinInfo { name: "has", sig: "has list_or_text any → b", desc: "Test membership" }, + BuiltinInfo { name: "spl", sig: "spl t t → L t", desc: "Split text by delimiter" }, + BuiltinInfo { name: "cat", sig: "cat L t t → t", desc: "Join list of text with separator" }, + BuiltinInfo { name: "map", sig: "map fn list → list", desc: "Apply function to each element" }, + BuiltinInfo { name: "flt", sig: "flt fn list → list", desc: "Filter list by predicate" }, + BuiltinInfo { name: "fld", sig: "fld fn list any → any", desc: "Fold/reduce list" }, + BuiltinInfo { name: "grp", sig: "grp fn list → map", desc: "Group list by key function" }, + BuiltinInfo { name: "rnd", sig: "rnd → n", desc: "Random number [0, 1)" }, + BuiltinInfo { name: "now", sig: "now → n", desc: "Current Unix timestamp" }, + BuiltinInfo { name: "rd", sig: "rd t → R ? t", desc: "Read file (rd path [fmt])" }, + BuiltinInfo { name: "rdl", sig: "rdl t → R L t t", desc: "Read file as lines" }, + BuiltinInfo { name: "rdb", sig: "rdb t t → R ? t", desc: "Parse string buffer" }, + BuiltinInfo { name: "wr", sig: "wr t t → R t t", desc: "Write text to file" }, + BuiltinInfo { name: "wrl", sig: "wrl t L t → R t t", desc: "Write lines to file" }, + BuiltinInfo { name: "prnt", sig: "prnt any → any", desc: "Print and return value" }, + BuiltinInfo { name: "env", sig: "env t → R t t", desc: "Read environment variable" }, + BuiltinInfo { name: "trm", sig: "trm t → t", desc: "Trim whitespace" }, + BuiltinInfo { name: "fmt", sig: "fmt t ... → t", desc: "Format string with substitutions" }, + BuiltinInfo { name: "rgx", sig: "rgx t t → L t", desc: "Regex match all captures" }, + BuiltinInfo { name: "jpth", sig: "jpth t t → R t t", desc: "JSON path query" }, + BuiltinInfo { name: "jdmp", sig: "jdmp any → t", desc: "Serialize to JSON string" }, + BuiltinInfo { name: "jpar", sig: "jpar t → R ? t", desc: "Parse JSON string" }, + BuiltinInfo { name: "get", sig: "get t → R t t", desc: "HTTP GET request" }, + BuiltinInfo { name: "post", sig: "post t t → R t t", desc: "HTTP POST request" }, + BuiltinInfo { name: "mmap", sig: "mmap → M t t", desc: "Create empty map" }, + BuiltinInfo { name: "mget", sig: "mget map t → O any", desc: "Get map value by key" }, + BuiltinInfo { name: "mset", sig: "mset map t any → map", desc: "Set map key to value" }, + BuiltinInfo { name: "mhas", sig: "mhas map t → b", desc: "Test if map has key" }, + BuiltinInfo { name: "mkeys", sig: "mkeys map → L t", desc: "Get all map keys" }, + BuiltinInfo { name: "mvals", sig: "mvals map → list", desc: "Get all map values" }, + BuiltinInfo { name: "mdel", sig: "mdel map t → map", desc: "Delete key from map" }, +]; + +/// All keywords that may appear at the start of a declaration or statement. +const KEYWORDS: &[&str] = &[ + "type", "tool", "use", "with", "alias", + "ret", "brk", "cnt", "wh", + "true", "false", "nil", +]; + +/// All type names for completion after `:`. +const TYPE_NAMES: &[&str] = &["n", "t", "b", "_", "L", "R", "M", "S", "F", "O"]; + +// ── Span ↔ LSP Position conversion ─────────────────────────────────────────── + +/// Convert a byte offset into (line, col) — both zero-based. +fn offset_to_position(source: &str, offset: usize) -> Position { + let clamped = offset.min(source.len()); + let before = &source[..clamped]; + let line = before.bytes().filter(|&b| b == b'\n').count(); + let col = before + .rfind('\n') + .map(|p| clamped - p - 1) + .unwrap_or(clamped); + Position::new(line as u32, col as u32) +} + +fn span_to_range(source: &str, span: Span) -> Range { + Range::new( + offset_to_position(source, span.start), + offset_to_position(source, span.end), + ) +} + +/// Convert an LSP position to a byte offset in the source. +fn position_to_offset(source: &str, pos: Position) -> usize { + let mut line = 0u32; + let mut offset = 0usize; + for (i, c) in source.char_indices() { + if line == pos.line { + offset = i + (pos.character as usize).min(source[i..].len()); + break; + } + if c == '\n' { + line += 1; + } + offset = i + c.len_utf8(); + } + // If we iterated all chars and are still on the right line, offset is at end + offset.min(source.len()) +} + +/// Extract the identifier (or partial identifier) at a given byte offset. +fn ident_at(source: &str, offset: usize) -> Option<(String, usize)> { + if offset > source.len() { + return None; + } + let bytes = source.as_bytes(); + // Scan back to find start of identifier + let mut start = offset; + while start > 0 { + let b = bytes[start - 1]; + if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' { + start -= 1; + } else { + break; + } + } + // Scan forward to find end + let mut end = offset; + while end < bytes.len() { + let b = bytes[end]; + if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' { + end += 1; + } else { + break; + } + } + if start == end { + return None; + } + Some((source[start..end].to_string(), start)) +} + +// ── AST walking helpers ─────────────────────────────────────────────────────── + +/// Walk all expressions in a statement and call `f` with each expression and its span. +fn walk_stmts_for_expr<'a>( + stmts: &'a [Spanned], + f: &mut impl FnMut(&'a Expr, Span), +) { + for s in stmts { + walk_stmt_for_expr(&s.node, s.span, f); + } +} + +fn walk_stmt_for_expr<'a>( + stmt: &'a Stmt, + span: Span, + f: &mut impl FnMut(&'a Expr, Span), +) { + match stmt { + Stmt::Expr(e) => walk_expr(e, span, f), + Stmt::Let { value, .. } => walk_expr(value, span, f), + Stmt::Return(e) => walk_expr(e, span, f), + Stmt::Break(Some(e)) => walk_expr(e, span, f), + Stmt::Break(None) | Stmt::Continue => {} + Stmt::Guard { condition, body, else_body, .. } => { + walk_expr(condition, span, f); + walk_stmts_for_expr(body, f); + if let Some(eb) = else_body { + walk_stmts_for_expr(eb, f); + } + } + Stmt::Match { subject, arms } => { + if let Some(e) = subject { + walk_expr(e, span, f); + } + for arm in arms { + walk_stmts_for_expr(&arm.body, f); + } + } + Stmt::ForEach { collection, body, .. } => { + walk_expr(collection, span, f); + walk_stmts_for_expr(body, f); + } + Stmt::ForRange { start, end, body, .. } => { + walk_expr(start, span, f); + walk_expr(end, span, f); + walk_stmts_for_expr(body, f); + } + Stmt::While { condition, body } => { + walk_expr(condition, span, f); + walk_stmts_for_expr(body, f); + } + Stmt::Destructure { value, .. } => walk_expr(value, span, f), + } +} + +fn walk_expr<'a>(expr: &'a Expr, span: Span, f: &mut impl FnMut(&'a Expr, Span)) { + f(expr, span); + match expr { + Expr::Call { args, .. } => { + for a in args { + walk_expr(a, span, f); + } + } + Expr::BinOp { left, right, .. } => { + walk_expr(left, span, f); + walk_expr(right, span, f); + } + Expr::UnaryOp { operand, .. } => walk_expr(operand, span, f), + Expr::Ok(e) | Expr::Err(e) => walk_expr(e, span, f), + Expr::List(items) => { + for i in items { + walk_expr(i, span, f); + } + } + Expr::Record { fields, .. } => { + for (_, v) in fields { + walk_expr(v, span, f); + } + } + Expr::NilCoalesce { value, default } => { + walk_expr(value, span, f); + walk_expr(default, span, f); + } + Expr::With { object, updates } => { + walk_expr(object, span, f); + for (_, v) in updates { + walk_expr(v, span, f); + } + } + Expr::Match { subject, arms } => { + if let Some(s) = subject { + walk_expr(s, span, f); + } + for arm in arms { + walk_stmts_for_expr(&arm.body, f); + } + } + Expr::Ternary { condition, then_expr, else_expr } => { + walk_expr(condition, span, f); + walk_expr(then_expr, span, f); + walk_expr(else_expr, span, f); + } + Expr::Literal(_) | Expr::Ref(_) | Expr::Field { .. } | Expr::Index { .. } => {} + } +} + +// ── Compile source → diagnostics ───────────────────────────────────────────── + +fn compile_diagnostics(source: &str) -> Vec { + let mut diags: Vec = Vec::new(); + + // Lex + let tokens = match lexer::lex(source) { + Ok(t) => t, + Err(e) => { + let span = Span { + start: e.position, + end: (e.position + e.snippet.len().max(1)).min(source.len()), + }; + diags.push(Diagnostic { + range: span_to_range(source, span), + severity: Some(DiagnosticSeverity::ERROR), + code: Some(NumberOrString::String(e.code.to_string())), + message: format!("unexpected token '{}'", e.snippet), + ..Default::default() + }); + return diags; + } + }; + + let token_spans: Vec<_> = tokens + .into_iter() + .map(|(t, r)| (t, Span { start: r.start, end: r.end })) + .collect(); + + // Parse + let (mut program, parse_errors) = parser::parse(token_spans); + ast::resolve_aliases(&mut program); + program.source = Some(source.to_string()); + + for e in &parse_errors { + diags.push(Diagnostic { + range: span_to_range(source, e.span), + severity: Some(DiagnosticSeverity::ERROR), + code: Some(NumberOrString::String(e.code.to_string())), + message: e.message.clone(), + ..Default::default() + }); + } + + // Verify + let vr = verify::verify(&program); + for e in &vr.errors { + let range = if let Some(span) = e.span { + span_to_range(source, span) + } else { + // No span — point to start of file + Range::new(Position::new(0, 0), Position::new(0, 0)) + }; + diags.push(Diagnostic { + range, + severity: Some(DiagnosticSeverity::ERROR), + code: Some(NumberOrString::String(e.code.to_string())), + message: e.message.clone(), + ..Default::default() + }); + } + for w in &vr.warnings { + let range = if let Some(span) = w.span { + span_to_range(source, span) + } else { + Range::new(Position::new(0, 0), Position::new(0, 0)) + }; + diags.push(Diagnostic { + range, + severity: Some(DiagnosticSeverity::WARNING), + code: Some(NumberOrString::String(w.code.to_string())), + message: w.message.clone(), + ..Default::default() + }); + } + + diags +} + +// ── Parse a program (best-effort) for semantic queries ─────────────────────── + +fn parse_program(source: &str) -> Option { + let tokens = lexer::lex(source).ok()?; + let token_spans: Vec<_> = tokens + .into_iter() + .map(|(t, r)| (t, Span { start: r.start, end: r.end })) + .collect(); + let (mut program, _) = parser::parse(token_spans); + ast::resolve_aliases(&mut program); + program.source = Some(source.to_string()); + Some(program) +} + +// ── Hover helpers ───────────────────────────────────────────────────────────── + +/// Build hover markdown for a builtin. +fn builtin_hover(name: &str) -> Option { + BUILTIN_INFO + .iter() + .find(|b| b.name == name) + .map(|b| format!("**{}**\n\n`{}`\n\n{}", b.name, b.sig, b.desc)) +} + +/// Format an ilo type as a string for hover. +fn type_display(ty: &Type) -> String { + use crate::codegen::fmt::type_str; + type_str(ty) +} + +/// Build hover text for a declaration. +fn decl_hover(decl: &Decl) -> Option { + match decl { + Decl::Function { name, params, return_type, .. } => { + let params_str: String = params + .iter() + .map(|p| format!("{}:{}", p.name, type_display(&p.ty))) + .collect::>() + .join(" "); + Some(format!( + "**fn** `{}`\n\n```\n{} {}>{}\n```", + name, + name, + params_str, + type_display(return_type) + )) + } + Decl::TypeDef { name, fields, .. } => { + let fields_str: String = fields + .iter() + .map(|f| format!(" {}:{}", f.name, type_display(&f.ty))) + .collect::>() + .join("\n"); + Some(format!("**type** `{}`\n\n```\ntype {} {{\n{}\n}}\n```", name, name, fields_str)) + } + Decl::Alias { name, target, .. } => { + Some(format!("**alias** `{}` = `{}`", name, type_display(target))) + } + Decl::Tool { name, description, params, return_type, .. } => { + let params_str: String = params + .iter() + .map(|p| format!("{}:{}", p.name, type_display(&p.ty))) + .collect::>() + .join(" "); + Some(format!( + "**tool** `{}`\n\n{}\n\n`{} {}>{}`", + name, description, name, params_str, type_display(return_type) + )) + } + _ => None, + } +} + +// ── The LSP backend ─────────────────────────────────────────────────────────── + +struct IloBackend { + client: Client, + /// uri → source text + docs: Mutex>, +} + +impl IloBackend { + fn new(client: Client) -> Self { + IloBackend { + client, + docs: Mutex::new(HashMap::new()), + } + } + + fn get_source(&self, uri: &Url) -> Option { + self.docs + .lock() + .ok() + .and_then(|m| m.get(uri.as_str()).cloned()) + } + + fn set_source(&self, uri: &Url, text: String) { + if let Ok(mut m) = self.docs.lock() { + m.insert(uri.as_str().to_string(), text); + } + } + + async fn publish_diagnostics(&self, uri: Url, source: &str) { + let diags = compile_diagnostics(source); + self.client + .publish_diagnostics(uri, diags, None) + .await; + } +} + +#[tower_lsp::async_trait] +impl LanguageServer for IloBackend { + async fn initialize(&self, _params: InitializeParams) -> LspResult { + Ok(InitializeResult { + capabilities: ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + hover_provider: Some(HoverProviderCapability::Simple(true)), + definition_provider: Some(OneOf::Left(true)), + completion_provider: Some(CompletionOptions { + trigger_characters: Some(vec![":".to_string(), " ".to_string()]), + ..Default::default() + }), + document_symbol_provider: Some(OneOf::Left(true)), + ..Default::default() + }, + server_info: Some(ServerInfo { + name: "ilo-lsp".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + }) + } + + async fn initialized(&self, _: InitializedParams) { + self.client + .log_message(MessageType::INFO, "ilo LSP server initialized") + .await; + } + + async fn shutdown(&self) -> LspResult<()> { + Ok(()) + } + + // ── Document lifecycle ──────────────────────────────────────────────────── + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + let uri = params.text_document.uri; + let text = params.text_document.text; + self.set_source(&uri, text.clone()); + self.publish_diagnostics(uri, &text).await; + } + + async fn did_change(&self, params: DidChangeTextDocumentParams) { + let uri = params.text_document.uri; + // We requested FULL sync + if let Some(change) = params.content_changes.into_iter().last() { + let text = change.text; + self.set_source(&uri, text.clone()); + self.publish_diagnostics(uri, &text).await; + } + } + + async fn did_close(&self, params: DidCloseTextDocumentParams) { + let uri = params.text_document.uri; + if let Ok(mut m) = self.docs.lock() { + m.remove(uri.as_str()); + } + // Clear diagnostics on close + self.client + .publish_diagnostics(uri, vec![], None) + .await; + } + + // ── Hover ───────────────────────────────────────────────────────────────── + + async fn hover(&self, params: HoverParams) -> LspResult> { + let uri = ¶ms.text_document_position_params.text_document.uri; + let pos = params.text_document_position_params.position; + + let source = match self.get_source(uri) { + Some(s) => s, + None => return Ok(None), + }; + + let offset = position_to_offset(&source, pos); + let (ident, _) = match ident_at(&source, offset) { + Some(i) => i, + None => return Ok(None), + }; + + // 1. Check builtins + if let Some(hover_text) = builtin_hover(&ident) { + return Ok(Some(Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: hover_text, + }), + range: None, + })); + } + + // 2. Check user-defined declarations + if let Some(program) = parse_program(&source) { + for decl in &program.declarations { + let decl_name = match decl { + Decl::Function { name, .. } + | Decl::TypeDef { name, .. } + | Decl::Tool { name, .. } + | Decl::Alias { name, .. } => Some(name.as_str()), + _ => None, + }; + if decl_name == Some(ident.as_str()) { + if let Some(text) = decl_hover(decl) { + return Ok(Some(Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: text, + }), + range: None, + })); + } + } + } + } + + Ok(None) + } + + // ── Go to definition ────────────────────────────────────────────────────── + + async fn goto_definition( + &self, + params: GotoDefinitionParams, + ) -> LspResult> { + let uri = ¶ms.text_document_position_params.text_document.uri; + let pos = params.text_document_position_params.position; + + let source = match self.get_source(uri) { + Some(s) => s, + None => return Ok(None), + }; + + let offset = position_to_offset(&source, pos); + let (ident, _) = match ident_at(&source, offset) { + Some(i) => i, + None => return Ok(None), + }; + + if let Some(program) = parse_program(&source) { + for decl in &program.declarations { + let (name, span) = match decl { + Decl::Function { name, span, .. } => (name.as_str(), *span), + Decl::TypeDef { name, span, .. } => (name.as_str(), *span), + Decl::Tool { name, span, .. } => (name.as_str(), *span), + Decl::Alias { name, span, .. } => (name.as_str(), *span), + _ => continue, + }; + if name == ident { + let range = span_to_range(&source, span); + return Ok(Some(GotoDefinitionResponse::Scalar(Location { + uri: uri.clone(), + range, + }))); + } + } + } + + Ok(None) + } + + // ── Completions ─────────────────────────────────────────────────────────── + + async fn completion( + &self, + params: CompletionParams, + ) -> LspResult> { + let uri = ¶ms.text_document_position.text_document.uri; + let pos = params.text_document_position.position; + + let source = match self.get_source(uri) { + Some(s) => s, + None => return Ok(None), + }; + + let offset = position_to_offset(&source, pos); + + // Determine context: after `:` → type completions only + let after_colon = offset > 0 && { + let before = &source[..offset]; + before.trim_end_matches(|c: char| c.is_alphanumeric()) + .ends_with(':') + }; + + let mut items: Vec = Vec::new(); + + if after_colon { + // Type completions + for &ty in TYPE_NAMES { + items.push(CompletionItem { + label: ty.to_string(), + kind: Some(CompletionItemKind::TYPE_PARAMETER), + ..Default::default() + }); + } + return Ok(Some(CompletionResponse::Array(items))); + } + + // User-defined functions and types + if let Some(program) = parse_program(&source) { + for decl in &program.declarations { + match decl { + Decl::Function { name, params, return_type, .. } => { + let params_str: String = params + .iter() + .map(|p| format!("{}:{}", p.name, type_display(&p.ty))) + .collect::>() + .join(" "); + items.push(CompletionItem { + label: name.clone(), + kind: Some(CompletionItemKind::FUNCTION), + detail: Some(format!("{} {}>{}", name, params_str, type_display(return_type))), + ..Default::default() + }); + } + Decl::TypeDef { name, .. } => { + items.push(CompletionItem { + label: name.clone(), + kind: Some(CompletionItemKind::CLASS), + ..Default::default() + }); + } + Decl::Tool { name, description, .. } => { + items.push(CompletionItem { + label: name.clone(), + kind: Some(CompletionItemKind::FUNCTION), + detail: Some(description.clone()), + ..Default::default() + }); + } + _ => {} + } + } + } + + // Builtins + for b in BUILTIN_INFO { + items.push(CompletionItem { + label: b.name.to_string(), + kind: Some(CompletionItemKind::FUNCTION), + detail: Some(b.sig.to_string()), + documentation: Some(Documentation::String(b.desc.to_string())), + ..Default::default() + }); + } + + // Keywords + for &kw in KEYWORDS { + items.push(CompletionItem { + label: kw.to_string(), + kind: Some(CompletionItemKind::KEYWORD), + ..Default::default() + }); + } + + Ok(Some(CompletionResponse::Array(items))) + } + + // ── Document symbols ────────────────────────────────────────────────────── + + async fn document_symbol( + &self, + params: DocumentSymbolParams, + ) -> LspResult> { + let uri = ¶ms.text_document.uri; + let source = match self.get_source(uri) { + Some(s) => s, + None => return Ok(None), + }; + + let program = match parse_program(&source) { + Some(p) => p, + None => return Ok(None), + }; + + let mut symbols: Vec = Vec::new(); + for decl in &program.declarations { + let (name, kind, span) = match decl { + Decl::Function { name, span, .. } => (name.as_str(), SymbolKind::FUNCTION, *span), + Decl::TypeDef { name, span, .. } => (name.as_str(), SymbolKind::CLASS, *span), + Decl::Tool { name, span, .. } => (name.as_str(), SymbolKind::INTERFACE, *span), + Decl::Alias { name, span, .. } => (name.as_str(), SymbolKind::TYPE_PARAMETER, *span), + _ => continue, + }; + let range = span_to_range(&source, span); + #[allow(deprecated)] + symbols.push(SymbolInformation { + name: name.to_string(), + kind, + deprecated: None, + location: Location { + uri: uri.clone(), + range, + }, + container_name: None, + tags: None, + }); + } + + Ok(Some(DocumentSymbolResponse::Flat(symbols))) + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +/// Start the LSP server on stdin/stdout. Blocks until the client disconnects. +pub fn run() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime"); + + rt.block_on(async { + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + + let (service, socket) = LspService::new(|client| IloBackend::new(client)); + Server::new(stdin, stdout, socket).serve(service).await; + }); +} diff --git a/src/main.rs b/src/main.rs index fded4a5..539b7a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1825,6 +1825,11 @@ fn dispatch_cli(cli: cli::Cli, bare_has_bin: bool) -> i32 { println!("ilo {}", env!("CARGO_PKG_VERSION")); 0 } + #[cfg(feature = "lsp")] + Some(cli::Cmd::Lsp) => { + ilo::lsp::run(); + 0 + } Some(cli::Cmd::Run(r)) => { let mode = cli.global.output_mode(); let explicit_json = cli.global.explicit_json();