Gleam-inspired typed configuration language (POC)
0

Configure Feed

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

Add LSP hover and bump gleam wasm flake input.

Implement textDocument/hover with symbol analysis for types, constructors,
lets, and builtins; pin the wasm Gleam input to 8d88cf3a and add a Go example.

+844 -4
+1
README.md
··· 64 64 | Full document sync (`textDocumentSync: 1`) | yes | 65 65 | `textDocument/didOpen` / `didChange` / `didClose` | yes | 66 66 | `textDocument/publishDiagnostics` | yes (lex / parse / type errors) | 67 + | `textDocument/hover` | yes (types, constructors, lets, builtins) | 67 68 68 69 ### Editor config (generic) 69 70
+61
examples/go/config.glint
··· 1 + // Config for the Go host example (examples/go). 2 + 3 + type LogLevel { 4 + Debug 5 + Info 6 + Warn 7 + Error 8 + } 9 + 10 + type Database { 11 + Database( 12 + host: String, 13 + port: Int, 14 + name: String, 15 + ssl: Bool, 16 + password: Option(String), 17 + ) 18 + } 19 + 20 + type Server { 21 + Server( 22 + host: String, 23 + port: Int, 24 + workers: Int, 25 + ) 26 + } 27 + 28 + type Config { 29 + Config( 30 + name: String, 31 + log_level: LogLevel, 32 + server: Server, 33 + database: Database, 34 + cors_origins: List(String), 35 + ) 36 + } 37 + 38 + let database = Database( 39 + host: "localhost", 40 + port: 5432, 41 + name: "myapp", 42 + ssl: True, 43 + password: None, 44 + ) 45 + 46 + let server = Server( 47 + host: "0.0.0.0", 48 + port: 8080, 49 + workers: 4, 50 + ) 51 + 52 + pub let config = Config( 53 + name: "go-service", 54 + log_level: Info, 55 + server: server, 56 + database: database, 57 + cors_origins: [ 58 + "https://example.com", 59 + "http://localhost:3000", 60 + ], 61 + )
+3
examples/go/go.mod
··· 1 + module github.com/nandi/glint/examples/go 2 + 3 + go 1.22
+186
examples/go/main.go
··· 1 + // Example: load a .glint config into typed Go values. 2 + // 3 + // Glint typechecks and evaluates the config; this host only projects the 4 + // resulting JSON into structs. Run from this directory: 5 + // 6 + // # needs `glint` on PATH, or GLINT=/path/to/glint 7 + // go run . 8 + // 9 + // # or evaluate with gleam from the repo (no installed binary): 10 + // GLINT_USE_GLEAM=1 go run . 11 + package main 12 + 13 + import ( 14 + "bytes" 15 + "encoding/json" 16 + "fmt" 17 + "os" 18 + "os/exec" 19 + "path/filepath" 20 + "strings" 21 + ) 22 + 23 + // JSON shape from `glint dump <file> --json`: 24 + // - record constructors → objects with "_tag" plus labeled fields 25 + // - unit variants (Info, Dev, …) → strings 26 + // - Option: None → null, Some(x) → x 27 + // - List → JSON arrays 28 + 29 + type LogLevel string 30 + 31 + const ( 32 + LogDebug LogLevel = "Debug" 33 + LogInfo LogLevel = "Info" 34 + LogWarn LogLevel = "Warn" 35 + LogError LogLevel = "Error" 36 + ) 37 + 38 + type Database struct { 39 + Host string `json:"host"` 40 + Port int `json:"port"` 41 + Name string `json:"name"` 42 + SSL bool `json:"ssl"` 43 + Password *string `json:"password"` // Option(String): null | string 44 + } 45 + 46 + type Server struct { 47 + Host string `json:"host"` 48 + Port int `json:"port"` 49 + Workers int `json:"workers"` 50 + } 51 + 52 + type Config struct { 53 + Name string `json:"name"` 54 + LogLevel LogLevel `json:"log_level"` 55 + Server Server `json:"server"` 56 + Database Database `json:"database"` 57 + CORSOrigins []string `json:"cors_origins"` 58 + } 59 + 60 + func main() { 61 + path := "config.glint" 62 + if len(os.Args) > 1 { 63 + path = os.Args[1] 64 + } 65 + 66 + cfg, err := Load[Config](path) 67 + if err != nil { 68 + fmt.Fprintf(os.Stderr, "load %s: %v\n", path, err) 69 + os.Exit(1) 70 + } 71 + 72 + fmt.Printf("%s log=%s\n", cfg.Name, cfg.LogLevel) 73 + fmt.Printf("server %s:%d workers=%d\n", 74 + cfg.Server.Host, cfg.Server.Port, cfg.Server.Workers) 75 + fmt.Printf("db %s@%s:%d ssl=%v\n", 76 + cfg.Database.Name, cfg.Database.Host, cfg.Database.Port, cfg.Database.SSL) 77 + if cfg.Database.Password == nil { 78 + fmt.Println("password (none)") 79 + } else { 80 + fmt.Printf("password %q\n", *cfg.Database.Password) 81 + } 82 + fmt.Printf("cors %s\n", strings.Join(cfg.CORSOrigins, ", ")) 83 + } 84 + 85 + // Load runs `glint dump path --json` and unmarshals into T. 86 + func Load[T any](path string) (T, error) { 87 + var zero T 88 + 89 + abs, err := filepath.Abs(path) 90 + if err != nil { 91 + return zero, err 92 + } 93 + 94 + raw, err := dumpJSON(abs) 95 + if err != nil { 96 + return zero, err 97 + } 98 + 99 + var out T 100 + if err := json.Unmarshal(raw, &out); err != nil { 101 + return zero, fmt.Errorf("decode json: %w\njson: %s", err, raw) 102 + } 103 + return out, nil 104 + } 105 + 106 + func dumpJSON(path string) ([]byte, error) { 107 + cmd, err := glintDumpCmd(path) 108 + if err != nil { 109 + return nil, err 110 + } 111 + var stdout, stderr bytes.Buffer 112 + cmd.Stdout = &stdout 113 + cmd.Stderr = &stderr 114 + if err := cmd.Run(); err != nil { 115 + msg := strings.TrimSpace(stderr.String()) 116 + if msg == "" { 117 + msg = err.Error() 118 + } 119 + return nil, fmt.Errorf("glint dump: %s", msg) 120 + } 121 + return bytes.TrimSpace(stdout.Bytes()), nil 122 + } 123 + 124 + // glintDumpCmd prefers an installed binary, then falls back to gleam from the monorepo. 125 + func glintDumpCmd(path string) (*exec.Cmd, error) { 126 + if os.Getenv("GLINT_USE_GLEAM") != "" { 127 + return gleamDumpCmd(path) 128 + } 129 + 130 + bin := os.Getenv("GLINT") 131 + if bin == "" { 132 + if p, err := exec.LookPath("glint"); err == nil { 133 + bin = p 134 + } 135 + } 136 + if bin != "" { 137 + return exec.Command(bin, "dump", path, "--json"), nil 138 + } 139 + 140 + // No binary: try the glint checkout two levels up (examples/go → repo root). 141 + return gleamDumpCmd(path) 142 + } 143 + 144 + func gleamDumpCmd(path string) (*exec.Cmd, error) { 145 + root, err := glintRepoRoot() 146 + if err != nil { 147 + return nil, fmt.Errorf( 148 + "glint not on PATH (set GLINT=… or install the binary); gleam fallback: %w", 149 + err, 150 + ) 151 + } 152 + // JavaScript target works without a local Erlang install. 153 + cmd := exec.Command( 154 + "gleam", "run", "--target", "javascript", "--", 155 + "dump", path, "--json", 156 + ) 157 + cmd.Dir = root 158 + return cmd, nil 159 + } 160 + 161 + func glintRepoRoot() (string, error) { 162 + // Prefer walking up from the config / cwd looking for gleam.toml named glint. 163 + wd, err := os.Getwd() 164 + if err != nil { 165 + return "", err 166 + } 167 + dir := wd 168 + for { 169 + gt := filepath.Join(dir, "gleam.toml") 170 + if b, err := os.ReadFile(gt); err == nil && bytes.Contains(b, []byte(`name = "glint"`)) { 171 + return dir, nil 172 + } 173 + parent := filepath.Dir(dir) 174 + if parent == dir { 175 + break 176 + } 177 + dir = parent 178 + } 179 + // examples/go → ../.. 180 + cand := filepath.Clean(filepath.Join(wd, "../..")) 181 + if b, err := os.ReadFile(filepath.Join(cand, "gleam.toml")); err == nil && 182 + bytes.Contains(b, []byte(`name = "glint"`)) { 183 + return cand, nil 184 + } 185 + return "", fmt.Errorf("could not find glint repo root from %s", wd) 186 + }
+4 -4
flake.lock
··· 31 31 "systems": "systems" 32 32 }, 33 33 "locked": { 34 - "lastModified": 1785056822, 35 - "narHash": "sha256-9fz16KwNDe5bvYSxO6Ap9mz3dNj6Jizi+GeitKvyjGE=", 34 + "lastModified": 1785096518, 35 + "narHash": "sha256-MC3ylSxtDC8+kKWwXDbhX0FUC+agvrcDqYh/4Ivph00=", 36 36 "ref": "wasm", 37 - "rev": "b81d39c1d98f15c4dc7846d277568ab1d62d94a5", 38 - "revCount": 9825, 37 + "rev": "8d88cf3a24467e88bd21bbed8d97d08abfbc5dc4", 38 + "revCount": 11287, 39 39 "type": "git", 40 40 "url": "https://tangled.org/nandi.uk/gleam" 41 41 },
+31
src/glint/check.gleam
··· 46 46 Checked(config: Value, config_type: Type) 47 47 } 48 48 49 + /// Constructor metadata for hover / tooling. 50 + pub type Ctor { 51 + Ctor(type_name: String, fields: List(#(String, Type))) 52 + } 53 + 54 + /// Symbol table after type collection and `let` evaluation. 55 + /// Unlike `check`, does **not** require `pub let config`. 56 + pub type Symbols { 57 + Symbols( 58 + vars: Dict(String, #(Type, Value)), 59 + ctors: Dict(String, Ctor), 60 + /// Type name → constructor names. 61 + types: Dict(String, List(String)), 62 + ) 63 + } 64 + 49 65 fn err(message: String) -> CheckError { 50 66 CheckError(message, 0, 0) 51 67 } ··· 58 74 Error(_) -> 59 75 Error(err("missing root export: `pub let config` is required")) 60 76 } 77 + } 78 + 79 + /// Build a symbol table for hover and other IDE features. 80 + pub fn symbols(program: Program) -> Result(Symbols, CheckError) { 81 + use env <- result.try(collect_types(program.statements, empty_env())) 82 + use env <- result.try(eval_lets(program.statements, env)) 83 + Ok(env_to_symbols(env)) 84 + } 85 + 86 + fn env_to_symbols(env: Env) -> Symbols { 87 + let ctors = 88 + dict.map_values(env.ctors, fn(_name, info) { 89 + Ctor(type_name: info.type_name, fields: info.fields) 90 + }) 91 + Symbols(vars: env.vars, ctors:, types: env.types) 61 92 } 62 93 63 94 fn empty_env() -> Env {
+357
src/glint/lsp/hover.gleam
··· 1 + //// Resolve hover content for a cursor position in a Glint document. 2 + 3 + import gleam/dict 4 + import gleam/list 5 + import gleam/option.{type Option, None, Some} 6 + import gleam/string 7 + import glint/check 8 + import glint/lexer 9 + import glint/lsp/protocol 10 + import glint/parser 11 + import glint/position 12 + import glint/token 13 + import glint/value 14 + 15 + /// Look up hover info at an LSP position in `source`. 16 + /// 17 + /// Returns `None` when there is nothing useful to show (whitespace, 18 + /// punctuation, unknown name with no analysis). 19 + pub fn at( 20 + source: String, 21 + line: Int, 22 + character: Int, 23 + ) -> Option(protocol.Hover) { 24 + let offset = 25 + position.position_to_offset( 26 + source, 27 + position.Position(line:, character:), 28 + ) 29 + case token_at(source, offset) { 30 + None -> None 31 + Some(#(name, start, end)) -> { 32 + let range = 33 + protocol.range_to_json(position.range_from_offsets(source, start, end)) 34 + case describe(source, name) { 35 + None -> None 36 + Some(markdown) -> 37 + Some(protocol.Hover( 38 + contents: protocol.MarkupContent( 39 + kind: "markdown", 40 + value: markdown, 41 + ), 42 + range: Some(range), 43 + )) 44 + } 45 + } 46 + } 47 + } 48 + 49 + fn token_at( 50 + source: String, 51 + offset: Int, 52 + ) -> Option(#(String, Int, Int)) { 53 + case lexer.lex(source) { 54 + Error(_) -> word_at(source, offset) 55 + Ok(tokens) -> 56 + case find_name_token(tokens, offset) { 57 + Some(hit) -> Some(hit) 58 + None -> word_at(source, offset) 59 + } 60 + } 61 + } 62 + 63 + fn find_name_token( 64 + tokens: List(lexer.Spanned), 65 + offset: Int, 66 + ) -> Option(#(String, Int, Int)) { 67 + case tokens { 68 + [] -> None 69 + [sp, ..rest] -> { 70 + let covers = 71 + offset >= sp.start 72 + && { offset < sp.end || { sp.start == sp.end && offset == sp.start } } 73 + case covers { 74 + True -> 75 + case name_of(sp.token) { 76 + Some(name) -> Some(#(name, sp.start, sp.end)) 77 + None -> None 78 + } 79 + False -> find_name_token(rest, offset) 80 + } 81 + } 82 + } 83 + } 84 + 85 + fn name_of(tok: token.Token) -> Option(String) { 86 + case tok { 87 + token.Ident(name) -> Some(name) 88 + token.Type -> Some("type") 89 + token.Let -> Some("let") 90 + token.Pub -> Some("pub") 91 + token.True -> Some("True") 92 + token.False -> Some("False") 93 + token.None -> Some("None") 94 + token.Some -> Some("Some") 95 + _ -> None 96 + } 97 + } 98 + 99 + /// Fallback when lex fails: take identifier-ish graphemes under the cursor. 100 + fn word_at(source: String, offset: Int) -> Option(#(String, Int, Int)) { 101 + let graphemes = string.to_graphemes(source) 102 + let len = list.length(graphemes) 103 + let at = case offset >= len { 104 + True -> 105 + case len == 0 { 106 + True -> 0 107 + False -> len - 1 108 + } 109 + False -> 110 + case offset < 0 { 111 + True -> 0 112 + False -> offset 113 + } 114 + } 115 + case grapheme_at(graphemes, at) { 116 + None -> None 117 + Some(c) -> 118 + case is_ident_char(c) { 119 + False -> None 120 + True -> { 121 + let start = scan_left(graphemes, at) 122 + let end = scan_right(graphemes, at) 123 + let name = 124 + graphemes 125 + |> list.drop(start) 126 + |> list.take(end - start) 127 + |> string.concat 128 + case name == "" { 129 + True -> None 130 + False -> Some(#(name, start, end)) 131 + } 132 + } 133 + } 134 + } 135 + } 136 + 137 + fn grapheme_at(graphemes: List(String), index: Int) -> Option(String) { 138 + case list.drop(graphemes, index) { 139 + [c, ..] -> Some(c) 140 + [] -> None 141 + } 142 + } 143 + 144 + fn scan_left(graphemes: List(String), index: Int) -> Int { 145 + case index <= 0 { 146 + True -> 0 147 + False -> 148 + case grapheme_at(graphemes, index - 1) { 149 + Some(c) -> 150 + case is_ident_char(c) { 151 + True -> scan_left(graphemes, index - 1) 152 + False -> index 153 + } 154 + None -> index 155 + } 156 + } 157 + } 158 + 159 + fn scan_right(graphemes: List(String), index: Int) -> Int { 160 + case grapheme_at(graphemes, index) { 161 + Some(c) -> 162 + case is_ident_char(c) { 163 + True -> scan_right(graphemes, index + 1) 164 + False -> index 165 + } 166 + None -> index 167 + } 168 + } 169 + 170 + fn is_ident_char(c: String) -> Bool { 171 + case c { 172 + "_" -> True 173 + _ -> { 174 + let code = string.to_utf_codepoints(c) 175 + case code { 176 + [cp] -> { 177 + let n = string.utf_codepoint_to_int(cp) 178 + { n >= 65 && n <= 90 } 179 + || { n >= 97 && n <= 122 } 180 + || { n >= 48 && n <= 57 } 181 + } 182 + _ -> False 183 + } 184 + } 185 + } 186 + } 187 + 188 + fn describe(source: String, name: String) -> Option(String) { 189 + case builtin(name) { 190 + Some(md) -> Some(md) 191 + None -> 192 + case analyse_symbols(source) { 193 + None -> None 194 + Some(syms) -> lookup_symbol(syms, name) 195 + } 196 + } 197 + } 198 + 199 + fn analyse_symbols(source: String) -> Option(check.Symbols) { 200 + case lexer.lex(source) { 201 + Error(_) -> None 202 + Ok(tokens) -> 203 + case parser.parse(tokens) { 204 + Error(_) -> None 205 + Ok(program) -> 206 + case check.symbols(program) { 207 + Ok(syms) -> Some(syms) 208 + Error(_) -> None 209 + } 210 + } 211 + } 212 + } 213 + 214 + fn lookup_symbol(syms: check.Symbols, name: String) -> Option(String) { 215 + case dict.get(syms.vars, name) { 216 + Ok(#(ty, val)) -> 217 + Some(variable_markdown(name, ty, val)) 218 + Error(_) -> 219 + case dict.get(syms.ctors, name) { 220 + Ok(ctor) -> Some(ctor_markdown(name, ctor)) 221 + Error(_) -> 222 + case dict.get(syms.types, name) { 223 + Ok(ctors) -> Some(type_markdown(name, ctors, syms)) 224 + Error(_) -> field_markdown(syms, name) 225 + } 226 + } 227 + } 228 + } 229 + 230 + fn variable_markdown( 231 + name: String, 232 + ty: check.Type, 233 + val: value.Value, 234 + ) -> String { 235 + let ty_s = check.type_to_string(ty) 236 + let val_s = value.to_glint(val) 237 + "**`" 238 + <> name 239 + <> "`**: `" 240 + <> ty_s 241 + <> "`\n\n```glint\n" 242 + <> name 243 + <> " = " 244 + <> val_s 245 + <> "\n```" 246 + } 247 + 248 + fn ctor_markdown(name: String, ctor: check.Ctor) -> String { 249 + case ctor.fields { 250 + [] -> 251 + "**`" 252 + <> name 253 + <> "`**\n\nUnit constructor of type `" 254 + <> ctor.type_name 255 + <> "`" 256 + fields -> { 257 + let sig = format_ctor_sig(name, fields) 258 + "**`" 259 + <> name 260 + <> "`**\n\nConstructor of type `" 261 + <> ctor.type_name 262 + <> "`\n\n```glint\n" 263 + <> sig 264 + <> "\n```" 265 + } 266 + } 267 + } 268 + 269 + fn type_markdown( 270 + name: String, 271 + ctors: List(String), 272 + syms: check.Symbols, 273 + ) -> String { 274 + let variants = 275 + list.map(ctors, fn(c) { 276 + case dict.get(syms.ctors, c) { 277 + Ok(check.Ctor(_, fields)) -> format_ctor_sig(c, fields) 278 + Error(_) -> c 279 + } 280 + }) 281 + |> string.join("\n") 282 + "**type `" 283 + <> name 284 + <> "`**\n\n```glint\ntype " 285 + <> name 286 + <> " {\n " 287 + <> string.replace(variants, each: "\n", with: "\n ") 288 + <> "\n}\n```" 289 + } 290 + 291 + fn format_ctor_sig(name: String, fields: List(#(String, check.Type))) -> String { 292 + case fields { 293 + [] -> name 294 + _ -> { 295 + let body = 296 + fields 297 + |> list.map(fn(f) { 298 + let #(label, ty) = f 299 + label <> ": " <> check.type_to_string(ty) 300 + }) 301 + |> string.join(", ") 302 + name <> "(" <> body <> ")" 303 + } 304 + } 305 + } 306 + 307 + fn field_markdown(syms: check.Symbols, name: String) -> Option(String) { 308 + let matches = 309 + dict.to_list(syms.ctors) 310 + |> list.filter_map(fn(pair) { 311 + let #(ctor_name, ctor) = pair 312 + case list.key_find(ctor.fields, name) { 313 + Ok(ty) -> 314 + Ok( 315 + "`" 316 + <> ctor_name 317 + <> "." 318 + <> name 319 + <> "`: `" 320 + <> check.type_to_string(ty) 321 + <> "`", 322 + ) 323 + Error(_) -> Error(Nil) 324 + } 325 + }) 326 + case matches { 327 + [] -> None 328 + lines -> 329 + Some( 330 + "**field `" 331 + <> name 332 + <> "`**\n\n" 333 + <> string.join(lines, "\n"), 334 + ) 335 + } 336 + } 337 + 338 + fn builtin(name: String) -> Option(String) { 339 + case name { 340 + "String" -> Some("**`String`**\n\nBuilt-in string type.") 341 + "Int" -> Some("**`Int`**\n\nBuilt-in integer type.") 342 + "Float" -> Some("**`Float`**\n\nBuilt-in floating-point type.") 343 + "Bool" -> Some("**`Bool`**\n\nBuilt-in boolean type (`True` | `False`).") 344 + "List" -> Some("**`List(a)`**\n\nBuilt-in list type.") 345 + "Option" -> 346 + Some("**`Option(a)`**\n\nBuilt-in optional type (`None` | `Some(a)`).") 347 + "True" -> Some("**`True`**: `Bool`") 348 + "False" -> Some("**`False`**: `Bool`") 349 + "None" -> Some("**`None`**: `Option(a)`") 350 + "Some" -> Some("**`Some(value)`**: `Option(a)`") 351 + "type" -> Some("**`type`**\n\nDeclare a custom type and its constructors.") 352 + "let" -> Some("**`let`**\n\nBind a name to a value.") 353 + "pub" -> 354 + Some("**`pub`**\n\nExport a binding. The root export is `pub let config`.") 355 + _ -> None 356 + } 357 + }
+45
src/glint/lsp/protocol.gleam
··· 2 2 3 3 import gleam/dynamic/decode 4 4 import gleam/json 5 + import gleam/list 5 6 import gleam/option.{type Option, None, Some} 6 7 import glint/position.{type Position, type Range} 7 8 ··· 60 61 DidCloseParams(text_document: TextDocumentIdentifier) 61 62 } 62 63 64 + pub type HoverParams { 65 + HoverParams(text_document: TextDocumentIdentifier, position: PositionJson) 66 + } 67 + 68 + pub type MarkupContent { 69 + MarkupContent(kind: String, value: String) 70 + } 71 + 72 + pub type Hover { 73 + Hover(contents: MarkupContent, range: Option(RangeJson)) 74 + } 75 + 63 76 // ── Conversions ───────────────────────────────────────────────────── 64 77 65 78 pub fn position_to_json(pos: Position) -> PositionJson { ··· 119 132 // textDocumentSync: 1 = Full 120 133 json.object([ 121 134 #("textDocumentSync", json.int(1)), 135 + #("hoverProvider", json.bool(True)), 122 136 ]) 123 137 } 124 138 139 + pub fn encode_markup_content(m: MarkupContent) -> json.Json { 140 + json.object([ 141 + #("kind", json.string(m.kind)), 142 + #("value", json.string(m.value)), 143 + ]) 144 + } 145 + 146 + pub fn encode_hover(h: Hover) -> json.Json { 147 + let base = [#("contents", encode_markup_content(h.contents))] 148 + let fields = case h.range { 149 + Some(r) -> list.append(base, [#("range", encode_range(r))]) 150 + None -> base 151 + } 152 + json.object(fields) 153 + } 154 + 125 155 pub fn encode_initialize_result() -> json.Json { 126 156 json.object([ 127 157 #("capabilities", encode_server_capabilities()), ··· 239 269 use uri <- decode.subfield(["textDocument", "uri"], decode.string) 240 270 decode.success(DidCloseParams(text_document: TextDocumentIdentifier(uri:))) 241 271 } 272 + 273 + pub fn position_json_decoder() -> decode.Decoder(PositionJson) { 274 + use line <- decode.field("line", decode.int) 275 + use character <- decode.field("character", decode.int) 276 + decode.success(PositionJson(line:, character:)) 277 + } 278 + 279 + pub fn hover_params_decoder() -> decode.Decoder(HoverParams) { 280 + use uri <- decode.subfield(["textDocument", "uri"], decode.string) 281 + use position <- decode.field("position", position_json_decoder()) 282 + decode.success(HoverParams( 283 + text_document: TextDocumentIdentifier(uri:), 284 + position:, 285 + )) 286 + }
+19
src/glint/lsp/rpc.gleam
··· 198 198 Error(_) -> server.HandleResult(server:, messages: []) 199 199 } 200 200 201 + "textDocument/hover" -> 202 + case id { 203 + Some(req_id) -> 204 + case decode_params(params, protocol.hover_params_decoder()) { 205 + Ok(p) -> server.handle_hover(server, req_id, p) 206 + Error(_) -> 207 + server.HandleResult(server:, messages: [ 208 + server.Response( 209 + protocol.encode_error_response( 210 + Some(req_id), 211 + -32_602, 212 + "Invalid hover params", 213 + ), 214 + ), 215 + ]) 216 + } 217 + None -> server.HandleResult(server:, messages: []) 218 + } 219 + 201 220 _ -> server.handle_unknown_request(server, id, method) 202 221 } 203 222 }
+21
src/glint/lsp/server.gleam
··· 5 5 import gleam/list 6 6 import gleam/option.{type Option, None, Some} 7 7 import glint/lsp/diagnostics 8 + import glint/lsp/hover 8 9 import glint/lsp/protocol 9 10 10 11 pub type Server { ··· 115 116 protocol.encode_publish_diagnostics(uri, []), 116 117 )) 117 118 HandleResult(server:, messages: [clear]) 119 + } 120 + 121 + pub fn handle_hover( 122 + server: Server, 123 + id: json.Json, 124 + params: protocol.HoverParams, 125 + ) -> HandleResult { 126 + let uri = params.text_document.uri 127 + let result = case dict.get(server.documents, uri) { 128 + Error(_) -> json.null() 129 + Ok(text) -> 130 + case hover.at(text, params.position.line, params.position.character) { 131 + None -> json.null() 132 + Some(h) -> protocol.encode_hover(h) 133 + } 134 + } 135 + HandleResult( 136 + server:, 137 + messages: [Response(protocol.encode_response_ok(id, result))], 138 + ) 118 139 } 119 140 120 141 pub fn handle_unknown_request(
+56
src/glint/position.gleam
··· 50 50 Range(start: start_pos, end: end_pos) 51 51 } 52 52 53 + /// Convert a line/character position into a grapheme offset. 54 + /// 55 + /// Positions past the end of the source clamp to the source length. 56 + /// A character past the end of a line clamps to that line's end (before `\n`). 57 + pub fn position_to_offset(source: String, pos: Position) -> Int { 58 + do_position_to_offset(string.to_graphemes(source), pos.line, pos.character, 0, 0) 59 + } 60 + 61 + fn do_position_to_offset( 62 + graphemes: List(String), 63 + target_line: Int, 64 + target_character: Int, 65 + line: Int, 66 + offset: Int, 67 + ) -> Int { 68 + case line >= target_line { 69 + True -> advance_characters(graphemes, target_character, offset) 70 + False -> 71 + case graphemes { 72 + [] -> offset 73 + ["\n", ..rest] -> 74 + do_position_to_offset( 75 + rest, 76 + target_line, 77 + target_character, 78 + line + 1, 79 + offset + 1, 80 + ) 81 + [_, ..rest] -> 82 + do_position_to_offset( 83 + rest, 84 + target_line, 85 + target_character, 86 + line, 87 + offset + 1, 88 + ) 89 + } 90 + } 91 + } 92 + 93 + fn advance_characters( 94 + graphemes: List(String), 95 + remaining: Int, 96 + offset: Int, 97 + ) -> Int { 98 + case remaining <= 0 { 99 + True -> offset 100 + False -> 101 + case graphemes { 102 + [] -> offset 103 + ["\n", ..] -> offset 104 + [_, ..rest] -> advance_characters(rest, remaining - 1, offset + 1) 105 + } 106 + } 107 + } 108 + 53 109 /// Total number of graphemes in the source. 54 110 pub fn length(source: String) -> Int { 55 111 list.length(string.to_graphemes(source))
+60
test/glint_test.gleam
··· 1 + import gleam/option.{None, Some} 1 2 import gleam/string 2 3 import gleeunit 3 4 import glint 4 5 import glint/check 5 6 import glint/lsp/diagnostics 7 + import glint/lsp/hover 6 8 import glint/pipeline 7 9 import glint/position 8 10 import glint/value ··· 254 256 let assert [d] = diags 255 257 let assert True = string.contains(d.message, "type error") 256 258 } 259 + 260 + // ── Hover ─────────────────────────────────────────────────────────── 261 + 262 + pub fn position_to_offset_test() { 263 + let source = "abc\ndef" 264 + let assert 0 = 265 + position.position_to_offset(source, position.Position(0, 0)) 266 + let assert 2 = 267 + position.position_to_offset(source, position.Position(0, 2)) 268 + let assert 4 = 269 + position.position_to_offset(source, position.Position(1, 0)) 270 + let assert 6 = 271 + position.position_to_offset(source, position.Position(1, 2)) 272 + // Past end of line clamps to before newline 273 + let assert 3 = 274 + position.position_to_offset(source, position.Position(0, 99)) 275 + } 276 + 277 + pub fn hover_builtin_test() { 278 + let source = "type Config { Config(name: String) }\npub let config = Config(name: \"x\")" 279 + // Hover on `String` in the field type 280 + let assert Some(h) = hover.at(source, 0, 30) 281 + let assert True = string.contains(h.contents.value, "String") 282 + let assert True = string.contains(h.contents.value, "Built-in") 283 + } 284 + 285 + pub fn hover_variable_test() { 286 + let source = 287 + "type Mode { Dev }\ntype Config { Config(mode: Mode) }\npub let config = Config(mode: Dev)" 288 + // Find line with `config` binding — line 2 (0-based), character of `c` in config 289 + let assert Some(h) = hover.at(source, 2, 8) 290 + let assert True = string.contains(h.contents.value, "config") 291 + let assert True = string.contains(h.contents.value, "Config") 292 + } 293 + 294 + pub fn hover_constructor_test() { 295 + // `Dev` starts at character 30 on line 2: 296 + // "pub let config = Config(mode: Dev)" 297 + let source = 298 + "type Mode { Dev Prod }\ntype Config { Config(mode: Mode) }\npub let config = Config(mode: Dev)" 299 + let assert Some(h) = hover.at(source, 2, 30) 300 + let assert True = string.contains(h.contents.value, "Dev") 301 + let assert True = string.contains(h.contents.value, "Mode") 302 + } 303 + 304 + pub fn hover_type_test() { 305 + let source = 306 + "type Mode { Dev }\ntype Config { Config(mode: Mode) }\npub let config = Config(mode: Dev)" 307 + // `Mode` in `type Mode` 308 + let assert Some(h) = hover.at(source, 0, 5) 309 + let assert True = string.contains(h.contents.value, "type") 310 + let assert True = string.contains(h.contents.value, "Mode") 311 + } 312 + 313 + pub fn hover_whitespace_none_test() { 314 + let source = "type Mode { Dev }\n" 315 + let assert None = hover.at(source, 0, 4) 316 + }