Gleam-inspired typed configuration language (POC)
0

Configure Feed

Select the types of activity you want to include in your feed.

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.

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