diff --git a/README.md b/README.md index e7a92d8..6ef9ef7 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ gleam test --target javascript | Full document sync (`textDocumentSync: 1`) | yes | | `textDocument/didOpen` / `didChange` / `didClose` | yes | | `textDocument/publishDiagnostics` | yes (lex / parse / type errors) | +| `textDocument/hover` | yes (types, constructors, lets, builtins) | ### Editor config (generic) diff --git a/examples/go/config.glint b/examples/go/config.glint new file mode 100644 index 0000000..a22bd1c --- /dev/null +++ b/examples/go/config.glint @@ -0,0 +1,61 @@ +// Config for the Go host example (examples/go). + +type LogLevel { + Debug + Info + Warn + Error +} + +type Database { + Database( + host: String, + port: Int, + name: String, + ssl: Bool, + password: Option(String), + ) +} + +type Server { + Server( + host: String, + port: Int, + workers: Int, + ) +} + +type Config { + Config( + name: String, + log_level: LogLevel, + server: Server, + database: Database, + cors_origins: List(String), + ) +} + +let database = Database( + host: "localhost", + port: 5432, + name: "myapp", + ssl: True, + password: None, +) + +let server = Server( + host: "0.0.0.0", + port: 8080, + workers: 4, +) + +pub let config = Config( + name: "go-service", + log_level: Info, + server: server, + database: database, + cors_origins: [ + "https://example.com", + "http://localhost:3000", + ], +) diff --git a/examples/go/go.mod b/examples/go/go.mod new file mode 100644 index 0000000..da61788 --- /dev/null +++ b/examples/go/go.mod @@ -0,0 +1,3 @@ +module github.com/nandi/glint/examples/go + +go 1.22 diff --git a/examples/go/main.go b/examples/go/main.go new file mode 100644 index 0000000..fd60faa --- /dev/null +++ b/examples/go/main.go @@ -0,0 +1,186 @@ +// Example: load a .glint config into typed Go values. +// +// Glint typechecks and evaluates the config; this host only projects the +// resulting JSON into structs. Run from this directory: +// +// # needs `glint` on PATH, or GLINT=/path/to/glint +// go run . +// +// # or evaluate with gleam from the repo (no installed binary): +// GLINT_USE_GLEAM=1 go run . +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// JSON shape from `glint dump --json`: +// - record constructors → objects with "_tag" plus labeled fields +// - unit variants (Info, Dev, …) → strings +// - Option: None → null, Some(x) → x +// - List → JSON arrays + +type LogLevel string + +const ( + LogDebug LogLevel = "Debug" + LogInfo LogLevel = "Info" + LogWarn LogLevel = "Warn" + LogError LogLevel = "Error" +) + +type Database struct { + Host string `json:"host"` + Port int `json:"port"` + Name string `json:"name"` + SSL bool `json:"ssl"` + Password *string `json:"password"` // Option(String): null | string +} + +type Server struct { + Host string `json:"host"` + Port int `json:"port"` + Workers int `json:"workers"` +} + +type Config struct { + Name string `json:"name"` + LogLevel LogLevel `json:"log_level"` + Server Server `json:"server"` + Database Database `json:"database"` + CORSOrigins []string `json:"cors_origins"` +} + +func main() { + path := "config.glint" + if len(os.Args) > 1 { + path = os.Args[1] + } + + cfg, err := Load[Config](path) + if err != nil { + fmt.Fprintf(os.Stderr, "load %s: %v\n", path, err) + os.Exit(1) + } + + fmt.Printf("%s log=%s\n", cfg.Name, cfg.LogLevel) + fmt.Printf("server %s:%d workers=%d\n", + cfg.Server.Host, cfg.Server.Port, cfg.Server.Workers) + fmt.Printf("db %s@%s:%d ssl=%v\n", + cfg.Database.Name, cfg.Database.Host, cfg.Database.Port, cfg.Database.SSL) + if cfg.Database.Password == nil { + fmt.Println("password (none)") + } else { + fmt.Printf("password %q\n", *cfg.Database.Password) + } + fmt.Printf("cors %s\n", strings.Join(cfg.CORSOrigins, ", ")) +} + +// Load runs `glint dump path --json` and unmarshals into T. +func Load[T any](path string) (T, error) { + var zero T + + abs, err := filepath.Abs(path) + if err != nil { + return zero, err + } + + raw, err := dumpJSON(abs) + if err != nil { + return zero, err + } + + var out T + if err := json.Unmarshal(raw, &out); err != nil { + return zero, fmt.Errorf("decode json: %w\njson: %s", err, raw) + } + return out, nil +} + +func dumpJSON(path string) ([]byte, error) { + cmd, err := glintDumpCmd(path) + if err != nil { + return nil, err + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("glint dump: %s", msg) + } + return bytes.TrimSpace(stdout.Bytes()), nil +} + +// glintDumpCmd prefers an installed binary, then falls back to gleam from the monorepo. +func glintDumpCmd(path string) (*exec.Cmd, error) { + if os.Getenv("GLINT_USE_GLEAM") != "" { + return gleamDumpCmd(path) + } + + bin := os.Getenv("GLINT") + if bin == "" { + if p, err := exec.LookPath("glint"); err == nil { + bin = p + } + } + if bin != "" { + return exec.Command(bin, "dump", path, "--json"), nil + } + + // No binary: try the glint checkout two levels up (examples/go → repo root). + return gleamDumpCmd(path) +} + +func gleamDumpCmd(path string) (*exec.Cmd, error) { + root, err := glintRepoRoot() + if err != nil { + return nil, fmt.Errorf( + "glint not on PATH (set GLINT=… or install the binary); gleam fallback: %w", + err, + ) + } + // JavaScript target works without a local Erlang install. + cmd := exec.Command( + "gleam", "run", "--target", "javascript", "--", + "dump", path, "--json", + ) + cmd.Dir = root + return cmd, nil +} + +func glintRepoRoot() (string, error) { + // Prefer walking up from the config / cwd looking for gleam.toml named glint. + wd, err := os.Getwd() + if err != nil { + return "", err + } + dir := wd + for { + gt := filepath.Join(dir, "gleam.toml") + if b, err := os.ReadFile(gt); err == nil && bytes.Contains(b, []byte(`name = "glint"`)) { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + // examples/go → ../.. + cand := filepath.Clean(filepath.Join(wd, "../..")) + if b, err := os.ReadFile(filepath.Join(cand, "gleam.toml")); err == nil && + bytes.Contains(b, []byte(`name = "glint"`)) { + return cand, nil + } + return "", fmt.Errorf("could not find glint repo root from %s", wd) +} diff --git a/flake.lock b/flake.lock index bb579dc..bb3f1b4 100644 --- a/flake.lock +++ b/flake.lock @@ -31,11 +31,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1785056822, - "narHash": "sha256-9fz16KwNDe5bvYSxO6Ap9mz3dNj6Jizi+GeitKvyjGE=", + "lastModified": 1785096518, + "narHash": "sha256-MC3ylSxtDC8+kKWwXDbhX0FUC+agvrcDqYh/4Ivph00=", "ref": "wasm", - "rev": "b81d39c1d98f15c4dc7846d277568ab1d62d94a5", - "revCount": 9825, + "rev": "8d88cf3a24467e88bd21bbed8d97d08abfbc5dc4", + "revCount": 11287, "type": "git", "url": "https://tangled.org/nandi.uk/gleam" }, diff --git a/src/glint/check.gleam b/src/glint/check.gleam index 9561446..22bd9b1 100644 --- a/src/glint/check.gleam +++ b/src/glint/check.gleam @@ -46,6 +46,22 @@ pub type Checked { Checked(config: Value, config_type: Type) } +/// Constructor metadata for hover / tooling. +pub type Ctor { + Ctor(type_name: String, fields: List(#(String, Type))) +} + +/// Symbol table after type collection and `let` evaluation. +/// Unlike `check`, does **not** require `pub let config`. +pub type Symbols { + Symbols( + vars: Dict(String, #(Type, Value)), + ctors: Dict(String, Ctor), + /// Type name → constructor names. + types: Dict(String, List(String)), + ) +} + fn err(message: String) -> CheckError { CheckError(message, 0, 0) } @@ -60,6 +76,21 @@ pub fn check(program: Program) -> Result(Checked, CheckError) { } } +/// Build a symbol table for hover and other IDE features. +pub fn symbols(program: Program) -> Result(Symbols, CheckError) { + use env <- result.try(collect_types(program.statements, empty_env())) + use env <- result.try(eval_lets(program.statements, env)) + Ok(env_to_symbols(env)) +} + +fn env_to_symbols(env: Env) -> Symbols { + let ctors = + dict.map_values(env.ctors, fn(_name, info) { + Ctor(type_name: info.type_name, fields: info.fields) + }) + Symbols(vars: env.vars, ctors:, types: env.types) +} + fn empty_env() -> Env { Env(vars: dict.new(), ctors: dict.new(), types: dict.new()) } diff --git a/src/glint/lsp/hover.gleam b/src/glint/lsp/hover.gleam new file mode 100644 index 0000000..3b8f6ab --- /dev/null +++ b/src/glint/lsp/hover.gleam @@ -0,0 +1,357 @@ +//// Resolve hover content for a cursor position in a Glint document. + +import gleam/dict +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string +import glint/check +import glint/lexer +import glint/lsp/protocol +import glint/parser +import glint/position +import glint/token +import glint/value + +/// Look up hover info at an LSP position in `source`. +/// +/// Returns `None` when there is nothing useful to show (whitespace, +/// punctuation, unknown name with no analysis). +pub fn at( + source: String, + line: Int, + character: Int, +) -> Option(protocol.Hover) { + let offset = + position.position_to_offset( + source, + position.Position(line:, character:), + ) + case token_at(source, offset) { + None -> None + Some(#(name, start, end)) -> { + let range = + protocol.range_to_json(position.range_from_offsets(source, start, end)) + case describe(source, name) { + None -> None + Some(markdown) -> + Some(protocol.Hover( + contents: protocol.MarkupContent( + kind: "markdown", + value: markdown, + ), + range: Some(range), + )) + } + } + } +} + +fn token_at( + source: String, + offset: Int, +) -> Option(#(String, Int, Int)) { + case lexer.lex(source) { + Error(_) -> word_at(source, offset) + Ok(tokens) -> + case find_name_token(tokens, offset) { + Some(hit) -> Some(hit) + None -> word_at(source, offset) + } + } +} + +fn find_name_token( + tokens: List(lexer.Spanned), + offset: Int, +) -> Option(#(String, Int, Int)) { + case tokens { + [] -> None + [sp, ..rest] -> { + let covers = + offset >= sp.start + && { offset < sp.end || { sp.start == sp.end && offset == sp.start } } + case covers { + True -> + case name_of(sp.token) { + Some(name) -> Some(#(name, sp.start, sp.end)) + None -> None + } + False -> find_name_token(rest, offset) + } + } + } +} + +fn name_of(tok: token.Token) -> Option(String) { + case tok { + token.Ident(name) -> Some(name) + token.Type -> Some("type") + token.Let -> Some("let") + token.Pub -> Some("pub") + token.True -> Some("True") + token.False -> Some("False") + token.None -> Some("None") + token.Some -> Some("Some") + _ -> None + } +} + +/// Fallback when lex fails: take identifier-ish graphemes under the cursor. +fn word_at(source: String, offset: Int) -> Option(#(String, Int, Int)) { + let graphemes = string.to_graphemes(source) + let len = list.length(graphemes) + let at = case offset >= len { + True -> + case len == 0 { + True -> 0 + False -> len - 1 + } + False -> + case offset < 0 { + True -> 0 + False -> offset + } + } + case grapheme_at(graphemes, at) { + None -> None + Some(c) -> + case is_ident_char(c) { + False -> None + True -> { + let start = scan_left(graphemes, at) + let end = scan_right(graphemes, at) + let name = + graphemes + |> list.drop(start) + |> list.take(end - start) + |> string.concat + case name == "" { + True -> None + False -> Some(#(name, start, end)) + } + } + } + } +} + +fn grapheme_at(graphemes: List(String), index: Int) -> Option(String) { + case list.drop(graphemes, index) { + [c, ..] -> Some(c) + [] -> None + } +} + +fn scan_left(graphemes: List(String), index: Int) -> Int { + case index <= 0 { + True -> 0 + False -> + case grapheme_at(graphemes, index - 1) { + Some(c) -> + case is_ident_char(c) { + True -> scan_left(graphemes, index - 1) + False -> index + } + None -> index + } + } +} + +fn scan_right(graphemes: List(String), index: Int) -> Int { + case grapheme_at(graphemes, index) { + Some(c) -> + case is_ident_char(c) { + True -> scan_right(graphemes, index + 1) + False -> index + } + None -> index + } +} + +fn is_ident_char(c: String) -> Bool { + case c { + "_" -> True + _ -> { + let code = string.to_utf_codepoints(c) + case code { + [cp] -> { + let n = string.utf_codepoint_to_int(cp) + { n >= 65 && n <= 90 } + || { n >= 97 && n <= 122 } + || { n >= 48 && n <= 57 } + } + _ -> False + } + } + } +} + +fn describe(source: String, name: String) -> Option(String) { + case builtin(name) { + Some(md) -> Some(md) + None -> + case analyse_symbols(source) { + None -> None + Some(syms) -> lookup_symbol(syms, name) + } + } +} + +fn analyse_symbols(source: String) -> Option(check.Symbols) { + case lexer.lex(source) { + Error(_) -> None + Ok(tokens) -> + case parser.parse(tokens) { + Error(_) -> None + Ok(program) -> + case check.symbols(program) { + Ok(syms) -> Some(syms) + Error(_) -> None + } + } + } +} + +fn lookup_symbol(syms: check.Symbols, name: String) -> Option(String) { + case dict.get(syms.vars, name) { + Ok(#(ty, val)) -> + Some(variable_markdown(name, ty, val)) + Error(_) -> + case dict.get(syms.ctors, name) { + Ok(ctor) -> Some(ctor_markdown(name, ctor)) + Error(_) -> + case dict.get(syms.types, name) { + Ok(ctors) -> Some(type_markdown(name, ctors, syms)) + Error(_) -> field_markdown(syms, name) + } + } + } +} + +fn variable_markdown( + name: String, + ty: check.Type, + val: value.Value, +) -> String { + let ty_s = check.type_to_string(ty) + let val_s = value.to_glint(val) + "**`" + <> name + <> "`**: `" + <> ty_s + <> "`\n\n```glint\n" + <> name + <> " = " + <> val_s + <> "\n```" +} + +fn ctor_markdown(name: String, ctor: check.Ctor) -> String { + case ctor.fields { + [] -> + "**`" + <> name + <> "`**\n\nUnit constructor of type `" + <> ctor.type_name + <> "`" + fields -> { + let sig = format_ctor_sig(name, fields) + "**`" + <> name + <> "`**\n\nConstructor of type `" + <> ctor.type_name + <> "`\n\n```glint\n" + <> sig + <> "\n```" + } + } +} + +fn type_markdown( + name: String, + ctors: List(String), + syms: check.Symbols, +) -> String { + let variants = + list.map(ctors, fn(c) { + case dict.get(syms.ctors, c) { + Ok(check.Ctor(_, fields)) -> format_ctor_sig(c, fields) + Error(_) -> c + } + }) + |> string.join("\n") + "**type `" + <> name + <> "`**\n\n```glint\ntype " + <> name + <> " {\n " + <> string.replace(variants, each: "\n", with: "\n ") + <> "\n}\n```" +} + +fn format_ctor_sig(name: String, fields: List(#(String, check.Type))) -> String { + case fields { + [] -> name + _ -> { + let body = + fields + |> list.map(fn(f) { + let #(label, ty) = f + label <> ": " <> check.type_to_string(ty) + }) + |> string.join(", ") + name <> "(" <> body <> ")" + } + } +} + +fn field_markdown(syms: check.Symbols, name: String) -> Option(String) { + let matches = + dict.to_list(syms.ctors) + |> list.filter_map(fn(pair) { + let #(ctor_name, ctor) = pair + case list.key_find(ctor.fields, name) { + Ok(ty) -> + Ok( + "`" + <> ctor_name + <> "." + <> name + <> "`: `" + <> check.type_to_string(ty) + <> "`", + ) + Error(_) -> Error(Nil) + } + }) + case matches { + [] -> None + lines -> + Some( + "**field `" + <> name + <> "`**\n\n" + <> string.join(lines, "\n"), + ) + } +} + +fn builtin(name: String) -> Option(String) { + case name { + "String" -> Some("**`String`**\n\nBuilt-in string type.") + "Int" -> Some("**`Int`**\n\nBuilt-in integer type.") + "Float" -> Some("**`Float`**\n\nBuilt-in floating-point type.") + "Bool" -> Some("**`Bool`**\n\nBuilt-in boolean type (`True` | `False`).") + "List" -> Some("**`List(a)`**\n\nBuilt-in list type.") + "Option" -> + Some("**`Option(a)`**\n\nBuilt-in optional type (`None` | `Some(a)`).") + "True" -> Some("**`True`**: `Bool`") + "False" -> Some("**`False`**: `Bool`") + "None" -> Some("**`None`**: `Option(a)`") + "Some" -> Some("**`Some(value)`**: `Option(a)`") + "type" -> Some("**`type`**\n\nDeclare a custom type and its constructors.") + "let" -> Some("**`let`**\n\nBind a name to a value.") + "pub" -> + Some("**`pub`**\n\nExport a binding. The root export is `pub let config`.") + _ -> None + } +} diff --git a/src/glint/lsp/protocol.gleam b/src/glint/lsp/protocol.gleam index 2613f65..3ace661 100644 --- a/src/glint/lsp/protocol.gleam +++ b/src/glint/lsp/protocol.gleam @@ -2,6 +2,7 @@ import gleam/dynamic/decode import gleam/json +import gleam/list import gleam/option.{type Option, None, Some} import glint/position.{type Position, type Range} @@ -60,6 +61,18 @@ pub type DidCloseParams { DidCloseParams(text_document: TextDocumentIdentifier) } +pub type HoverParams { + HoverParams(text_document: TextDocumentIdentifier, position: PositionJson) +} + +pub type MarkupContent { + MarkupContent(kind: String, value: String) +} + +pub type Hover { + Hover(contents: MarkupContent, range: Option(RangeJson)) +} + // ── Conversions ───────────────────────────────────────────────────── pub fn position_to_json(pos: Position) -> PositionJson { @@ -119,9 +132,26 @@ pub fn encode_server_capabilities() -> json.Json { // textDocumentSync: 1 = Full json.object([ #("textDocumentSync", json.int(1)), + #("hoverProvider", json.bool(True)), + ]) +} + +pub fn encode_markup_content(m: MarkupContent) -> json.Json { + json.object([ + #("kind", json.string(m.kind)), + #("value", json.string(m.value)), ]) } +pub fn encode_hover(h: Hover) -> json.Json { + let base = [#("contents", encode_markup_content(h.contents))] + let fields = case h.range { + Some(r) -> list.append(base, [#("range", encode_range(r))]) + None -> base + } + json.object(fields) +} + pub fn encode_initialize_result() -> json.Json { json.object([ #("capabilities", encode_server_capabilities()), @@ -239,3 +269,18 @@ pub fn did_close_decoder() -> decode.Decoder(DidCloseParams) { use uri <- decode.subfield(["textDocument", "uri"], decode.string) decode.success(DidCloseParams(text_document: TextDocumentIdentifier(uri:))) } + +pub fn position_json_decoder() -> decode.Decoder(PositionJson) { + use line <- decode.field("line", decode.int) + use character <- decode.field("character", decode.int) + decode.success(PositionJson(line:, character:)) +} + +pub fn hover_params_decoder() -> decode.Decoder(HoverParams) { + use uri <- decode.subfield(["textDocument", "uri"], decode.string) + use position <- decode.field("position", position_json_decoder()) + decode.success(HoverParams( + text_document: TextDocumentIdentifier(uri:), + position:, + )) +} diff --git a/src/glint/lsp/rpc.gleam b/src/glint/lsp/rpc.gleam index bf3647e..fb995f1 100644 --- a/src/glint/lsp/rpc.gleam +++ b/src/glint/lsp/rpc.gleam @@ -198,6 +198,25 @@ fn route( Error(_) -> server.HandleResult(server:, messages: []) } + "textDocument/hover" -> + case id { + Some(req_id) -> + case decode_params(params, protocol.hover_params_decoder()) { + Ok(p) -> server.handle_hover(server, req_id, p) + Error(_) -> + server.HandleResult(server:, messages: [ + server.Response( + protocol.encode_error_response( + Some(req_id), + -32_602, + "Invalid hover params", + ), + ), + ]) + } + None -> server.HandleResult(server:, messages: []) + } + _ -> server.handle_unknown_request(server, id, method) } } diff --git a/src/glint/lsp/server.gleam b/src/glint/lsp/server.gleam index 64091d2..cc971b1 100644 --- a/src/glint/lsp/server.gleam +++ b/src/glint/lsp/server.gleam @@ -5,6 +5,7 @@ import gleam/json import gleam/list import gleam/option.{type Option, None, Some} import glint/lsp/diagnostics +import glint/lsp/hover import glint/lsp/protocol pub type Server { @@ -117,6 +118,26 @@ pub fn handle_did_close( HandleResult(server:, messages: [clear]) } +pub fn handle_hover( + server: Server, + id: json.Json, + params: protocol.HoverParams, +) -> HandleResult { + let uri = params.text_document.uri + let result = case dict.get(server.documents, uri) { + Error(_) -> json.null() + Ok(text) -> + case hover.at(text, params.position.line, params.position.character) { + None -> json.null() + Some(h) -> protocol.encode_hover(h) + } + } + HandleResult( + server:, + messages: [Response(protocol.encode_response_ok(id, result))], + ) +} + pub fn handle_unknown_request( server: Server, id: Option(json.Json), diff --git a/src/glint/position.gleam b/src/glint/position.gleam index b8a3581..d2d91c7 100644 --- a/src/glint/position.gleam +++ b/src/glint/position.gleam @@ -50,6 +50,62 @@ pub fn range_from_offsets(source: String, start: Int, end: Int) -> Range { Range(start: start_pos, end: end_pos) } +/// Convert a line/character position into a grapheme offset. +/// +/// Positions past the end of the source clamp to the source length. +/// A character past the end of a line clamps to that line's end (before `\n`). +pub fn position_to_offset(source: String, pos: Position) -> Int { + do_position_to_offset(string.to_graphemes(source), pos.line, pos.character, 0, 0) +} + +fn do_position_to_offset( + graphemes: List(String), + target_line: Int, + target_character: Int, + line: Int, + offset: Int, +) -> Int { + case line >= target_line { + True -> advance_characters(graphemes, target_character, offset) + False -> + case graphemes { + [] -> offset + ["\n", ..rest] -> + do_position_to_offset( + rest, + target_line, + target_character, + line + 1, + offset + 1, + ) + [_, ..rest] -> + do_position_to_offset( + rest, + target_line, + target_character, + line, + offset + 1, + ) + } + } +} + +fn advance_characters( + graphemes: List(String), + remaining: Int, + offset: Int, +) -> Int { + case remaining <= 0 { + True -> offset + False -> + case graphemes { + [] -> offset + ["\n", ..] -> offset + [_, ..rest] -> advance_characters(rest, remaining - 1, offset + 1) + } + } +} + /// Total number of graphemes in the source. pub fn length(source: String) -> Int { list.length(string.to_graphemes(source)) diff --git a/test/glint_test.gleam b/test/glint_test.gleam index 0c0efa1..2d01bc3 100644 --- a/test/glint_test.gleam +++ b/test/glint_test.gleam @@ -1,8 +1,10 @@ +import gleam/option.{None, Some} import gleam/string import gleeunit import glint import glint/check import glint/lsp/diagnostics +import glint/lsp/hover import glint/pipeline import glint/position import glint/value @@ -254,3 +256,61 @@ pub let config = Config(port: \"nope\") let assert [d] = diags let assert True = string.contains(d.message, "type error") } + +// ── Hover ─────────────────────────────────────────────────────────── + +pub fn position_to_offset_test() { + let source = "abc\ndef" + let assert 0 = + position.position_to_offset(source, position.Position(0, 0)) + let assert 2 = + position.position_to_offset(source, position.Position(0, 2)) + let assert 4 = + position.position_to_offset(source, position.Position(1, 0)) + let assert 6 = + position.position_to_offset(source, position.Position(1, 2)) + // Past end of line clamps to before newline + let assert 3 = + position.position_to_offset(source, position.Position(0, 99)) +} + +pub fn hover_builtin_test() { + let source = "type Config { Config(name: String) }\npub let config = Config(name: \"x\")" + // Hover on `String` in the field type + let assert Some(h) = hover.at(source, 0, 30) + let assert True = string.contains(h.contents.value, "String") + let assert True = string.contains(h.contents.value, "Built-in") +} + +pub fn hover_variable_test() { + let source = + "type Mode { Dev }\ntype Config { Config(mode: Mode) }\npub let config = Config(mode: Dev)" + // Find line with `config` binding — line 2 (0-based), character of `c` in config + let assert Some(h) = hover.at(source, 2, 8) + let assert True = string.contains(h.contents.value, "config") + let assert True = string.contains(h.contents.value, "Config") +} + +pub fn hover_constructor_test() { + // `Dev` starts at character 30 on line 2: + // "pub let config = Config(mode: Dev)" + let source = + "type Mode { Dev Prod }\ntype Config { Config(mode: Mode) }\npub let config = Config(mode: Dev)" + let assert Some(h) = hover.at(source, 2, 30) + let assert True = string.contains(h.contents.value, "Dev") + let assert True = string.contains(h.contents.value, "Mode") +} + +pub fn hover_type_test() { + let source = + "type Mode { Dev }\ntype Config { Config(mode: Mode) }\npub let config = Config(mode: Dev)" + // `Mode` in `type Mode` + let assert Some(h) = hover.at(source, 0, 5) + let assert True = string.contains(h.contents.value, "type") + let assert True = string.contains(h.contents.value, "Mode") +} + +pub fn hover_whitespace_none_test() { + let source = "type Mode { Dev }\n" + let assert None = hover.at(source, 0, 4) +}