From 15815eff18b324e4e724f0bc8b7db9d742ea5cec Mon Sep 17 00:00:00 2001 From: nandi Date: Sun, 26 Jul 2026 01:17:54 -0700 Subject: [PATCH] Add Zed editor extension, LSP, and compile docs. Ship editors/zed (Rust guest for Install Dev Extension, Gleam sketch for future pure Wasm), language-server sources, and README instructions linking the Gleam wasm fork on Tangled. --- .gitignore | 5 + README.md | 110 +++++++++- editors/zed/.cargo/config.toml | 4 + editors/zed/.gitignore | 5 + editors/zed/Cargo.toml | 13 ++ editors/zed/LICENSE | 201 +++++++++++++++++ editors/zed/README.md | 89 ++++++++ editors/zed/extension.toml | 24 ++ editors/zed/gleam.toml | 7 + editors/zed/languages/glint/brackets.scm | 4 + editors/zed/languages/glint/config.toml | 12 + editors/zed/languages/glint/highlights.scm | 109 ++++++++++ editors/zed/languages/glint/indents.scm | 3 + editors/zed/languages/glint/outline.scm | 23 ++ editors/zed/manifest.toml | 6 + editors/zed/src/glint.rs | 92 ++++++++ editors/zed/src/zed_glint.gleam | 42 ++++ examples/demo/config.glint | 22 ++ examples/demo/gleam.toml | 7 + examples/demo/manifest.toml | 20 ++ examples/demo/src/demo.gleam | 18 ++ gleam.toml | 1 + manifest.toml | 2 + src/glint.gleam | 125 +++++++++-- src/glint/check.gleam | 39 ++-- src/glint/lexer.gleam | 61 ++++-- src/glint/lsp.gleam | 42 ++++ src/glint/lsp/diagnostics.gleam | 62 ++++++ src/glint/lsp/protocol.gleam | 241 +++++++++++++++++++++ src/glint/lsp/rpc.gleam | 233 ++++++++++++++++++++ src/glint/lsp/server.gleam | 149 +++++++++++++ src/glint/parser.gleam | 146 ++++++++----- src/glint/pipeline.gleam | 75 ++++++- src/glint/position.gleam | 56 +++++ src/glint/value.gleam | 120 ++++++++++ src/glint_lsp_ffi.erl | 39 ++++ test/glint_test.gleam | 100 +++++++++ 37 files changed, 2184 insertions(+), 123 deletions(-) create mode 100644 editors/zed/.cargo/config.toml create mode 100644 editors/zed/.gitignore create mode 100644 editors/zed/Cargo.toml create mode 100644 editors/zed/LICENSE create mode 100644 editors/zed/README.md create mode 100644 editors/zed/extension.toml create mode 100644 editors/zed/gleam.toml create mode 100644 editors/zed/languages/glint/brackets.scm create mode 100644 editors/zed/languages/glint/config.toml create mode 100644 editors/zed/languages/glint/highlights.scm create mode 100644 editors/zed/languages/glint/indents.scm create mode 100644 editors/zed/languages/glint/outline.scm create mode 100644 editors/zed/manifest.toml create mode 100644 editors/zed/src/glint.rs create mode 100644 editors/zed/src/zed_glint.gleam create mode 100644 examples/demo/config.glint create mode 100644 examples/demo/gleam.toml create mode 100644 examples/demo/manifest.toml create mode 100644 examples/demo/src/demo.gleam create mode 100644 src/glint/lsp.gleam create mode 100644 src/glint/lsp/diagnostics.gleam create mode 100644 src/glint/lsp/protocol.gleam create mode 100644 src/glint/lsp/rpc.gleam create mode 100644 src/glint/lsp/server.gleam create mode 100644 src/glint/position.gleam create mode 100644 src/glint_lsp_ffi.erl diff --git a/.gitignore b/.gitignore index be609e1..1bfa446 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,12 @@ *.beam *.ez /build +**/build erl_crash.dump /result result-* .direnv + +# Zed extension build +editors/zed/target +editors/zed/extension.wasm diff --git a/README.md b/README.md index e3aecd4..a1e5003 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,113 @@ gleam run -- check examples/hello.glint gleam run -- dump examples/hello.glint gleam run -- dump examples/hello.glint --json -# Or JavaScript target: +# Language server (stdio JSON-RPC; Erlang target): +gleam run -- lsp + +# Or JavaScript target (check/dump/library; LSP stdio needs Erlang): gleam run --target javascript -- check examples/hello.glint gleam test --target javascript ``` +## Language server + +`glint lsp` speaks the Language Server Protocol over **stdio** (Content-Length framing). + +| Capability | Support | +|------------|---------| +| `initialize` / `shutdown` / `exit` | yes | +| Full document sync (`textDocumentSync: 1`) | yes | +| `textDocument/didOpen` / `didChange` / `didClose` | yes | +| `textDocument/publishDiagnostics` | yes (lex / parse / type errors) | + +### Editor config (generic) + +Point your editor at the `glint` binary with the `lsp` argument, and associate `*.glint` files. + +**Neovim** (`nvim-lspconfig` style): + +```lua +vim.api.nvim_create_autocmd("FileType", { + pattern = "glint", + callback = function() + vim.lsp.start({ + name = "glint", + cmd = { "glint", "lsp" }, -- or: { "gleam", "run", "--", "lsp" } from the repo + root_dir = vim.fn.getcwd(), + }) + end, +}) +``` + +**VS Code** (any generic LSP client extension): command `glint`, args `["lsp"]`, language id / file glob `*.glint`. + + +**Zed** (dev extension in this repo — see also [`editors/zed/README.md`](editors/zed/README.md)): + +### 1. Build the glint binary + +```sh +cd /home/nandi/code/glint +nix build # → result/bin/glint +``` + +### 2. Compile the Zed guest (Rust) + +Zed Install Dev Extension always runs `cargo build --target wasm32-wasip2`. +Build yourself first if you want to check the toolchain: + +```sh +cd /home/nandi/code/glint/editors/zed +rustup target add wasm32-wasip2 # once +cargo build --release --target wasm32-wasip2 +cp target/wasm32-wasip2/release/zed_glint.wasm extension.wasm +file extension.wasm # WebAssembly component +``` + +### 3. Install in Zed + +1. **Extensions → Install Dev Extension…** +2. Select: `/home/nandi/code/glint/editors/zed` +3. Open a `*.glint` file — the guest starts `glint lsp` + +Optional settings override: + +```json +{ + "lsp": { + "glint": { + "binary": { + "path": "/home/nandi/code/glint/result/bin/glint", + "arguments": ["lsp"] + } + } + } +} +``` + +### Pure Gleam guest (experimental) + +Compiler work lives on our Gleam **wasm** fork: + +- **https://tangled.org/nandi.uk/gleam** (`wasm` branch) + +```sh +# build the forked compiler +git clone https://tangled.org/nandi.uk/gleam +cd gleam && git checkout wasm +cargo build -p gleam + +# from this extension (optional; Install Dev still uses Rust today) +cd /home/nandi/code/glint/editors/zed +/path/to/gleam/target/debug/gleam export zed-extension +``` + +Notes: grammar id is `gleam` (exports `tree_sitter_gleam`). Zed does not yet +install a prebuilt Gleam `extension.wasm` without cargo — see +[docs on the fork](https://tangled.org/nandi.uk/gleam) / +`docs/compiler/wasm-zed-extensions.md`. + + ## Library ```gleam @@ -79,12 +181,16 @@ pub fn example() { src/glint.gleam CLI + library entry src/glint/ token.gleam tokens - lexer.gleam lexer + lexer.gleam lexer (spanned tokens) + position.gleam LSP-style positions / ranges ast.gleam AST parser.gleam parser check.gleam typecheck + evaluate value.gleam runtime values + printers pipeline.gleam source → checked config + lsp.gleam language server entry + lsp/ JSON-RPC, protocol, handlers, diagnostics +src/glint_lsp_ffi.erl stdio FFI (Erlang) examples/ hello.glint app.glint diff --git a/editors/zed/.cargo/config.toml b/editors/zed/.cargo/config.toml new file mode 100644 index 0000000..f0c56e1 --- /dev/null +++ b/editors/zed/.cargo/config.toml @@ -0,0 +1,4 @@ +# Zed's extension builder compiles with `cargo build --target wasm32-wasip2` +# (WASI preview2 + component model). That is the only guest shape the host loads. +[build] +target = "wasm32-wasip2" diff --git a/editors/zed/.gitignore b/editors/zed/.gitignore new file mode 100644 index 0000000..b90522f --- /dev/null +++ b/editors/zed/.gitignore @@ -0,0 +1,5 @@ +/target +/build +/extension.wasm +/grammars +Cargo.lock diff --git a/editors/zed/Cargo.toml b/editors/zed/Cargo.toml new file mode 100644 index 0000000..13d0cdd --- /dev/null +++ b/editors/zed/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "zed_glint" +version = "0.1.0" +edition = "2021" +publish = false +license = "Apache-2.0" + +[lib] +path = "src/glint.rs" +crate-type = ["cdylib"] + +[dependencies] +zed_extension_api = "0.7.0" diff --git a/editors/zed/LICENSE b/editors/zed/LICENSE new file mode 100644 index 0000000..ff3966d --- /dev/null +++ b/editors/zed/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016 - present Louis Pilfold + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/editors/zed/README.md b/editors/zed/README.md new file mode 100644 index 0000000..5bec886 --- /dev/null +++ b/editors/zed/README.md @@ -0,0 +1,89 @@ +# Glint for Zed + +Zed extension for Glint — language config, tree-sitter highlighting (via +tree-sitter-gleam), and the glint language server. + +## Compile + +### Glint binary (LSP) + +```sh +cd /home/nandi/code/glint +nix build # → result/bin/glint +``` + +### Zed guest (Rust — what Install Dev Extension builds) + +Zed always runs `cargo build --target wasm32-wasip2` when +`[lib] kind = "Rust"`. Local check: + +```sh +cd /home/nandi/code/glint/editors/zed +rustup target add wasm32-wasip2 # once +cargo build --release --target wasm32-wasip2 +cp target/wasm32-wasip2/release/zed_glint.wasm extension.wasm +file extension.wasm # WebAssembly (wasm) binary version 0x1000d (component) +``` + +### Pure Gleam guest (experimental) + +Compiler work is on our Gleam **wasm** fork on Tangled: + +**https://tangled.org/nandi.uk/gleam** (`wasm` branch) + +```sh +git clone https://tangled.org/nandi.uk/gleam +cd gleam && git checkout wasm +cargo build -p gleam + +cd /home/nandi/code/glint/editors/zed +/path/to/gleam/target/debug/gleam export zed-extension +# → extension.wasm +``` + +See `docs/compiler/wasm-zed-extensions.md` in that repo. Until Zed can load a +prebuilt guest without cargo, **Install Dev Extension still uses the Rust +crate** (`src/glint.rs`). `src/zed_glint.gleam` is the pure-Gleam sketch. + +## Install in Zed + +1. Build `glint` (`nix build` above). +2. **Extensions → Install Dev Extension…** → this directory: + + ```text + /home/nandi/code/glint/editors/zed + ``` + +3. Open a `*.glint` file. The guest runs `glint lsp`. + +### LSP binary resolution + +1. `lsp.glint.binary` in Zed settings +2. `glint` on `$PATH` (`worktree.which`) +3. Fallback: `/home/nandi/code/glint/result/bin/glint` + +```json +{ + "lsp": { + "glint": { + "binary": { + "path": "/home/nandi/code/glint/result/bin/glint", + "arguments": ["lsp"] + } + } + } +} +``` + +## Layout + +| Path | Role | +|------|------| +| `extension.toml` | Zed manifest | +| `src/glint.rs` | Installable guest (`zed_extension_api`) | +| `src/zed_glint.gleam` | Pure-Gleam authoring sketch | +| `languages/glint/` | Tree-sitter queries + language config | +| `extension.wasm` | Built component (Zed / cargo) | + +Grammar id is **`gleam`** so the linker finds `tree_sitter_gleam`. Language +name / path suffix stay **Glint** / `*.glint`. diff --git a/editors/zed/extension.toml b/editors/zed/extension.toml new file mode 100644 index 0000000..0bc56e8 --- /dev/null +++ b/editors/zed/extension.toml @@ -0,0 +1,24 @@ +id = "glint" +name = "Glint" +version = "0.1.0" +schema_version = 1 +authors = ["nandi"] +description = "Glint language support — syntax highlighting and the glint language server" +repository = "https://github.com/nandi/glint" + +[lib] +kind = "Rust" +# Matches zed_extension_api; Zed also reads this from the wasm `zed:api-version` section. +version = "0.7.0" + +[language_servers.glint] +name = "Glint LSP" +languages = ["Glint"] + +[language_servers.glint.language_ids] +Glint = "glint" + +# Glint syntax is Gleam-inspired; reuse tree-sitter-gleam for highlighting. +[grammars.gleam] +repository = "https://github.com/gleam-lang/tree-sitter-gleam" +rev = "6ea757f7eb8d391dbf24dbb9461990757946dd5e" diff --git a/editors/zed/gleam.toml b/editors/zed/gleam.toml new file mode 100644 index 0000000..35de812 --- /dev/null +++ b/editors/zed/gleam.toml @@ -0,0 +1,7 @@ +name = "zed_glint" +version = "0.1.0" +target = "wasm" + +# Future: pure Gleam guest via the Gleam wasm branch +# gleam export zed-extension +# Today Zed Install Dev Extension still requires Cargo (src/glint.rs). diff --git a/editors/zed/languages/glint/brackets.scm b/editors/zed/languages/glint/brackets.scm new file mode 100644 index 0000000..2fbfd44 --- /dev/null +++ b/editors/zed/languages/glint/brackets.scm @@ -0,0 +1,4 @@ +("(" @open ")" @close) +("[" @open "]" @close) +("{" @open "}" @close) +("\"" @open "\"" @close) diff --git a/editors/zed/languages/glint/config.toml b/editors/zed/languages/glint/config.toml new file mode 100644 index 0000000..918d9f4 --- /dev/null +++ b/editors/zed/languages/glint/config.toml @@ -0,0 +1,12 @@ +name = "Glint" +grammar = "gleam" +path_suffixes = ["glint"] +line_comments = ["// "] +autoclose_before = ";:.,=}])>" +brackets = [ + { start = "{", end = "}", close = true, newline = true }, + { start = "[", end = "]", close = true, newline = true }, + { start = "(", end = ")", close = true, newline = true }, + { start = "\"", end = "\"", close = true, newline = false, not_in = ["string", "comment"] }, +] +tab_size = 2 diff --git a/editors/zed/languages/glint/highlights.scm b/editors/zed/languages/glint/highlights.scm new file mode 100644 index 0000000..dd611a3 --- /dev/null +++ b/editors/zed/languages/glint/highlights.scm @@ -0,0 +1,109 @@ +; Comments +(module_comment) @comment +(statement_comment) @comment +(comment) @comment + +; Constants +(constant + name: (identifier) @constant) + +; Variables +(identifier) @variable +(discard) @comment.unused + +; Modules +(module) @module +(import alias: (identifier) @module) +(remote_type_identifier + module: (identifier) @module) +(remote_constructor_name + module: (identifier) @module) +((field_access + record: (identifier) @module + field: (label) @function) + (#is-not? local)) + +; Functions +(unqualified_import (identifier) @function) +(unqualified_import "type" (type_identifier) @type) +(unqualified_import (type_identifier) @constructor) +(function + name: (identifier) @function.definition) +(external_function + name: (identifier) @function.definition) +(function_parameter + name: (identifier) @variable.parameter) +((function_call + function: (identifier) @function.call) + (#is-not? local)) +((binary_expression + operator: "|>" + right: (identifier) @function.call) + (#is-not? local)) + +; Properties (record fields, labeled args) +(label) @property +(tuple_access + index: (integer) @property) + +; Attributes +(attribute + "@" @attribute + name: (identifier) @attribute) + +(attribute_value (identifier) @constant) + +; Type names +(remote_type_identifier) @type +(type_identifier) @type + +; Data constructors +(constructor_name) @constructor + +; Literals +(string) @string +(escape_sequence) @string.escape +(bit_array_segment_option) @function.builtin +(integer) @number +(float) @number + +; Keywords (Glint subset of Gleam) +[ + (visibility_modifier) ; "pub" + (opacity_modifier) ; "opaque" + "as" + "const" + "fn" + "import" + "let" + "type" +] @keyword + +; Operators +(binary_expression + operator: _ @operator) +(boolean_negation "!" @operator) +(integer_negation "-" @operator) + +; Punctuation +[ + "(" + ")" + "[" + "]" + "{" + "}" + "<<" + ">>" +] @punctuation.bracket +[ + "." + "," + ":" + "#" + "=" + "->" + ".." + "-" + "<-" +] @punctuation.delimiter diff --git a/editors/zed/languages/glint/indents.scm b/editors/zed/languages/glint/indents.scm new file mode 100644 index 0000000..112b414 --- /dev/null +++ b/editors/zed/languages/glint/indents.scm @@ -0,0 +1,3 @@ +(_ "[" "]" @end) @indent +(_ "{" "}" @end) @indent +(_ "(" ")" @end) @indent diff --git a/editors/zed/languages/glint/outline.scm b/editors/zed/languages/glint/outline.scm new file mode 100644 index 0000000..3439dd3 --- /dev/null +++ b/editors/zed/languages/glint/outline.scm @@ -0,0 +1,23 @@ +(type_definition + (visibility_modifier)? @context + (opacity_modifier)? @context + "type" @context + (type_name) @name) @item + +(data_constructor + (constructor_name) @name) @item + +(data_constructor_argument + (label) @name) @item + +(type_alias + (visibility_modifier)? @context + "type" @context + (type_name) @name) @item + +(constant + (visibility_modifier)? @context + "const" @context + name: (_) @name) @item + +(statement_comment) @annotation diff --git a/editors/zed/manifest.toml b/editors/zed/manifest.toml new file mode 100644 index 0000000..e375948 --- /dev/null +++ b/editors/zed/manifest.toml @@ -0,0 +1,6 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [] + +[requirements] diff --git a/editors/zed/src/glint.rs b/editors/zed/src/glint.rs new file mode 100644 index 0000000..bad2a4b --- /dev/null +++ b/editors/zed/src/glint.rs @@ -0,0 +1,92 @@ +use std::fs; + +use zed_extension_api::{self as zed, LanguageServerId, Result, settings::LspSettings}; + +/// Default local checkout used when `glint` is not on `$PATH`. +const DEFAULT_GLINT_BINARY: &str = "/home/nandi/code/glint/result/bin/glint"; + +struct GlintExtension; + +impl GlintExtension { + fn language_server_binary( + &self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result<(String, Vec)> { + let settings = LspSettings::for_worktree(language_server_id.as_ref(), worktree)?; + + // Prefer explicit config: + // "lsp": { "glint": { "binary": { "path": "...", "arguments": ["lsp"] } } } + if let Some(binary) = settings.binary { + if let Some(path) = binary.path { + let args = binary.arguments.unwrap_or_else(|| vec!["lsp".into()]); + return Ok((path, args)); + } + if let Some(args) = binary.arguments { + // Path omitted: resolve binary, use custom args. + let path = self.resolve_binary(worktree)?; + return Ok((path, args)); + } + } + + Ok((self.resolve_binary(worktree)?, vec!["lsp".into()])) + } + + fn resolve_binary(&self, worktree: &zed::Worktree) -> Result { + if let Some(path) = worktree.which("glint") { + return Ok(path); + } + + if fs::metadata(DEFAULT_GLINT_BINARY) + .map(|m| m.is_file()) + .unwrap_or(false) + { + return Ok(DEFAULT_GLINT_BINARY.into()); + } + + Err( + "glint binary not found. Install it (nix build in /home/nandi/code/glint) \ + or put `glint` on PATH, or set lsp.glint.binary.path in settings.json." + .into(), + ) + } +} + +impl zed::Extension for GlintExtension { + fn new() -> Self { + Self + } + + fn language_server_command( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result { + let (command, args) = self.language_server_binary(language_server_id, worktree)?; + Ok(zed::Command { + command, + args, + env: Default::default(), + }) + } + + fn language_server_initialization_options( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result> { + LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .map(|s| s.initialization_options) + } + + fn language_server_workspace_configuration( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result> { + LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .map(|s| s.settings) + } +} + +zed::register_extension!(GlintExtension); diff --git a/editors/zed/src/zed_glint.gleam b/editors/zed/src/zed_glint.gleam new file mode 100644 index 0000000..2881305 --- /dev/null +++ b/editors/zed/src/zed_glint.gleam @@ -0,0 +1,42 @@ +//// Zed extension written in Gleam. +//// +//// `gleam export zed-extension` packages a full `zed:extension` world +//// component. The guest shell implements every world export; this module +//// documents the intended `language_server_command` callback that the +//// compiler wires into the CABI layer. +//// +//// Resolution order for the glint binary: +//// 1. `worktree.which("glint")` (PATH) +//// 2. Fallback: `/home/nandi/code/glint/result/bin/glint` + +/// Opaque worktree handle from the Zed host (resource `i32`). +pub type Worktree + +/// Process command returned to Zed to start a language server. +pub type Command { + Command(command: String, args: List(String), env: List(#(String, String))) +} + +/// Look up a binary on the worktree `$PATH`. +/// +/// Implemented by the guest shell via `[method]worktree.which`. +@external(wasm, "$root", "[method]worktree.which") +pub fn which(worktree: Worktree, binary_name: String) -> Result(String, Nil) + +/// Extension entry: no-op init. +pub fn init() -> Nil { + Nil +} + +/// Return the command used to start the glint language server. +pub fn language_server_command( + _language_server_id: String, + worktree: Worktree, +) -> Result(Command, String) { + case which(worktree, "glint") { + Ok(path) -> Ok(Command(path, ["lsp"], [])) + // Default local checkout when `glint` is not on `$PATH`. + Error(_) -> + Ok(Command("/home/nandi/code/glint/result/bin/glint", ["lsp"], [])) + } +} diff --git a/examples/demo/config.glint b/examples/demo/config.glint new file mode 100644 index 0000000..c2b8581 --- /dev/null +++ b/examples/demo/config.glint @@ -0,0 +1,22 @@ +// Config for the demo Gleam app (examples/demo). + +type Mode { + Dev + Prod +} + +type Config { + Config( + name: String, + mode: Mode, + port: Int, + greeting: String, + ) +} + +pub let config = Config( + name: "demo-service", + mode: Dev, + port: 4000, + greeting: "hello from glint!", +) diff --git a/examples/demo/gleam.toml b/examples/demo/gleam.toml new file mode 100644 index 0000000..1036b9e --- /dev/null +++ b/examples/demo/gleam.toml @@ -0,0 +1,7 @@ +name = "demo" +version = "1.0.0" +description = "Example Gleam app that loads configuration from a .glint file" + +[dependencies] +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +glint = { path = "../.." } diff --git a/examples/demo/manifest.toml b/examples/demo/manifest.toml new file mode 100644 index 0000000..3e62259 --- /dev/null +++ b/examples/demo/manifest.toml @@ -0,0 +1,20 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ + { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, + { name = "glint", version = "0.1.0", build_tools = ["gleam"], requirements = ["argv", "gleam_json", "gleam_stdlib", "simplifile"], source = "local", path = "../.." }, + { name = "simplifile", version = "2.6.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A33C345F0A4FFB91DCCD4220114534A58C387964A5F17B3E472CEBD1ADA9FFB4" }, +] + +[requirements] +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +glint = { path = "../.." } diff --git a/examples/demo/src/demo.gleam b/examples/demo/src/demo.gleam new file mode 100644 index 0000000..1cba771 --- /dev/null +++ b/examples/demo/src/demo.gleam @@ -0,0 +1,18 @@ +//// Example: load a `.glint` config and use it. +//// +//// cd examples/demo && gleam run + +import gleam/int +import gleam/io +import glint + +pub fn main() -> Nil { + let c = glint.file("config.glint") + let name = glint.string(c, "name") + let mode = glint.unit(c, "mode") + let port = glint.int(c, "port") + + io.println(name <> " (" <> mode <> ")") + io.println("port " <> int.to_string(port)) + io.println(glint.string(c, "greeting")) +} diff --git a/gleam.toml b/gleam.toml index 1115db5..cc34c07 100644 --- a/gleam.toml +++ b/gleam.toml @@ -6,6 +6,7 @@ description = "Glint — a Gleam-inspired typed configuration language (POC)" gleam_stdlib = ">= 1.0.0 and < 2.0.0" simplifile = ">= 2.6.0 and < 3.0.0" argv = ">= 1.1.0 and < 2.0.0" +gleam_json = ">= 3.1.0 and < 4.0.0" [dev_dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 468f4a4..3b37c15 100644 --- a/manifest.toml +++ b/manifest.toml @@ -9,6 +9,7 @@ packages = [ { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, { name = "simplifile", version = "2.6.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A33C345F0A4FFB91DCCD4220114534A58C387964A5F17B3E472CEBD1ADA9FFB4" }, @@ -16,6 +17,7 @@ packages = [ [requirements] argv = { version = ">= 1.1.0 and < 2.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } simplifile = { version = ">= 2.6.0 and < 3.0.0" } diff --git a/src/glint.gleam b/src/glint.gleam index 67e7238..865f093 100644 --- a/src/glint.gleam +++ b/src/glint.gleam @@ -1,23 +1,83 @@ //// Glint — a Gleam-inspired configuration language (POC). //// -//// Usage: +//// CLI: //// gleam run -- check //// gleam run -- dump //// gleam run -- dump --json +//// +//// Host app (no ceremony): +//// let c = glint.file("config.glint") +//// glint.int(c, "port") +//// glint.string(c, "server.host") import argv import gleam/io +import gleam/list +import gleam/result import gleam/string import glint/check +import glint/lsp as glint_lsp import glint/pipeline -import glint/value +import glint/value.{type Value} import simplifile +// ── Host API ──────────────────────────────────────────────────────── +// Load once; read fields by path. Wrong field = bug (panic), not a +// recoverable Result chain — Glint already typechecked the file. + +/// Load a `.glint` file and return its root `config` value. +/// Panics with a readable message if the file is missing or invalid. +pub fn file(path: String) -> Value { + case read(path) { + Ok(v) -> v + Error(e) -> panic as e + } +} + +/// Like `file`, but as a `Result` when you want to handle errors. +pub fn read(path: String) -> Result(Value, String) { + use checked <- result.try(load_file(path)) + Ok(checked.config) +} + +/// Nested field access via dots: `"port"`, `"server.host"`. +pub fn at(cfg: Value, path: String) -> Value { + dig(cfg, path_parts(path)) +} + +pub fn string(cfg: Value, path: String) -> String { + expect(path, value.as_string(at(cfg, path))) +} + +pub fn int(cfg: Value, path: String) -> Int { + expect(path, value.as_int(at(cfg, path))) +} + +pub fn bool(cfg: Value, path: String) -> Bool { + expect(path, value.as_bool(at(cfg, path))) +} + +pub fn float(cfg: Value, path: String) -> Float { + expect(path, value.as_float(at(cfg, path))) +} + +/// Unit variant tag at path (`mode` → `"Dev"`). +pub fn unit(cfg: Value, path: String) -> String { + expect(path, value.as_unit(at(cfg, path))) +} + +pub fn list(cfg: Value, path: String) -> List(Value) { + expect(path, value.as_list(at(cfg, path))) +} + +// ── CLI ───────────────────────────────────────────────────────────── + pub fn main() -> Nil { case argv.load().arguments { ["check", path] -> run_check(path) ["dump", path] -> run_dump(path, False) ["dump", path, "--json"] -> run_dump(path, True) + ["lsp"] -> glint_lsp.main() ["help"] | ["--help"] | ["-h"] -> print_usage() [] -> print_usage() args -> { @@ -35,6 +95,7 @@ Usage: gleam run -- check gleam run -- dump gleam run -- dump --json + gleam run -- lsp ", ) } @@ -60,7 +121,18 @@ fn run_dump(path: String, as_json: Bool) -> Nil { } } -fn load_file(path: String) -> Result(check.Checked, String) { +// ── Lower-level (tests, tooling) ──────────────────────────────────── + +/// Load and typecheck a source string. +pub fn load(source: String) -> Result(check.Checked, String) { + case pipeline.load(source) { + Error(e) -> Error(pipeline.error_to_string(e)) + Ok(checked) -> Ok(checked) + } +} + +/// Read + load a file (keeps type info). Prefer `file` / `read` in apps. +pub fn load_file(path: String) -> Result(check.Checked, String) { case simplifile.read(path) { Error(e) -> Error( @@ -69,36 +141,41 @@ fn load_file(path: String) -> Result(check.Checked, String) { <> ": " <> simplifile.describe_error(e), ) - Ok(source) -> - case pipeline.load(source) { - Error(e) -> Error(pipeline.error_to_string(e)) - Ok(checked) -> Ok(checked) - } - } -} - -/// Library entry: load and check a source string. -pub fn load(source: String) -> Result(check.Checked, String) { - case pipeline.load(source) { - Error(e) -> Error(pipeline.error_to_string(e)) - Ok(checked) -> Ok(checked) + Ok(source) -> load(source) } } -/// Convenience for tests / embedding. pub fn dump(source: String) -> Result(String, String) { - use checked <- result_map(load(source)) - value.to_glint(checked.config) + use checked <- result.try(load(source)) + Ok(value.to_glint(checked.config)) } pub fn dump_json(source: String) -> Result(String, String) { - use checked <- result_map(load(source)) - value.to_json(checked.config) + use checked <- result.try(load(source)) + Ok(value.to_json(checked.config)) +} + +// ── internals ─────────────────────────────────────────────────────── + +fn path_parts(path: String) -> List(String) { + string.split(path, on: ".") + |> list.filter(fn(p) { p != "" }) +} + +fn dig(value: Value, parts: List(String)) -> Value { + case parts { + [] -> value + [key, ..rest] -> + case value.get(value, key) { + Ok(next) -> dig(next, rest) + Error(e) -> panic as e + } + } } -fn result_map(r: Result(a, e), f: fn(a) -> b) -> Result(b, e) { +fn expect(path: String, r: Result(a, String)) -> a { case r { - Ok(a) -> Ok(f(a)) - Error(e) -> Error(e) + Ok(v) -> v + Error(e) -> panic as { path <> ": " <> e } } } diff --git a/src/glint/check.gleam b/src/glint/check.gleam index 97007cc..9561446 100644 --- a/src/glint/check.gleam +++ b/src/glint/check.gleam @@ -11,7 +11,9 @@ import glint/ast.{ import glint/value.{type Value} pub type CheckError { - CheckError(message: String) + /// `start`/`end` are grapheme offsets when known; `0,0` when the AST + /// has no span information (POC). + CheckError(message: String, start: Int, end: Int) } pub type Type { @@ -44,13 +46,17 @@ pub type Checked { Checked(config: Value, config_type: Type) } +fn err(message: String) -> CheckError { + CheckError(message, 0, 0) +} + pub fn check(program: Program) -> Result(Checked, CheckError) { use env <- result.try(collect_types(program.statements, empty_env())) use env <- result.try(eval_lets(program.statements, env)) case dict.get(env.vars, "config") { Ok(#(ty, val)) -> Ok(Checked(config: val, config_type: ty)) Error(_) -> - Error(CheckError("missing root export: `pub let config` is required")) + Error(err("missing root export: `pub let config` is required")) } } @@ -76,7 +82,7 @@ fn register_type( constructors: List(Constructor), ) -> Result(Env, CheckError) { case dict.has_key(env.types, type_name) { - True -> Error(CheckError("type `" <> type_name <> "` is already defined")) + True -> Error(err("type `" <> type_name <> "` is already defined")) False -> { use env <- result.try( list.try_fold(constructors, env, fn(env, ctor) { @@ -101,9 +107,7 @@ fn register_ctor( ) -> Result(Env, CheckError) { case dict.has_key(env.ctors, ctor.name) { True -> - Error(CheckError( - "constructor `" <> ctor.name <> "` is already defined", - )) + Error(err("constructor `" <> ctor.name <> "` is already defined")) False -> { use fields <- result.try( list.try_map(ctor.fields, fn(field) { @@ -126,7 +130,7 @@ fn resolve_type_expr(env: Env, te: TypeExpr) -> Result(Type, CheckError) { ast.NamedType(name) -> case dict.has_key(env.types, name) { True -> Ok(TNamed(name)) - False -> Error(CheckError("unknown type `" <> name <> "`")) + False -> Error(err("unknown type `" <> name <> "`")) } ast.ListType(inner) -> { use t <- result.try(resolve_type_expr(env, inner)) @@ -145,8 +149,7 @@ fn eval_lets(stmts: List(Stmt), env: Env) -> Result(Env, CheckError) { ast.TypeDef(..) -> Ok(env) ast.Let(_public, name, value) -> { case dict.has_key(env.vars, name) { - True -> - Error(CheckError("`" <> name <> "` is already bound")) + True -> Error(err("`" <> name <> "` is already bound")) False -> { use #(ty, val) <- result.try(infer(env, value)) Ok( @@ -169,7 +172,7 @@ fn infer(env: Env, expr: Expr) -> Result(#(Type, Value), CheckError) { ast.FloatLit(f) -> Ok(#(TFloat, value.VFloat(f))) ast.BoolLit(b) -> Ok(#(TBool, value.VBool(b))) ast.NoneLit -> - Error(CheckError( + Error(err( "`None` needs a type context (use it in an Option field, or write Some(...))", )) ast.SomeExpr(inner) -> { @@ -190,7 +193,7 @@ fn resolve_name(env: Env, name: String) -> Result(#(Type, Value), CheckError) { Ok(CtorInfo(type_name, [])) -> Ok(#(TNamed(type_name), value.VVariant(name, []))) Ok(CtorInfo(_, fields)) -> - Error(CheckError( + Error(err( "constructor `" <> name <> "` requires fields: " @@ -198,7 +201,7 @@ fn resolve_name(env: Env, name: String) -> Result(#(Type, Value), CheckError) { |> list.map(fn(f) { f.0 }) |> string.join(", "), )) - Error(_) -> Error(CheckError("unknown name `" <> name <> "`")) + Error(_) -> Error(err("unknown name `" <> name <> "`")) } } } @@ -209,14 +212,14 @@ fn infer_construct( fields: List(#(String, Expr)), ) -> Result(#(Type, Value), CheckError) { case dict.get(env.ctors, name) { - Error(_) -> Error(CheckError("unknown constructor `" <> name <> "`")) + Error(_) -> Error(err("unknown constructor `" <> name <> "`")) Ok(CtorInfo(type_name, expected_fields)) -> { use field_vals <- result.try( list.try_map(expected_fields, fn(expected) { let #(label, expected_ty) = expected case list.find(fields, fn(f) { f.0 == label }) { Error(_) -> - Error(CheckError( + Error(err( "constructor `" <> name <> "` missing field `" @@ -227,21 +230,19 @@ fn infer_construct( use #(ty, val) <- result.try( check_expr(env, expr, expected_ty), ) - // ty already matches let _ = ty Ok(#(label, val)) } } }), ) - // reject unknown fields use _ <- result.try( list.try_map(fields, fn(f) { let #(label, _) = f case list.find(expected_fields, fn(e) { e.0 == label }) { Ok(_) -> Ok(Nil) Error(_) -> - Error(CheckError( + Error(err( "constructor `" <> name <> "` has unknown field `" @@ -265,7 +266,7 @@ fn infer_list( ) -> Result(#(Type, Value), CheckError) { case items { [] -> - Error(CheckError( + Error(err( "empty list needs a type context (not supported in POC; add an element)", )) [first, ..rest] -> { @@ -298,7 +299,7 @@ fn check_expr( case types_equal(got, expected) { True -> Ok(#(got, val)) False -> - Error(CheckError( + Error(err( "type mismatch: expected " <> type_to_string(expected) <> ", got " diff --git a/src/glint/lexer.gleam b/src/glint/lexer.gleam index 82337fc..588f56a 100644 --- a/src/glint/lexer.gleam +++ b/src/glint/lexer.gleam @@ -1,4 +1,4 @@ -//// Lexer: source text → tokens. +//// Lexer: source text → spanned tokens. import gleam/int import gleam/list @@ -10,17 +10,25 @@ pub type LexError { LexError(message: String, position: Int) } -pub fn lex(source: String) -> Result(List(Token), LexError) { +/// A token with half-open grapheme span `[start, end)`. +pub type Spanned { + Spanned(token: Token, start: Int, end: Int) +} + +pub fn lex(source: String) -> Result(List(Spanned), LexError) { do_lex(string.to_graphemes(source), 0, []) } fn do_lex( chars: List(String), pos: Int, - acc: List(Token), -) -> Result(List(Token), LexError) { + acc: List(Spanned), +) -> Result(List(Spanned), LexError) { case chars { - [] -> Ok(list.reverse([token.Eof, ..acc])) + [] -> { + let eof = Spanned(token.Eof, pos, pos) + Ok(list.reverse([eof, ..acc])) + } ["/", "/", ..rest] -> { let #(rest2, skipped) = skip_line_comment(rest) @@ -32,20 +40,32 @@ fn do_lex( | ["\n", ..rest] | ["\r", ..rest] -> do_lex(rest, pos + 1, acc) - ["{", ..rest] -> do_lex(rest, pos + 1, [token.LBrace, ..acc]) - ["}", ..rest] -> do_lex(rest, pos + 1, [token.RBrace, ..acc]) - ["(", ..rest] -> do_lex(rest, pos + 1, [token.LParen, ..acc]) - [")", ..rest] -> do_lex(rest, pos + 1, [token.RParen, ..acc]) - ["[", ..rest] -> do_lex(rest, pos + 1, [token.LBracket, ..acc]) - ["]", ..rest] -> do_lex(rest, pos + 1, [token.RBracket, ..acc]) - [":", ..rest] -> do_lex(rest, pos + 1, [token.Colon, ..acc]) - ["=", ..rest] -> do_lex(rest, pos + 1, [token.Equal, ..acc]) - [",", ..rest] -> do_lex(rest, pos + 1, [token.Comma, ..acc]) + ["{", ..rest] -> + do_lex(rest, pos + 1, [span(token.LBrace, pos, pos + 1), ..acc]) + ["}", ..rest] -> + do_lex(rest, pos + 1, [span(token.RBrace, pos, pos + 1), ..acc]) + ["(", ..rest] -> + do_lex(rest, pos + 1, [span(token.LParen, pos, pos + 1), ..acc]) + [")", ..rest] -> + do_lex(rest, pos + 1, [span(token.RParen, pos, pos + 1), ..acc]) + ["[", ..rest] -> + do_lex(rest, pos + 1, [span(token.LBracket, pos, pos + 1), ..acc]) + ["]", ..rest] -> + do_lex(rest, pos + 1, [span(token.RBracket, pos, pos + 1), ..acc]) + [":", ..rest] -> + do_lex(rest, pos + 1, [span(token.Colon, pos, pos + 1), ..acc]) + ["=", ..rest] -> + do_lex(rest, pos + 1, [span(token.Equal, pos, pos + 1), ..acc]) + [",", ..rest] -> + do_lex(rest, pos + 1, [span(token.Comma, pos, pos + 1), ..acc]) ["\"", ..rest] -> { case take_string(rest, pos + 1, "") { Ok(#(s, rest2, end_pos)) -> - do_lex(rest2, end_pos, [token.String(s), ..acc]) + do_lex(rest2, end_pos, [ + span(token.String(s), pos, end_pos), + ..acc + ]) Error(e) -> Error(e) } } @@ -55,9 +75,10 @@ fn do_lex( True -> { let #(num_chars, rest2) = take_while(chars, is_number_char) let raw = string.concat(num_chars) + let len = list.length(num_chars) case parse_number(raw) { Ok(tok) -> - do_lex(rest2, pos + list.length(num_chars), [tok, ..acc]) + do_lex(rest2, pos + len, [span(tok, pos, pos + len), ..acc]) Error(msg) -> Error(LexError(msg, pos)) } } @@ -66,8 +87,9 @@ fn do_lex( True -> { let #(id_chars, rest2) = take_while(chars, is_ident_char) let name = string.concat(id_chars) + let len = list.length(id_chars) let tok = keyword_or_ident(name) - do_lex(rest2, pos + list.length(id_chars), [tok, ..acc]) + do_lex(rest2, pos + len, [span(tok, pos, pos + len), ..acc]) } False -> Error(LexError("unexpected character `" <> c <> "`", pos)) @@ -77,6 +99,10 @@ fn do_lex( } } +fn span(token: Token, start: Int, end: Int) -> Spanned { + Spanned(token:, start:, end:) +} + fn skip_line_comment(chars: List(String)) -> #(List(String), Int) { case chars { [] -> #([], 0) @@ -167,7 +193,6 @@ fn parse_number(raw: String) -> Result(Token, String) { } fn float_parse(s: String) -> Result(Float, Nil) { - // gleam/float has parse in recent stdlib case string.split(s, on: ".") { [whole, frac] -> { use w <- result.try(int.parse(whole) |> result.replace_error(Nil)) diff --git a/src/glint/lsp.gleam b/src/glint/lsp.gleam new file mode 100644 index 0000000..7ec89c2 --- /dev/null +++ b/src/glint/lsp.gleam @@ -0,0 +1,42 @@ +//// Glint Language Server — stdio JSON-RPC entry point. +//// +//// Run with: `gleam run -- lsp` or `glint lsp` +//// +//// Logs go to stderr only; stdout is reserved for LSP framing. + +import gleam/io +import glint/lsp/rpc +import glint/lsp/server + +/// Start the language server main loop (Erlang / stdio). +pub fn main() -> Nil { + io.println_error("glint-lsp: starting (stdio)") + loop(server.new()) +} + +fn loop(state: server.Server) -> Nil { + case rpc.read_message() { + Error(rpc.Eof) -> { + io.println_error("glint-lsp: stdin closed, exiting") + Nil + } + Error(rpc.BadHeader(msg)) -> { + io.println_error("glint-lsp: bad header: " <> msg) + loop(state) + } + Error(rpc.BadBody(msg)) -> { + io.println_error("glint-lsp: bad body: " <> msg) + loop(state) + } + Ok(body) -> { + let server.HandleResult(server: next, messages:) = rpc.dispatch(state, body) + case rpc.flush(messages) { + True -> { + io.println_error("glint-lsp: exit") + Nil + } + False -> loop(next) + } + } + } +} diff --git a/src/glint/lsp/diagnostics.gleam b/src/glint/lsp/diagnostics.gleam new file mode 100644 index 0000000..2796fe8 --- /dev/null +++ b/src/glint/lsp/diagnostics.gleam @@ -0,0 +1,62 @@ +//// Convert Glint pipeline errors into LSP diagnostics. + +import glint/lsp/protocol +import glint/pipeline +import glint/position + +/// Analyse source and return zero or one diagnostic. +pub fn analyse(source: String) -> List(protocol.Diagnostic) { + case pipeline.load(source) { + Ok(_) -> [] + Error(err) -> [from_error(source, err)] + } +} + +pub fn from_error(source: String, err: pipeline.Error) -> protocol.Diagnostic { + let diag = pipeline.to_diagnostic(err) + let start = diag.start + let end = case diag.end <= diag.start { + True -> diag.start + 1 + False -> diag.end + } + // Clamp end so empty documents still get a valid range. + let source_len = position.length(source) + let start_clamped = case start > source_len { + True -> source_len + False -> + case start < 0 { + True -> 0 + False -> start + } + } + let end_clamped = case end > source_len { + True -> + case source_len == 0 { + True -> 0 + False -> source_len + } + False -> + case end < start_clamped { + True -> start_clamped + False -> end + } + } + // Empty source: range at 0:0–0:0 is valid for LSP. + let range = case source_len == 0 { + True -> + protocol.RangeJson( + start: protocol.PositionJson(0, 0), + end: protocol.PositionJson(0, 0), + ) + False -> { + let r = position.range_from_offsets(source, start_clamped, end_clamped) + protocol.range_to_json(r) + } + } + protocol.Diagnostic( + range:, + severity: protocol.severity_error, + source: "glint", + message: diag.message, + ) +} diff --git a/src/glint/lsp/protocol.gleam b/src/glint/lsp/protocol.gleam new file mode 100644 index 0000000..2613f65 --- /dev/null +++ b/src/glint/lsp/protocol.gleam @@ -0,0 +1,241 @@ +//// Minimal LSP JSON types used by the Glint language server. + +import gleam/dynamic/decode +import gleam/json +import gleam/option.{type Option, None, Some} +import glint/position.{type Position, type Range} + +// ── LSP severity ──────────────────────────────────────────────────── + +/// DiagnosticSeverity.Error = 1 +pub const severity_error = 1 + +// ── Wire types ────────────────────────────────────────────────────── + +pub type PositionJson { + PositionJson(line: Int, character: Int) +} + +pub type RangeJson { + RangeJson(start: PositionJson, end: PositionJson) +} + +pub type Diagnostic { + Diagnostic(range: RangeJson, severity: Int, source: String, message: String) +} + +pub type TextDocumentItem { + TextDocumentItem(uri: String, language_id: String, version: Int, text: String) +} + +pub type VersionedTextDocumentIdentifier { + VersionedTextDocumentIdentifier(uri: String, version: Int) +} + +pub type TextDocumentIdentifier { + TextDocumentIdentifier(uri: String) +} + +pub type TextDocumentContentChangeEvent { + /// Full document sync: the entire new text. + FullChange(text: String) +} + +pub type InitializeParams { + InitializeParams(process_id: Option(Int), root_uri: Option(String)) +} + +pub type DidOpenParams { + DidOpenParams(text_document: TextDocumentItem) +} + +pub type DidChangeParams { + DidChangeParams( + text_document: VersionedTextDocumentIdentifier, + content_changes: List(TextDocumentContentChangeEvent), + ) +} + +pub type DidCloseParams { + DidCloseParams(text_document: TextDocumentIdentifier) +} + +// ── Conversions ───────────────────────────────────────────────────── + +pub fn position_to_json(pos: Position) -> PositionJson { + PositionJson(line: pos.line, character: pos.character) +} + +pub fn range_to_json(range: Range) -> RangeJson { + RangeJson( + start: position_to_json(range.start), + end: position_to_json(range.end), + ) +} + +// ── JSON encode ───────────────────────────────────────────────────── + +pub fn encode_position(pos: PositionJson) -> json.Json { + json.object([ + #("line", json.int(pos.line)), + #("character", json.int(pos.character)), + ]) +} + +pub fn encode_range(range: RangeJson) -> json.Json { + json.object([ + #("start", encode_position(range.start)), + #("end", encode_position(range.end)), + ]) +} + +pub fn encode_diagnostic(d: Diagnostic) -> json.Json { + json.object([ + #("range", encode_range(d.range)), + #("severity", json.int(d.severity)), + #("source", json.string(d.source)), + #("message", json.string(d.message)), + ]) +} + +pub fn encode_publish_diagnostics(uri: String, diagnostics: List(Diagnostic)) -> json.Json { + json.object([ + #("uri", json.string(uri)), + #( + "diagnostics", + json.preprocessed_array(list_map_diag(diagnostics)), + ), + ]) +} + +fn list_map_diag(diagnostics: List(Diagnostic)) -> List(json.Json) { + case diagnostics { + [] -> [] + [d, ..rest] -> [encode_diagnostic(d), ..list_map_diag(rest)] + } +} + +pub fn encode_server_capabilities() -> json.Json { + // textDocumentSync: 1 = Full + json.object([ + #("textDocumentSync", json.int(1)), + ]) +} + +pub fn encode_initialize_result() -> json.Json { + json.object([ + #("capabilities", encode_server_capabilities()), + #( + "serverInfo", + json.object([ + #("name", json.string("glint-lsp")), + #("version", json.string("0.1.0")), + ]), + ), + ]) +} + +pub fn encode_response_ok(id: json.Json, result: json.Json) -> json.Json { + json.object([ + #("jsonrpc", json.string("2.0")), + #("id", id), + #("result", result), + ]) +} + +pub fn encode_response_null(id: json.Json) -> json.Json { + json.object([ + #("jsonrpc", json.string("2.0")), + #("id", id), + #("result", json.null()), + ]) +} + +pub fn encode_error_response( + id: Option(json.Json), + code: Int, + message: String, +) -> json.Json { + let id_json = case id { + Some(i) -> i + None -> json.null() + } + json.object([ + #("jsonrpc", json.string("2.0")), + #("id", id_json), + #( + "error", + json.object([ + #("code", json.int(code)), + #("message", json.string(message)), + ]), + ), + ]) +} + +pub fn encode_notification(method: String, params: json.Json) -> json.Json { + json.object([ + #("jsonrpc", json.string("2.0")), + #("method", json.string(method)), + #("params", params), + ]) +} + +// ── JSON decode ───────────────────────────────────────────────────── + +pub fn request_id_decoder() -> decode.Decoder(Option(json.Json)) { + decode.optional( + decode.one_of(decode.map(decode.int, json.int), [ + decode.map(decode.string, json.string), + ]), + ) +} + +/// Decode a raw RPC envelope: method, optional id, optional params dynamic. +pub type RpcMessage { + RpcMessage( + method: Option(String), + id: Option(json.Json), + /// Raw params object as a JSON string re-encoded, or empty. + params_present: Bool, + ) +} + +pub fn text_document_item_decoder() -> decode.Decoder(TextDocumentItem) { + use uri <- decode.field("uri", decode.string) + use language_id <- decode.field("languageId", decode.string) + use version <- decode.field("version", decode.int) + use text <- decode.field("text", decode.string) + decode.success(TextDocumentItem(uri:, language_id:, version:, text:)) +} + +pub fn did_open_decoder() -> decode.Decoder(DidOpenParams) { + use text_document <- decode.field( + "textDocument", + text_document_item_decoder(), + ) + decode.success(DidOpenParams(text_document:)) +} + +pub fn content_change_decoder() -> decode.Decoder(TextDocumentContentChangeEvent) { + use text <- decode.field("text", decode.string) + decode.success(FullChange(text)) +} + +pub fn did_change_decoder() -> decode.Decoder(DidChangeParams) { + use uri <- decode.subfield(["textDocument", "uri"], decode.string) + use version <- decode.subfield(["textDocument", "version"], decode.int) + use content_changes <- decode.field( + "contentChanges", + decode.list(content_change_decoder()), + ) + decode.success(DidChangeParams( + text_document: VersionedTextDocumentIdentifier(uri:, version:), + content_changes:, + )) +} + +pub fn did_close_decoder() -> decode.Decoder(DidCloseParams) { + use uri <- decode.subfield(["textDocument", "uri"], decode.string) + decode.success(DidCloseParams(text_document: TextDocumentIdentifier(uri:))) +} diff --git a/src/glint/lsp/rpc.gleam b/src/glint/lsp/rpc.gleam new file mode 100644 index 0000000..bf3647e --- /dev/null +++ b/src/glint/lsp/rpc.gleam @@ -0,0 +1,233 @@ +//// Content-Length framed JSON-RPC over stdio. + +import gleam/bit_array +import gleam/dynamic.{type Dynamic} +import gleam/dynamic/decode +import gleam/int +import gleam/json +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import glint/lsp/protocol +import glint/lsp/server.{type HandleResult, type Outgoing, type Server} + +// ── FFI (Erlang stdio; JS stubs so check/dump still build on JS) ───── + +@external(erlang, "glint_lsp_ffi", "read_bytes") +fn read_bytes(_n: Int) -> Result(BitArray, String) { + Error("lsp stdio is only available on the Erlang target") +} + +@external(erlang, "glint_lsp_ffi", "write_bytes") +fn write_bytes(_data: BitArray) -> Nil { + Nil +} + +@external(erlang, "glint_lsp_ffi", "read_line") +fn read_line() -> Result(BitArray, String) { + Error("lsp stdio is only available on the Erlang target") +} + +// ── Framing ───────────────────────────────────────────────────────── + +pub type ReadError { + Eof + BadHeader(String) + BadBody(String) +} + +/// Read one Content-Length framed message body as a UTF-8 string. +pub fn read_message() -> Result(String, ReadError) { + use content_length <- result.try(read_headers(0)) + case content_length <= 0 { + True -> Error(BadHeader("missing Content-Length")) + False -> { + case read_bytes(content_length) { + Error(_) -> Error(Eof) + Ok(bits) -> + case bit_array.to_string(bits) { + Ok(body) -> Ok(body) + Error(_) -> Error(BadBody("body is not valid UTF-8")) + } + } + } + } +} + +fn read_headers(content_length: Int) -> Result(Int, ReadError) { + case read_line() { + Error(_) -> Error(Eof) + Ok(bits) -> + case bit_array.to_string(bits) { + Error(_) -> Error(BadHeader("header is not valid UTF-8")) + Ok(line) -> { + let trimmed = string.trim(line) + case trimmed == "" { + True -> Ok(content_length) + False -> { + let lower = string.lowercase(trimmed) + case string.starts_with(lower, "content-length:") { + True -> { + let value = + trimmed + |> string.drop_start(string.length("Content-Length:")) + |> string.trim + case int.parse(value) { + Ok(n) -> read_headers(n) + Error(_) -> + Error(BadHeader("invalid Content-Length: " <> value)) + } + } + False -> read_headers(content_length) + } + } + } + } + } + } +} + +/// Encode and write a JSON-RPC message with Content-Length framing. +pub fn write_message(payload: json.Json) -> Nil { + let body = json.to_string(payload) + let header = + "Content-Length: " + <> int.to_string(byte_size(body)) + <> "\r\n\r\n" + let full = header <> body + write_bytes(bit_array.from_string(full)) +} + +fn byte_size(s: String) -> Int { + bit_array.byte_size(bit_array.from_string(s)) +} + +// ── Dispatch ──────────────────────────────────────────────────────── + +type Envelope { + Envelope( + method: Option(String), + id: Option(json.Json), + params: Option(Dynamic), + ) +} + +fn envelope_decoder() -> decode.Decoder(Envelope) { + use method <- decode.optional_field( + "method", + None, + decode.map(decode.string, Some), + ) + use id <- decode.optional_field( + "id", + None, + decode.one_of( + decode.map(decode.int, fn(n) { Some(json.int(n)) }), + or: [decode.map(decode.string, fn(s) { Some(json.string(s)) })], + ), + ) + use params <- decode.optional_field( + "params", + None, + decode.map(decode.dynamic, Some), + ) + decode.success(Envelope(method:, id:, params:)) +} + +/// Parse a body and handle it against server state. +pub fn dispatch(server: Server, body: String) -> HandleResult { + case json.parse(body, envelope_decoder()) { + Error(_) -> + server.HandleResult(server:, messages: [ + server.Response( + protocol.encode_error_response(None, -32_700, "Parse error"), + ), + ]) + Ok(envelope) -> handle_envelope(server, envelope) + } +} + +fn handle_envelope(server: Server, envelope: Envelope) -> HandleResult { + case envelope.method { + None -> server.HandleResult(server:, messages: []) + Some(method) -> route(server, method, envelope.id, envelope.params) + } +} + +fn route( + server: Server, + method: String, + id: Option(json.Json), + params: Option(Dynamic), +) -> HandleResult { + case method { + "initialize" -> + case id { + Some(req_id) -> server.handle_initialize(server, req_id) + None -> server.HandleResult(server:, messages: []) + } + + "initialized" -> server.handle_initialized(server) + + "shutdown" -> + case id { + Some(req_id) -> server.handle_shutdown(server, req_id) + None -> server.HandleResult(server:, messages: []) + } + + "exit" -> server.handle_exit(server) + + "$/cancelRequest" -> server.handle_cancel(server) + + "textDocument/didOpen" -> + case decode_params(params, protocol.did_open_decoder()) { + Ok(p) -> server.handle_did_open(server, p) + Error(_) -> server.HandleResult(server:, messages: []) + } + + "textDocument/didChange" -> + case decode_params(params, protocol.did_change_decoder()) { + Ok(p) -> server.handle_did_change(server, p) + Error(_) -> server.HandleResult(server:, messages: []) + } + + "textDocument/didClose" -> + case decode_params(params, protocol.did_close_decoder()) { + Ok(p) -> server.handle_did_close(server, p) + Error(_) -> server.HandleResult(server:, messages: []) + } + + _ -> server.handle_unknown_request(server, id, method) + } +} + +fn decode_params( + params: Option(Dynamic), + decoder: decode.Decoder(a), +) -> Result(a, Nil) { + case params { + None -> Error(Nil) + Some(dyn) -> + decode.run(dyn, decoder) + |> result.replace_error(Nil) + } +} + +/// Write all outgoing messages; returns True if the server should exit. +pub fn flush(messages: List(Outgoing)) -> Bool { + list.fold(messages, False, fn(should_exit, msg) { + case msg { + server.Response(j) -> { + write_message(j) + should_exit + } + server.Notification(j) -> { + write_message(j) + should_exit + } + server.Silent -> should_exit + server.Exit -> True + } + }) +} diff --git a/src/glint/lsp/server.gleam b/src/glint/lsp/server.gleam new file mode 100644 index 0000000..64091d2 --- /dev/null +++ b/src/glint/lsp/server.gleam @@ -0,0 +1,149 @@ +//// LSP server state and request handlers. + +import gleam/dict.{type Dict} +import gleam/json +import gleam/list +import gleam/option.{type Option, None, Some} +import glint/lsp/diagnostics +import glint/lsp/protocol + +pub type Server { + Server( + /// Open documents: uri → full text + documents: Dict(String, String), + /// Set after successful initialize. + initialized: Bool, + /// Set after shutdown request; exit may then terminate. + shutdown_requested: Bool, + ) +} + +pub type Outgoing { + /// JSON-RPC response (has id). + Response(json.Json) + /// JSON-RPC notification (no id). + Notification(json.Json) + /// No wire response. + Silent + /// Terminate the process after writing any pending messages. + Exit +} + +pub type HandleResult { + HandleResult(server: Server, messages: List(Outgoing)) +} + +pub fn new() -> Server { + Server(documents: dict.new(), initialized: False, shutdown_requested: False) +} + +pub fn handle_initialize(server: Server, id: json.Json) -> HandleResult { + let result = protocol.encode_initialize_result() + HandleResult( + server: Server(..server, initialized: True), + messages: [Response(protocol.encode_response_ok(id, result))], + ) +} + +pub fn handle_initialized(server: Server) -> HandleResult { + HandleResult(server:, messages: []) +} + +pub fn handle_shutdown(server: Server, id: json.Json) -> HandleResult { + HandleResult( + server: Server(..server, shutdown_requested: True), + messages: [Response(protocol.encode_response_null(id))], + ) +} + +pub fn handle_exit(server: Server) -> HandleResult { + HandleResult(server:, messages: [Exit]) +} + +pub fn handle_did_open( + server: Server, + params: protocol.DidOpenParams, +) -> HandleResult { + let uri = params.text_document.uri + let text = params.text_document.text + let server = + Server(..server, documents: dict.insert(server.documents, uri, text)) + HandleResult(server:, messages: [publish_for(uri, text)]) +} + +pub fn handle_did_change( + server: Server, + params: protocol.DidChangeParams, +) -> HandleResult { + let uri = params.text_document.uri + let text = case params.content_changes { + [protocol.FullChange(t), ..] -> t + [] -> + case dict.get(server.documents, uri) { + Ok(existing) -> existing + Error(_) -> "" + } + } + // Full sync: last change wins if multiple; use last entry. + let text = last_full_text(params.content_changes, text) + let server = + Server(..server, documents: dict.insert(server.documents, uri, text)) + HandleResult(server:, messages: [publish_for(uri, text)]) +} + +fn last_full_text( + changes: List(protocol.TextDocumentContentChangeEvent), + fallback: String, +) -> String { + case list.reverse(changes) { + [protocol.FullChange(t), ..] -> t + [] -> fallback + } +} + +pub fn handle_did_close( + server: Server, + params: protocol.DidCloseParams, +) -> HandleResult { + let uri = params.text_document.uri + let server = + Server(..server, documents: dict.delete(server.documents, uri)) + // Clear diagnostics for closed document. + let clear = + Notification(protocol.encode_notification( + "textDocument/publishDiagnostics", + protocol.encode_publish_diagnostics(uri, []), + )) + HandleResult(server:, messages: [clear]) +} + +pub fn handle_unknown_request( + server: Server, + id: Option(json.Json), + method: String, +) -> HandleResult { + let messages = case id { + Some(req_id) -> [ + Response(protocol.encode_error_response( + Some(req_id), + -32_601, + "Method not found: " <> method, + )), + ] + None -> [] + } + HandleResult(server:, messages:) +} + +pub fn handle_cancel(server: Server) -> HandleResult { + // $/cancelRequest — ignore + HandleResult(server:, messages: []) +} + +fn publish_for(uri: String, text: String) -> Outgoing { + let diags = diagnostics.analyse(text) + Notification(protocol.encode_notification( + "textDocument/publishDiagnostics", + protocol.encode_publish_diagnostics(uri, diags), + )) +} diff --git a/src/glint/parser.gleam b/src/glint/parser.gleam index 4634134..2fc5cfc 100644 --- a/src/glint/parser.gleam +++ b/src/glint/parser.gleam @@ -3,23 +3,26 @@ import gleam/list import gleam/result import glint/ast +import glint/lexer.{type Spanned, Spanned} import glint/token.{type Token} pub type ParseError { - ParseError(message: String) + ParseError(message: String, start: Int, end: Int) } type Parser { - Parser(tokens: List(Token)) + Parser(tokens: List(Spanned)) } -pub fn parse(tokens: List(Token)) -> Result(ast.Program, ParseError) { +pub fn parse(tokens: List(Spanned)) -> Result(ast.Program, ParseError) { let p = Parser(tokens) case parse_program(p) { - Ok(#(program, Parser([token.Eof]))) -> Ok(program) - Ok(#(_, Parser([tok, ..]))) -> + Ok(#(program, Parser([Spanned(token.Eof, _, _)]))) -> Ok(program) + Ok(#(_, Parser([sp, ..]))) -> Error(ParseError( - "unexpected token after program: " <> token.to_string(tok), + "unexpected token after program: " <> token.to_string(sp.token), + sp.start, + sp.end, )) Ok(#(program, Parser([]))) -> Ok(program) Error(e) -> Error(e) @@ -35,18 +38,20 @@ fn parse_stmts( acc: List(ast.Stmt), ) -> Result(#(ast.Program, Parser), ParseError) { case peek(p) { - token.Eof -> Ok(#(ast.Program(list.reverse(acc)), p)) - token.Type -> { + Spanned(token.Eof, _, _) -> Ok(#(ast.Program(list.reverse(acc)), p)) + Spanned(token.Type, _, _) -> { use #(stmt, p2) <- result.try(parse_type_def(p)) parse_stmts(p2, [stmt, ..acc]) } - token.Pub | token.Let -> { + Spanned(token.Pub, _, _) | Spanned(token.Let, _, _) -> { use #(stmt, p2) <- result.try(parse_let(p)) parse_stmts(p2, [stmt, ..acc]) } - tok -> + sp -> Error(ParseError( - "expected `type` or `let`, got " <> token.to_string(tok), + "expected `type` or `let`, got " <> token.to_string(sp.token), + sp.start, + sp.end, )) } } @@ -65,14 +70,20 @@ fn parse_constructors( acc: List(ast.Constructor), ) -> Result(#(List(ast.Constructor), Parser), ParseError) { case peek(p) { - token.RBrace -> Ok(#(list.reverse(acc), p)) - token.Ident(_) | token.Some | token.None | token.True | token.False -> { + Spanned(token.RBrace, _, _) -> Ok(#(list.reverse(acc), p)) + Spanned(token.Ident(_), _, _) + | Spanned(token.Some, _, _) + | Spanned(token.None, _, _) + | Spanned(token.True, _, _) + | Spanned(token.False, _, _) -> { use #(ctor, p2) <- result.try(parse_constructor(p)) parse_constructors(p2, [ctor, ..acc]) } - tok -> + sp -> Error(ParseError( - "expected constructor name, got " <> token.to_string(tok), + "expected constructor name, got " <> token.to_string(sp.token), + sp.start, + sp.end, )) } } @@ -82,7 +93,7 @@ fn parse_constructor( ) -> Result(#(ast.Constructor, Parser), ParseError) { use #(name, p) <- result.try(expect_name_like(p)) case peek(p) { - token.LParen -> { + Spanned(token.LParen, _, _) -> { use p <- result.try(expect(p, token.LParen)) use #(fields, p) <- result.try(parse_fields(p, [])) use p <- result.try(expect(p, token.RParen)) @@ -97,14 +108,15 @@ fn parse_fields( acc: List(ast.Field), ) -> Result(#(List(ast.Field), Parser), ParseError) { case peek(p) { - token.RParen -> Ok(#(list.reverse(acc), p)) + Spanned(token.RParen, _, _) -> Ok(#(list.reverse(acc), p)) _ -> { use #(field, p) <- result.try(parse_field(p)) case peek(p) { - token.Comma -> { + Spanned(token.Comma, _, _) -> { use p <- result.try(expect(p, token.Comma)) case peek(p) { - token.RParen -> Ok(#(list.reverse([field, ..acc]), p)) + Spanned(token.RParen, _, _) -> + Ok(#(list.reverse([field, ..acc]), p)) _ -> parse_fields(p, [field, ..acc]) } } @@ -142,7 +154,7 @@ fn parse_type_expr(p: Parser) -> Result(#(ast.TypeExpr, Parser), ParseError) { fn parse_let(p: Parser) -> Result(#(ast.Stmt, Parser), ParseError) { use #(public, p) <- result.try(case peek(p) { - token.Pub -> { + Spanned(token.Pub, _, _) -> { use p <- result.try(advance(p)) Ok(#(True, p)) } @@ -157,41 +169,45 @@ fn parse_let(p: Parser) -> Result(#(ast.Stmt, Parser), ParseError) { fn parse_expr(p: Parser) -> Result(#(ast.Expr, Parser), ParseError) { case peek(p) { - token.String(s) -> { + Spanned(token.String(s), _, _) -> { use p <- result.try(advance(p)) Ok(#(ast.StringLit(s), p)) } - token.Int(n) -> { + Spanned(token.Int(n), _, _) -> { use p <- result.try(advance(p)) Ok(#(ast.IntLit(n), p)) } - token.Float(f) -> { + Spanned(token.Float(f), _, _) -> { use p <- result.try(advance(p)) Ok(#(ast.FloatLit(f), p)) } - token.True -> { + Spanned(token.True, _, _) -> { use p <- result.try(advance(p)) Ok(#(ast.BoolLit(True), p)) } - token.False -> { + Spanned(token.False, _, _) -> { use p <- result.try(advance(p)) Ok(#(ast.BoolLit(False), p)) } - token.None -> { + Spanned(token.None, _, _) -> { use p <- result.try(advance(p)) Ok(#(ast.NoneLit, p)) } - token.Some -> { + Spanned(token.Some, _, _) -> { use p <- result.try(advance(p)) use p <- result.try(expect(p, token.LParen)) use #(inner, p) <- result.try(parse_expr(p)) use p <- result.try(expect(p, token.RParen)) Ok(#(ast.SomeExpr(inner), p)) } - token.LBracket -> parse_list(p) - token.Ident(name) -> parse_name_or_construct(p, name) - tok -> - Error(ParseError("expected expression, got " <> token.to_string(tok))) + Spanned(token.LBracket, _, _) -> parse_list(p) + Spanned(token.Ident(name), _, _) -> parse_name_or_construct(p, name) + sp -> + Error(ParseError( + "expected expression, got " <> token.to_string(sp.token), + sp.start, + sp.end, + )) } } @@ -201,7 +217,7 @@ fn parse_name_or_construct( ) -> Result(#(ast.Expr, Parser), ParseError) { use p <- result.try(advance(p)) case peek(p) { - token.LParen -> { + Spanned(token.LParen, _, _) -> { use p <- result.try(expect(p, token.LParen)) use #(fields, p) <- result.try(parse_labeled_args(p, [])) use p <- result.try(expect(p, token.RParen)) @@ -216,16 +232,17 @@ fn parse_labeled_args( acc: List(#(String, ast.Expr)), ) -> Result(#(List(#(String, ast.Expr)), Parser), ParseError) { case peek(p) { - token.RParen -> Ok(#(list.reverse(acc), p)) + Spanned(token.RParen, _, _) -> Ok(#(list.reverse(acc), p)) _ -> { use #(label, p) <- result.try(expect_ident(p)) use p <- result.try(expect(p, token.Colon)) use #(value, p) <- result.try(parse_expr(p)) case peek(p) { - token.Comma -> { + Spanned(token.Comma, _, _) -> { use p <- result.try(expect(p, token.Comma)) case peek(p) { - token.RParen -> Ok(#(list.reverse([#(label, value), ..acc]), p)) + Spanned(token.RParen, _, _) -> + Ok(#(list.reverse([#(label, value), ..acc]), p)) _ -> parse_labeled_args(p, [#(label, value), ..acc]) } } @@ -247,14 +264,15 @@ fn parse_list_items( acc: List(ast.Expr), ) -> Result(#(List(ast.Expr), Parser), ParseError) { case peek(p) { - token.RBracket -> Ok(#(list.reverse(acc), p)) + Spanned(token.RBracket, _, _) -> Ok(#(list.reverse(acc), p)) _ -> { use #(item, p) <- result.try(parse_expr(p)) case peek(p) { - token.Comma -> { + Spanned(token.Comma, _, _) -> { use p <- result.try(expect(p, token.Comma)) case peek(p) { - token.RBracket -> Ok(#(list.reverse([item, ..acc]), p)) + Spanned(token.RBracket, _, _) -> + Ok(#(list.reverse([item, ..acc]), p)) _ -> parse_list_items(p, [item, ..acc]) } } @@ -266,56 +284,68 @@ fn parse_list_items( // --- parser helpers --- -fn peek(p: Parser) -> Token { +fn peek(p: Parser) -> Spanned { case p.tokens { [t, ..] -> t - [] -> token.Eof + [] -> Spanned(token.Eof, 0, 0) } } fn advance(p: Parser) -> Result(Parser, ParseError) { case p.tokens { [_, ..rest] -> Ok(Parser(rest)) - [] -> Error(ParseError("unexpected end of input")) + [] -> Error(ParseError("unexpected end of input", 0, 0)) } } fn expect(p: Parser, expected: Token) -> Result(Parser, ParseError) { case p.tokens { - [tok, ..rest] if tok == expected -> Ok(Parser(rest)) - [tok, ..] -> + [Spanned(tok, _, _), ..rest] if tok == expected -> Ok(Parser(rest)) + [sp, ..] -> Error(ParseError( "expected " - <> token.to_string(expected) - <> ", got " - <> token.to_string(tok), + <> token.to_string(expected) + <> ", got " + <> token.to_string(sp.token), + sp.start, + sp.end, )) [] -> Error(ParseError( "expected " <> token.to_string(expected) <> ", got end of file", + 0, + 0, )) } } fn expect_ident(p: Parser) -> Result(#(String, Parser), ParseError) { case p.tokens { - [token.Ident(name), ..rest] -> Ok(#(name, Parser(rest))) - [tok, ..] -> - Error(ParseError("expected identifier, got " <> token.to_string(tok))) - [] -> Error(ParseError("expected identifier, got end of file")) + [Spanned(token.Ident(name), _, _), ..rest] -> Ok(#(name, Parser(rest))) + [sp, ..] -> + Error(ParseError( + "expected identifier, got " <> token.to_string(sp.token), + sp.start, + sp.end, + )) + [] -> Error(ParseError("expected identifier, got end of file", 0, 0)) } } /// Accept Ident or keyword tokens that can appear as type/ctor names. fn expect_name_like(p: Parser) -> Result(#(String, Parser), ParseError) { case p.tokens { - [token.Ident(name), ..rest] -> Ok(#(name, Parser(rest))) - [token.Some, ..rest] -> Ok(#("Some", Parser(rest))) - [token.None, ..rest] -> Ok(#("None", Parser(rest))) - [token.True, ..rest] -> Ok(#("True", Parser(rest))) - [token.False, ..rest] -> Ok(#("False", Parser(rest))) - [tok, ..] -> - Error(ParseError("expected name, got " <> token.to_string(tok))) - [] -> Error(ParseError("expected name, got end of file")) + [Spanned(token.Ident(name), _, _), ..rest] -> Ok(#(name, Parser(rest))) + [Spanned(token.Some, _, _), ..rest] -> Ok(#("Some", Parser(rest))) + [Spanned(token.None, _, _), ..rest] -> Ok(#("None", Parser(rest))) + [Spanned(token.True, _, _), ..rest] -> Ok(#("True", Parser(rest))) + [Spanned(token.False, _, _), ..rest] -> Ok(#("False", Parser(rest))) + [sp, ..] -> + Error(ParseError( + "expected name, got " <> token.to_string(sp.token), + sp.start, + sp.end, + )) + [] -> Error(ParseError("expected name, got end of file", 0, 0)) } } diff --git a/src/glint/pipeline.gleam b/src/glint/pipeline.gleam index 15c202b..d2f2e9f 100644 --- a/src/glint/pipeline.gleam +++ b/src/glint/pipeline.gleam @@ -4,6 +4,7 @@ import gleam/int as gleam_int import glint/check.{type CheckError, type Checked} import glint/lexer.{type LexError} import glint/parser.{type ParseError} +import glint/position pub type Error { Lex(LexError) @@ -11,6 +12,17 @@ pub type Error { Check(CheckError) } +/// Structured diagnostic used by the LSP and tooling. +pub type DiagnosticInfo { + DiagnosticInfo( + message: String, + /// Grapheme offset start (inclusive). + start: Int, + /// Grapheme offset end (exclusive). Use `start` when unknown. + end: Int, + ) +} + pub fn load(source: String) -> Result(Checked, Error) { case lexer.lex(source) { Error(e) -> Error(Lex(e)) @@ -26,12 +38,71 @@ pub fn load(source: String) -> Result(Checked, Error) { } } +/// Convert a pipeline error into a structured diagnostic. +pub fn to_diagnostic(err: Error) -> DiagnosticInfo { + case err { + Lex(lexer.LexError(message, position)) -> + DiagnosticInfo(message: "lex error: " <> message, start: position, end: position) + Parse(parser.ParseError(message, start, end)) -> + DiagnosticInfo(message: "parse error: " <> message, start:, end:) + Check(check.CheckError(message, start, end)) -> + DiagnosticInfo(message: "type error: " <> message, start:, end:) + } +} + pub fn error_to_string(err: Error) -> String { case err { Lex(lexer.LexError(message, position)) -> "lex error at " <> int_to_string(position) <> ": " <> message - Parse(parser.ParseError(message)) -> "parse error: " <> message - Check(check.CheckError(message)) -> "type error: " <> message + Parse(parser.ParseError(message, start, end)) -> { + let loc = case start == 0 && end == 0 { + True -> "" + False -> " at " <> int_to_string(start) <> ".." <> int_to_string(end) + } + "parse error" <> loc <> ": " <> message + } + Check(check.CheckError(message, start, end)) -> { + let loc = case start == 0 && end == 0 { + True -> "" + False -> " at " <> int_to_string(start) <> ".." <> int_to_string(end) + } + "type error" <> loc <> ": " <> message + } + } +} + +/// Format an error with line:col when a source string is available. +pub fn error_to_string_with_source(err: Error, source: String) -> String { + let diag = to_diagnostic(err) + let pos = position.offset_to_position(source, diag.start) + let line = pos.line + 1 + let col = pos.character + 1 + case err { + Lex(lexer.LexError(message, _)) -> + "lex error at " + <> int_to_string(line) + <> ":" + <> int_to_string(col) + <> ": " + <> message + Parse(parser.ParseError(message, _, _)) -> + "parse error at " + <> int_to_string(line) + <> ":" + <> int_to_string(col) + <> ": " + <> message + Check(check.CheckError(message, start, end)) -> + case start == 0 && end == 0 { + True -> "type error: " <> message + False -> + "type error at " + <> int_to_string(line) + <> ":" + <> int_to_string(col) + <> ": " + <> message + } } } diff --git a/src/glint/position.gleam b/src/glint/position.gleam new file mode 100644 index 0000000..b8a3581 --- /dev/null +++ b/src/glint/position.gleam @@ -0,0 +1,56 @@ +//// Source positions in LSP style (0-based line and character). +//// Offsets are grapheme indices matching the lexer. + +import gleam/list +import gleam/string + +/// 0-based line and character (column) position. +pub type Position { + Position(line: Int, character: Int) +} + +/// Half-open range from `start` (inclusive) to `end` (exclusive). +pub type Range { + Range(start: Position, end: Position) +} + +/// Convert a grapheme offset into a line/character position. +pub fn offset_to_position(source: String, offset: Int) -> Position { + let graphemes = string.to_graphemes(source) + do_offset_to_position(graphemes, offset, 0, 0) +} + +fn do_offset_to_position( + graphemes: List(String), + remaining: Int, + line: Int, + character: Int, +) -> Position { + case remaining <= 0 { + True -> Position(line:, character:) + False -> + case graphemes { + [] -> Position(line:, character:) + ["\n", ..rest] -> + do_offset_to_position(rest, remaining - 1, line + 1, 0) + [_, ..rest] -> + do_offset_to_position(rest, remaining - 1, line, character + 1) + } + } +} + +/// Build a range from two grapheme offsets in `source`. +pub fn range_from_offsets(source: String, start: Int, end: Int) -> Range { + let start_pos = offset_to_position(source, start) + let end_offset = case end < start { + True -> start + False -> end + } + let end_pos = offset_to_position(source, end_offset) + Range(start: start_pos, end: end_pos) +} + +/// Total number of graphemes in the source. +pub fn length(source: String) -> Int { + list.length(string.to_graphemes(source)) +} diff --git a/src/glint/value.gleam b/src/glint/value.gleam index 859bc51..c93afa3 100644 --- a/src/glint/value.gleam +++ b/src/glint/value.gleam @@ -1,8 +1,13 @@ /// Runtime values produced by evaluating a Glint config. +/// +/// After `glint.load` / `glint.load_file`, the root value is already +/// typechecked. Host code should **project** fields out of this tree — +/// not re-declare the schema or re-validate types. import gleam/float import gleam/int import gleam/list +import gleam/result import gleam/string pub type Value { @@ -17,6 +22,121 @@ pub type Value { VList(List(Value)) } +// ── Projection (for host apps) ────────────────────────────────────── + +/// Labeled fields of a record constructor (`Config(name: …, port: …)`). +/// Unit variants (`Dev`) yield an empty list. +pub fn fields(value: Value) -> Result(List(#(String, Value)), String) { + case value { + VVariant(_, fs) -> Ok(fs) + other -> Error("expected a record/variant, got " <> to_glint(other)) + } +} + +/// Constructor tag (`Config`, `Dev`, `Prod`, …). +pub fn tag(value: Value) -> Result(String, String) { + case value { + VVariant(name, _) -> Ok(name) + other -> Error("expected a variant, got " <> to_glint(other)) + } +} + +/// Field lookup on a record value: `get(config, "port")`. +pub fn get(value: Value, label: String) -> Result(Value, String) { + use fs <- result.try(fields(value)) + field(fs, label) +} + +/// Look up a label in an already-extracted field list. +pub fn field( + fields: List(#(String, Value)), + label: String, +) -> Result(Value, String) { + case list.key_find(fields, label) { + Ok(v) -> Ok(v) + Error(Nil) -> Error("missing field `" <> label <> "`") + } +} + +pub fn as_string(value: Value) -> Result(String, String) { + case value { + VString(s) -> Ok(s) + other -> Error("expected String, got " <> to_glint(other)) + } +} + +pub fn as_int(value: Value) -> Result(Int, String) { + case value { + VInt(n) -> Ok(n) + other -> Error("expected Int, got " <> to_glint(other)) + } +} + +pub fn as_bool(value: Value) -> Result(Bool, String) { + case value { + VBool(b) -> Ok(b) + other -> Error("expected Bool, got " <> to_glint(other)) + } +} + +pub fn as_float(value: Value) -> Result(Float, String) { + case value { + VFloat(f) -> Ok(f) + other -> Error("expected Float, got " <> to_glint(other)) + } +} + +/// Unit variant → its tag name (`Dev`, `Info`, …). +pub fn as_unit(value: Value) -> Result(String, String) { + case value { + VVariant(name, []) -> Ok(name) + other -> Error("expected a unit variant, got " <> to_glint(other)) + } +} + +pub fn as_list(value: Value) -> Result(List(Value), String) { + case value { + VList(items) -> Ok(items) + other -> Error("expected List, got " <> to_glint(other)) + } +} + +/// `None` → `Error`, `Some(x)` → `Ok(x)`. +pub fn as_some(value: Value) -> Result(Value, String) { + case value { + VSome(inner) -> Ok(inner) + VNone -> Error("expected Some(...), got None") + other -> Error("expected Option, got " <> to_glint(other)) + } +} + +// Sugar: get + cast in one step. + +pub fn get_string(value: Value, label: String) -> Result(String, String) { + use v <- result.try(get(value, label)) + as_string(v) +} + +pub fn get_int(value: Value, label: String) -> Result(Int, String) { + use v <- result.try(get(value, label)) + as_int(v) +} + +pub fn get_bool(value: Value, label: String) -> Result(Bool, String) { + use v <- result.try(get(value, label)) + as_bool(v) +} + +pub fn get_unit(value: Value, label: String) -> Result(String, String) { + use v <- result.try(get(value, label)) + as_unit(v) +} + +pub fn get_list(value: Value, label: String) -> Result(List(Value), String) { + use v <- result.try(get(value, label)) + as_list(v) +} + /// Pretty-print a value in Glint-like syntax. pub fn to_glint(value: Value) -> String { case value { diff --git a/src/glint_lsp_ffi.erl b/src/glint_lsp_ffi.erl new file mode 100644 index 0000000..5654853 --- /dev/null +++ b/src/glint_lsp_ffi.erl @@ -0,0 +1,39 @@ +%% Stdio helpers for the Glint language server. +%% Only used on the Erlang target. + +-module(glint_lsp_ffi). +-export([read_bytes/1, write_bytes/1, read_line/0]). + +%% Read exactly N bytes from stdin as a bit array / binary. +read_bytes(N) when is_integer(N), N >= 0 -> + case file:read(standard_io, N) of + {ok, Data} when is_binary(Data) -> + {ok, Data}; + {ok, Data} when is_list(Data) -> + {ok, list_to_binary(Data)}; + eof -> + {error, <<"eof">>}; + {error, Reason} -> + {error, iolist_to_binary(io_lib:format("~p", [Reason]))} + end. + +%% Write raw bytes to stdout (must not go through the logger). +write_bytes(Data) when is_binary(Data) -> + ok = file:write(standard_io, Data), + ok; +write_bytes(Data) when is_list(Data) -> + ok = file:write(standard_io, list_to_binary(Data)), + ok. + +%% Read one line from stdin (including the trailing newline when present). +read_line() -> + case io:get_line("") of + eof -> + {error, <<"eof">>}; + {error, Reason} -> + {error, iolist_to_binary(io_lib:format("~p", [Reason]))}; + Line when is_list(Line) -> + {ok, unicode:characters_to_binary(Line)}; + Line when is_binary(Line) -> + {ok, Line} + end. diff --git a/test/glint_test.gleam b/test/glint_test.gleam index 2bb09bb..0c0efa1 100644 --- a/test/glint_test.gleam +++ b/test/glint_test.gleam @@ -2,7 +2,9 @@ import gleam/string import gleeunit import glint import glint/check +import glint/lsp/diagnostics import glint/pipeline +import glint/position import glint/value pub fn main() -> Nil { @@ -154,3 +156,101 @@ fn list_has_tag(fields: List(#(String, value.Value)), label: String) -> Bool { } } } + +pub fn host_accessors_test() { + let source = + " +type Mode { + Dev + Prod +} + +type Server { + Server(host: String, port: Int) +} + +type Config { + Config( + name: String, + mode: Mode, + server: Server, + ) +} + +pub let config = Config( + name: \"hello\", + mode: Dev, + server: Server(host: \"localhost\", port: 3000), +) +" + let assert Ok(checked) = glint.load(source) + let cfg = checked.config + let assert "hello" = glint.string(cfg, "name") + let assert "Dev" = glint.unit(cfg, "mode") + let assert 3000 = glint.int(cfg, "server.port") + let assert "localhost" = glint.string(cfg, "server.host") +} + +// ── Position / diagnostics ────────────────────────────────────────── + +pub fn offset_to_position_test() { + let source = "abc\ndef" + let assert position.Position(0, 0) = position.offset_to_position(source, 0) + let assert position.Position(0, 2) = position.offset_to_position(source, 2) + let assert position.Position(1, 0) = position.offset_to_position(source, 4) + let assert position.Position(1, 2) = position.offset_to_position(source, 6) +} + +pub fn range_from_offsets_test() { + let source = "let x = 1" + let range = position.range_from_offsets(source, 0, 3) + let assert position.Position(0, 0) = range.start + let assert position.Position(0, 3) = range.end +} + +pub fn valid_source_no_diagnostics_test() { + let source = + " +type Config { + Config(port: Int) +} + +pub let config = Config(port: 1) +" + let assert [] = diagnostics.analyse(source) +} + +pub fn lex_error_has_range_test() { + // `@` is not a valid token + let source = "let x = @" + let diags = diagnostics.analyse(source) + let assert [d] = diags + let assert True = string.contains(d.message, "lex error") + // Range should not be the default empty only — character should land near `@` + let assert True = d.range.start.line >= 0 + let assert True = d.range.start.character >= 0 +} + +pub fn parse_error_has_range_test() { + // Missing `=` after name + let source = "let x 1" + let diags = diagnostics.analyse(source) + let assert [d] = diags + let assert True = string.contains(d.message, "parse error") + let assert True = + d.range.start.character > 0 || d.range.end.character > d.range.start.character +} + +pub fn check_error_diagnostic_test() { + let source = + " +type Config { + Config(port: Int) +} + +pub let config = Config(port: \"nope\") +" + let diags = diagnostics.analyse(source) + let assert [d] = diags + let assert True = string.contains(d.message, "type error") +} -- 2.51.2