lsp: type-aware field completions — scan type/let/param decls, complete on dot-access

When a document is opened or changed, scan for `type Name { field: Type }` blocks
and `let var: Type` / `fn foo(param: Type)` annotations. On completion requests,
if the text before the cursor ends with `identifier.`, look up the variable's type
and return its fields as Field (kind=5) completion items instead of the full list.
This commit is contained in:
Will Anderson
2026-05-03 16:16:11 -05:00
parent 0676725cb7
commit 71a1e41f93
3 changed files with 1560 additions and 2 deletions
Vendored Executable
BIN
View File
Binary file not shown.
+1252
View File
File diff suppressed because it is too large Load Diff
+308 -2
View File
@@ -299,6 +299,293 @@ fn lsp_builtin_find(name: String, catalog: [String]) -> String {
""
}
// Type registry & variable type map
// Key helpers for type registry and vartype map in process state.
fn lsp_types_key(uri: String, type_name: String) -> String {
"types:" + uri + ":" + type_name
}
fn lsp_vartypes_key(uri: String) -> String {
"vartypes:" + uri
}
// Scan source for `type Name { field: Type, ... }` declarations.
// Stores each type's fields in state as "field1:Type1|field2:Type2|...".
fn lsp_scan_types(uri: String, source: String) -> Void {
let lines: [String] = str_split_lines(source)
let n: Int = native_list_len(lines)
let i: Int = 0
let in_type: Bool = false
let cur_type: String = ""
let fields: String = ""
while i < n {
let line: String = str_trim(native_list_get(lines, i))
if in_type {
// Check for closing brace (end of type block)
if str_starts_with(line, "}") {
// Save accumulated fields for cur_type
state_set(lsp_types_key(uri, cur_type), fields)
let in_type = false
let cur_type = ""
let fields = ""
} else {
// Try to parse "fieldName: TypeName," or "fieldName: TypeName"
let colon_pos: Int = str_index_of(line, ":")
if colon_pos > 0 {
let raw_name: String = str_trim(str_slice(line, 0, colon_pos))
let after_colon: String = str_trim(str_slice(line, colon_pos + 1, str_len(line)))
// Strip trailing comma
let type_name: String = after_colon
let tlen: Int = str_len(after_colon)
if tlen > 0 {
let last_ch: String = str_slice(after_colon, tlen - 1, tlen)
if str_eq(last_ch, ",") {
let type_name = str_trim(str_slice(after_colon, 0, tlen - 1))
}
}
// Validate field name (non-empty, starts with letter or _)
if str_len(raw_name) > 0 {
let fc: Int = str_char_code(raw_name, 0)
let valid_start: Bool = false
if fc >= 97 { if fc <= 122 { let valid_start = true } }
if fc >= 65 { if fc <= 90 { let valid_start = true } }
if fc == 95 { let valid_start = true }
if valid_start {
if str_len(fields) > 0 {
let fields = fields + "|"
}
let fields = fields + raw_name + ":" + type_name
}
}
}
}
} else {
// Look for "type Name {" pattern
if str_starts_with(line, "type ") {
let after: String = str_slice(line, 5, str_len(line))
let brace_pos: Int = str_index_of(after, "{")
if brace_pos > 0 {
let tname: String = str_trim(str_slice(after, 0, brace_pos))
if str_len(tname) > 0 {
let in_type = true
let cur_type = tname
let fields = ""
}
}
}
}
let i = i + 1
}
}
// Look up fields for a type name. Returns "field1:Type1|field2:Type2|..." or "".
fn lsp_get_type_fields(uri: String, type_name: String) -> String {
state_get(lsp_types_key(uri, type_name))
}
// Parse a single vartype entry "varName:TypeName" extract the var name.
fn lsp_varentry_name(entry: String) -> String {
let colon: Int = str_index_of(entry, ":")
if colon <= 0 {
return ""
}
str_slice(entry, 0, colon)
}
// Parse a single vartype entry "varName:TypeName" extract the type name.
fn lsp_varentry_type(entry: String) -> String {
let colon: Int = str_index_of(entry, ":")
if colon < 0 {
return ""
}
str_slice(entry, colon + 1, str_len(entry))
}
// Scan source for variable type annotations:
// let varName: TypeName = ...
// fn foo(varName: TypeName, ...) -> RetType {
// Stores in state as "var1:Type1|var2:Type2|...".
fn lsp_scan_vartypes(uri: String, source: String) -> Void {
let lines: [String] = str_split_lines(source)
let n: Int = native_list_len(lines)
let i: Int = 0
let entries: String = ""
while i < n {
let line: String = str_trim(native_list_get(lines, i))
// Handle "let varName: TypeName" declarations
if str_starts_with(line, "let ") {
let after_let: String = str_slice(line, 4, str_len(line))
let colon_pos: Int = str_index_of(after_let, ":")
if colon_pos > 0 {
let var_name: String = str_trim(str_slice(after_let, 0, colon_pos))
let after_colon: String = str_trim(str_slice(after_let, colon_pos + 1, str_len(after_let)))
// Extract type stop at '=' or whitespace
let eq_pos: Int = str_index_of(after_colon, "=")
let raw_type: String = after_colon
if eq_pos > 0 {
let raw_type = str_trim(str_slice(after_colon, 0, eq_pos))
} else {
let sp_pos: Int = str_index_of(after_colon, " ")
if sp_pos > 0 {
let raw_type = str_trim(str_slice(after_colon, 0, sp_pos))
}
}
if str_len(var_name) > 0 {
if str_len(raw_type) > 0 {
if str_len(entries) > 0 {
let entries = entries + "|"
}
let entries = entries + var_name + ":" + raw_type
}
}
}
}
// Handle "fn name(param: Type, ...)" extract each parameter's type annotation
if str_starts_with(line, "fn ") {
let paren_open: Int = str_index_of(line, "(")
let paren_close: Int = str_last_index_of(line, ")")
if paren_open > 0 {
if paren_close > paren_open {
let params_str: String = str_slice(line, paren_open + 1, paren_close)
// Split by comma to get each param
let params: [String] = str_split(params_str, ",")
let pn: Int = native_list_len(params)
let pi: Int = 0
while pi < pn {
let param: String = str_trim(native_list_get(params, pi))
let pc: Int = str_index_of(param, ":")
if pc > 0 {
let pname: String = str_trim(str_slice(param, 0, pc))
let ptype: String = str_trim(str_slice(param, pc + 1, str_len(param)))
if str_len(pname) > 0 {
if str_len(ptype) > 0 {
if str_len(entries) > 0 {
let entries = entries + "|"
}
let entries = entries + pname + ":" + ptype
}
}
}
let pi = pi + 1
}
}
}
}
let i = i + 1
}
state_set(lsp_vartypes_key(uri), entries)
}
// Look up a variable's type. Returns type name or "".
fn lsp_get_var_type(uri: String, var_name: String) -> String {
let entries: String = state_get(lsp_vartypes_key(uri))
if str_eq(entries, "") {
return ""
}
let parts: [String] = str_split(entries, "|")
let n: Int = native_list_len(parts)
let i: Int = 0
while i < n {
let entry: String = native_list_get(parts, i)
let ename: String = lsp_varentry_name(entry)
if str_eq(ename, var_name) {
return lsp_varentry_type(entry)
}
let i = i + 1
}
""
}
// Scan both types and vartypes from source call on open/change.
fn lsp_scan_document(uri: String, source: String) -> Void {
lsp_scan_types(uri, source)
lsp_scan_vartypes(uri, source)
}
// Given a line of text up to the cursor, extract the identifier before a trailing '.'.
// e.g. "let x = user." "user"
// "foo.bar." "bar" (innermost only sufficient for one-level field access)
// Returns "" if the text doesn't end with "identifier.".
fn lsp_dot_ident_before(text: String) -> String {
let tlen: Int = str_len(text)
if tlen < 2 {
return ""
}
// Must end with '.'
let last_ch: String = str_slice(text, tlen - 1, tlen)
if !str_eq(last_ch, ".") {
return ""
}
// Find the identifier immediately before the dot
let dot_pos: Int = tlen - 1
// Walk backwards from dot_pos-1 collecting word chars
let end_idx: Int = dot_pos
let start_idx: Int = dot_pos
let going: Bool = true
while going {
if start_idx <= 0 {
let going = false
} else {
let c: Int = str_char_code(text, start_idx - 1)
let is_word: Bool = false
if c >= 97 { if c <= 122 { let is_word = true } }
if c >= 65 { if c <= 90 { let is_word = true } }
if c >= 48 { if c <= 57 { let is_word = true } }
if c == 95 { let is_word = true }
if is_word {
let start_idx = start_idx - 1
} else {
let going = false
}
}
}
if start_idx >= end_idx {
return ""
}
str_slice(text, start_idx, end_idx)
}
// Extract text on the given line up to (not including) char_no.
fn lsp_line_prefix(source: String, line_no: Int, char_no: Int) -> String {
let lines: [String] = str_split_lines(source)
if line_no >= native_list_len(lines) {
return ""
}
let line: String = native_list_get(lines, line_no)
let line_len: Int = str_len(line)
let end: Int = char_no
if end > line_len {
let end = line_len
}
str_slice(line, 0, end)
}
// Build field completion items for a given type. Returns JSON array string or "".
// Returns "" (not "[]") if no type/fields found, so caller can fall back.
fn lsp_field_completions(uri: String, type_name: String) -> String {
let fields_str: String = lsp_get_type_fields(uri, type_name)
if str_eq(fields_str, "") {
return ""
}
let field_entries: [String] = str_split(fields_str, "|")
let n: Int = native_list_len(field_entries)
let items: [String] = native_list_empty()
let i: Int = 0
while i < n {
let entry: String = native_list_get(field_entries, i)
let colon: Int = str_index_of(entry, ":")
if colon > 0 {
let fname: String = str_slice(entry, 0, colon)
let ftype: String = str_slice(entry, colon + 1, str_len(entry))
// kind=5 is Field in LSP
let items = native_list_append(items, lsp_completion_item(fname, 5, ftype))
}
let i = i + 1
}
"[" + str_join(items, ",") + "]"
}
// Source scanning
// Extract user-defined function names from source text.
@@ -415,8 +702,22 @@ fn lsp_completion_item(label: String, kind: Int, detail: String) -> String {
"{\"label\":\"" + lsp_json_escape(label) + "\",\"kind\":" + int_to_str(kind) + ",\"detail\":\"" + lsp_json_escape(detail) + "\"}"
}
fn lsp_build_completions(uri: String, catalog: [String]) -> String {
fn lsp_build_completions(uri: String, catalog: [String], line_no: Int, char_no: Int) -> String {
let source: String = lsp_doc_get(uri)
// Check for dot-access: if the text before the cursor ends with "identifier.",
// return field completions for that variable's type.
let prefix: String = lsp_line_prefix(source, line_no, char_no)
let dot_ident: String = lsp_dot_ident_before(prefix)
if !str_eq(dot_ident, "") {
// Look up the variable's type
let var_type: String = lsp_get_var_type(uri, dot_ident)
if !str_eq(var_type, "") {
let field_items: String = lsp_field_completions(uri, var_type)
if !str_eq(field_items, "") {
return field_items
}
}
}
let items: [String] = native_list_empty()
// User-defined fns (kind=3 Function)
let user_fns: [String] = lsp_extract_fns(source)
@@ -708,7 +1009,10 @@ fn lsp_handle_initialize(id: String) -> Void {
fn lsp_handle_completion(id: String, params: String, catalog: [String]) -> Void {
let td: String = json_get_raw(params, "textDocument")
let uri: String = json_get_string(td, "uri")
let items: String = lsp_build_completions(uri, catalog)
let pos: String = json_get_raw(params, "position")
let line_no: Int = json_get_int(pos, "line")
let char_no: Int = json_get_int(pos, "character")
let items: String = lsp_build_completions(uri, catalog, line_no, char_no)
let result: String = "{\"isIncomplete\":false,\"items\":" + items + "}"
lsp_write_message(lsp_response(id, result))
}
@@ -738,6 +1042,7 @@ fn lsp_handle_did_open(params: String, catalog: [String]) -> Void {
let uri: String = json_get_string(td, "uri")
let text: String = json_get_string(td, "text")
lsp_doc_store(uri, text)
lsp_scan_document(uri, text)
lsp_publish_diagnostics(uri)
}
@@ -748,6 +1053,7 @@ fn lsp_handle_did_change(params: String) -> Void {
let first: String = json_array_get(changes, 0)
let text: String = json_get_string(first, "text")
lsp_doc_store(uri, text)
lsp_scan_document(uri, text)
lsp_publish_diagnostics(uri)
}