From 08c5ee74780b46ccf5dd39a98cc4e9602cf51fa0 Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Wed, 11 Mar 2026 19:40:02 -0400 Subject: [PATCH 1/2] feat: added inlay hints --- handlers.v | 357 ++++++++++++++++++++++++++++++++++ handlers_test.v | 499 ++++++++++++++++++++++++++++++++++++++++++++++++ lsp.v | 14 ++ main.v | 5 + 4 files changed, 875 insertions(+) diff --git a/handlers.v b/handlers.v index be0ec5b5..d50bf824 100644 --- a/handlers.v +++ b/handlers.v @@ -273,6 +273,363 @@ fn (mut app App) handle_document_symbols(request Request) Response { } } +// handle_inlay_hints returns type inlay hints for `:=` declarations within the +// requested range whose RHS is a recognizable literal (int, f64, string, bool). +fn (mut app App) handle_inlay_hints(request Request) Response { + uri := request.params.text_document.uri + content := app.open_files[uri] or { '' } + lines := content.split_into_lines() + + start_line := request.params.range.start.line + end_line := request.params.range.end.line + + // Build fn index lazily: current file + open files + vlib modules imported in this file + file_path := uri_to_path(uri) + working_dir := os.dir(file_path) + mut index_files := []string{} + + // Collect all open file paths + for open_uri, _ in app.open_files { + p := uri_to_path(open_uri) + if p != '' && p != file_path { + index_files << p + } + } + + // Only scan project directory if working_dir is a real, accessible directory. + // Guard against fake URIs (e.g. tests using file:///test.v) which resolve + // working_dir to '/' and would cause a full filesystem walk. + if working_dir != '' && working_dir != '/' && os.is_dir(working_dir) { + project_files := os.walk_ext(working_dir, '.v') + for pf in project_files { + if !pf.ends_with('_test.v') && pf != file_path { + index_files << pf + } + } + + // Add vlib modules imported by this file + vroot := find_vroot() + if vroot != '' { + imported_mods := parse_imports(content) + for mod in imported_mods { + mod_path := mod.replace('.', '/') + vlib_mod_dir := os.join_path(vroot, 'vlib', mod_path) + if os.is_dir(vlib_mod_dir) { + vlib_files := os.walk_ext(vlib_mod_dir, '.v') + for vf in vlib_files { + if !vf.ends_with('_test.v') { + index_files << vf + } + } + } + } + } + } + + mut fn_index := build_fn_index(index_files) + // Also index functions defined in the current file (in-memory content). + parse_fn_signatures_into(content, '', mut fn_index) + + mut hints := []InlayHint{} + mut in_const_block := false + for line_idx in start_line .. (end_line + 1) { + if line_idx >= lines.len { + break + } + raw := lines[line_idx] + trimmed := raw.trim_space() + + // Skip comments and blank lines + if trimmed == '' || trimmed.starts_with('//') { + continue + } + + // Track const block boundaries + if trimmed == 'const (' { + in_const_block = true + continue + } + if in_const_block && trimmed == ')' { + in_const_block = false + continue + } + + mut var_name := '' + mut rhs := '' + + if in_const_block { + // Inside `const (` block: lines look like `name = value` + eq_idx := trimmed.index(' = ') or { continue } + var_name = trimmed[..eq_idx].trim_space() + rhs = trimmed[eq_idx + 3..].trim_space() + } else if trimmed.starts_with('const ') && trimmed.contains(' = ') { + // Single-line const: `const name = value` + after_const := trimmed[6..] + eq_idx := after_const.index(' = ') or { continue } + var_name = after_const[..eq_idx].trim_space() + rhs = after_const[eq_idx + 3..].trim_space() + } else { + // Short variable declaration: `name := value` or `mut name := value` + assign_idx := trimmed.index(' := ') or { continue } + lhs := trimmed[..assign_idx].trim_space() + rhs = trimmed[assign_idx + 4..].trim_space() + var_name = lhs + if lhs.starts_with('mut ') { + var_name = lhs[4..].trim_space() + } + } + + // Skip multi-assignment or invalid identifiers + if var_name.contains(' ') || var_name.contains(',') || var_name == '' { + continue + } + + // Strip error-handling suffix from RHS: `os.read_file(p) or { [] }` → `os.read_file(p)` + mut clean_rhs := rhs + if or_idx := rhs.index(' or ') { + clean_rhs = rhs[..or_idx].trim_space() + } + if q_idx := rhs.index(' ?') { + _ = q_idx // optional chaining — leave as is + } + + // Try literal inference first, then fn index lookup + mut inferred := infer_type_from_literal(clean_rhs) + if inferred == '' { + inferred = lookup_fn_return_type(clean_rhs, fn_index) + // Strip result/optional prefix for display: `!string` → `string`, `?string` → `?string` + if inferred.starts_with('!') { + inferred = inferred[1..] + } + } + if inferred == '' { + continue + } + + // Position the hint right after the variable name in the raw line + name_col := raw.index(var_name) or { continue } + hints << InlayHint{ + position: Position{ + line: line_idx + char: name_col + var_name.len + } + label: ': ${inferred}' + kind: inlay_hint_kind_type + padding_left: false + } + } + + return Response{ + id: request.id + result: hints + } +} + +// infer_type_from_literal returns the V type name for a simple literal RHS value, +// or '' if the type cannot be determined without compiler assistance. +fn infer_type_from_literal(rhs string) string { + r := rhs.trim_space() + if r == '' { + return '' + } + // Boolean + if r == 'true' || r == 'false' { + return 'bool' + } + // String literals: single-quote, double-quote, or backtick + first := r[0] + if first == `'` || first == `"` || first == '`'[0] { + return 'string' + } + // Already explicitly typed (struct/array/map init): skip + if r.contains('{') || r.contains('[') { + return '' + } + // Float literal: contains a '.' and digits only + if r.contains('.') { + mut is_float := true + for c in r { + if !((c >= `0` && c <= `9`) || c == `.` || c == `-` || c == `_`) { + is_float = false + break + } + } + if is_float { + return 'f64' + } + } + // Integer literal: hex (0x), octal (0o), binary (0b), or plain digits + if r.starts_with('0x') || r.starts_with('0X') || r.starts_with('0o') + || r.starts_with('0b') { + return 'int' + } + mut is_int := true + for c in r { + if !((c >= `0` && c <= `9`) || c == `-` || c == `_`) { + is_int = false + break + } + } + if is_int && r.len > 0 { + return 'int' + } + return '' +} + +// parse_imports extracts module names from `import` statements in V source content. +// Returns e.g. ['os', 'math', 'strings'] for a file with those imports. +fn parse_imports(content string) []string { + mut modules := []string{} + for line in content.split_into_lines() { + trimmed := line.trim_space() + if !trimmed.starts_with('import ') { + continue + } + mod := trimmed[7..].trim_space() + // skip import blocks (`import (` style) - not supported in V, but be safe + if mod == '(' || mod == '' { + continue + } + // Handle aliased imports: `import os as operating_system` → take first word + parts := mod.split(' ') + modules << parts[0] + } + return modules +} + +// find_vroot returns the V installation root directory (where vlib/ lives), +// or '' if the v binary cannot be found. +fn find_vroot() string { + v_exe := os.find_abs_path_of_executable('v') or { return '' } + root := os.dir(v_exe) + vlib_candidate := os.join_path(root, 'vlib') + if os.is_dir(vlib_candidate) { + return root + } + // Some installations have v in a bin/ subdirectory + parent := os.dir(root) + vlib_parent := os.join_path(parent, 'vlib') + if os.is_dir(vlib_parent) { + return parent + } + return '' +} + +// extract_fn_call parses a RHS expression like `os.temp_dir()` or `get_value()` +// and returns (module_name, fn_name). Returns ('', '') if not a simple call. +// Skips method calls on receivers (e.g. `obj.method()`). +fn extract_fn_call(rhs string) (string, string) { + r := rhs.trim_space() + // Must end with `)` (allowing trailing comments stripped by caller) + if !r.ends_with(')') { + return '', '' + } + // Find the opening paren + paren_idx := r.index('(') or { return '', '' } + call_part := r[..paren_idx] + + if call_part.contains('.') { + // Could be `module.fn` or `receiver.method` — only handle one dot + dot_idx := call_part.last_index('.') or { return '', '' } + mod_part := call_part[..dot_idx] + fn_part := call_part[dot_idx + 1..] + // Skip if module part looks like a variable (lowercase first char only heuristic + // won't work reliably, so we allow both and let the index miss on methods) + if mod_part == '' || fn_part == '' { + return '', '' + } + return mod_part, fn_part + } + // Plain call: `get_value()` + if call_part == '' { + return '', '' + } + return '', call_part +} + +// parse_fn_signatures_into scans V source `content` for simple fn declarations +// and populates `index` with fn_name → return_type and mod_name.fn_name → return_type. +// Only captures non-method, non-multi-return, non-void signatures. +fn parse_fn_signatures_into(content string, mod_name string, mut index map[string]string) { + for line in content.split_into_lines() { + trimmed := line.trim_space() + // Match `fn name(` or `pub fn name(` + mut after_fn := '' + if trimmed.starts_with('pub fn ') { + after_fn = trimmed[7..] + } else if trimmed.starts_with('fn ') { + after_fn = trimmed[3..] + } else { + continue + } + // Skip method receivers: `(mut app App) name(` + if after_fn.starts_with('(') { + continue + } + paren_idx := after_fn.index('(') or { continue } + fn_name := after_fn[..paren_idx].trim_space() + if fn_name == '' || fn_name.contains(' ') || fn_name.contains('[') { + continue + } + // Find closing paren to locate return type + close_paren := after_fn.index(')') or { continue } + after_params := after_fn[close_paren + 1..].trim_space() + // after_params could be: `string {`, `!string {`, `?string {`, + // `(string, int) {` (multi-return — skip), ` {` (void — skip) + if after_params == '' || after_params.starts_with('{') { + continue + } + // Multi-return: starts with `(` + if after_params.starts_with('(') { + continue + } + // Strip trailing ` {` or just `{` + ret := after_params.all_before('{').trim_space() + if ret == '' { + continue + } + index[fn_name] = ret + if mod_name != '' { + index['${mod_name}.${fn_name}'] = ret + } + } +} + +// build_fn_index scans the given V source files and returns a map of +// fn_name → return_type and module_prefix.fn_name → return_type. +// Only captures simple (non-method, non-multi-return) signatures. +fn build_fn_index(files []string) map[string]string { + mut index := map[string]string{} + for fpath in files { + content := os.read_file(fpath) or { continue } + mod_name := os.file_name(fpath).replace('.v', '') + parse_fn_signatures_into(content, mod_name, mut index) + } + return index +} + +// lookup_fn_return_type looks up the return type of a function call RHS in the +// provided index. For qualified calls like `os.temp_dir()`, it checks both +// `os.temp_dir` and just `temp_dir`. +fn lookup_fn_return_type(rhs string, index map[string]string) string { + mod_name, fn_name := extract_fn_call(rhs) + if fn_name == '' { + return '' + } + // Strip any error handling suffix from RHS for lookup: `os.read_file(p) or { ... }` + // extract_fn_call already handles plain `)` endings; but callers may pass full line + if mod_name != '' { + qualified := '${mod_name}.${fn_name}' + if qualified in index { + return index[qualified] + } + } + if fn_name in index { + return index[fn_name] + } + return '' +} + // parse_document_symbols scans `content` line by line and extracts top-level // V declarations: functions, methods, structs, enums, interfaces, constants, // and type aliases. It is intentionally simple – the goal is to get the diff --git a/handlers_test.v b/handlers_test.v index c53d444e..0f4e62dc 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -1725,3 +1725,502 @@ const my_const = 42 assert false, 'Expected []DocumentSymbol' } } + +// ─── infer_type_from_literal ───────────────────────────────────────────────── + +fn test_infer_type_integer() { + assert infer_type_from_literal('42') == 'int' +} + +fn test_infer_type_negative_integer() { + assert infer_type_from_literal('-7') == 'int' +} + +fn test_infer_type_hex() { + assert infer_type_from_literal('0xff') == 'int' +} + +fn test_infer_type_octal() { + assert infer_type_from_literal('0o77') == 'int' +} + +fn test_infer_type_binary() { + assert infer_type_from_literal('0b1010') == 'int' +} + +fn test_infer_type_float() { + assert infer_type_from_literal('3.14') == 'f64' +} + +fn test_infer_type_string_single_quote() { + assert infer_type_from_literal("'hello'") == 'string' +} + +fn test_infer_type_string_double_quote() { + assert infer_type_from_literal('"world"') == 'string' +} + +fn test_infer_type_bool_true() { + assert infer_type_from_literal('true') == 'bool' +} + +fn test_infer_type_bool_false() { + assert infer_type_from_literal('false') == 'bool' +} + +fn test_infer_type_struct_init_skipped() { + assert infer_type_from_literal('MyStruct{}') == '' +} + +fn test_infer_type_array_init_skipped() { + assert infer_type_from_literal('[]int{}') == '' +} + +fn test_infer_type_function_call_skipped() { + assert infer_type_from_literal('get_value()') == '' +} + +fn test_infer_type_identifier_skipped() { + assert infer_type_from_literal('other_var') == '' +} + +fn test_infer_type_empty_skipped() { + assert infer_type_from_literal('') == '' +} + +// ─── handle_inlay_hints ────────────────────────────────────────────────────── + +fn test_handle_inlay_hints_basic() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + + uri := 'file:///test_inlay.v' + content := 'module main + +fn main() { + x := 42 + name := \'hello\' + flag := true + ratio := 3.14 + obj := MyStruct{} +}' + app.open_files[uri] = content + + request := Request{ + id: 30 + method: 'textDocument/inlayHint' + params: Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ line: 0, char: 0 } + end: Position{ line: 9, char: 0 } + } + } + } + + response := app.handle_inlay_hints(request) + + if response.result is []InlayHint { + hints := response.result + // Should find int, string, bool, f64 but NOT obj (struct init) + assert hints.len == 4 + labels := hints.map(it.label) + assert ': int' in labels + assert ': string' in labels + assert ': bool' in labels + assert ': f64' in labels + } else { + assert false, 'Expected []InlayHint' + } +} + +fn test_handle_inlay_hints_hint_position() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + + uri := 'file:///test_inlay_pos.v' + content := 'module main + +fn main() { + x := 99 +}' + app.open_files[uri] = content + + request := Request{ + id: 31 + method: 'textDocument/inlayHint' + params: Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ line: 0, char: 0 } + end: Position{ line: 4, char: 0 } + } + } + } + + response := app.handle_inlay_hints(request) + + if response.result is []InlayHint { + hints := response.result + assert hints.len == 1 + hint := hints[0] + assert hint.label == ': int' + assert hint.kind == 1 + assert hint.position.line == 3 + // 'x' appears at column 1 (after tab), hint after 'x' = col 2 + assert hint.position.char == 2 + } else { + assert false, 'Expected []InlayHint' + } +} + +fn test_handle_inlay_hints_empty_file() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + + uri := 'file:///test_inlay_empty.v' + app.open_files[uri] = '' + + request := Request{ + id: 32 + method: 'textDocument/inlayHint' + params: Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ line: 0, char: 0 } + end: Position{ line: 0, char: 0 } + } + } + } + + response := app.handle_inlay_hints(request) + + if response.result is []InlayHint { + assert response.result.len == 0 + } else { + assert false, 'Expected []InlayHint' + } +} + +fn test_handle_inlay_hints_mut_var() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + + uri := 'file:///test_inlay_mut.v' + content := 'fn main() { + mut count := 0 +}' + app.open_files[uri] = content + + request := Request{ + id: 33 + method: 'textDocument/inlayHint' + params: Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ line: 0, char: 0 } + end: Position{ line: 2, char: 0 } + } + } + } + + response := app.handle_inlay_hints(request) + + if response.result is []InlayHint { + hints := response.result + assert hints.len == 1 + assert hints[0].label == ': int' + } else { + assert false, 'Expected []InlayHint' + } +} + +fn test_handle_inlay_hints_single_const() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + + uri := 'file:///test_inlay_const_single.v' + content := 'module main + +const pi = 3.14 +const greeting = \'hello\' +const max_count = 100 +const is_debug = false +' + app.open_files[uri] = content + + request := Request{ + id: 34 + method: 'textDocument/inlayHint' + params: Params{ + text_document: TextDocumentIdentifier{ uri: uri } + range: LSPRange{ + start: Position{ line: 0, char: 0 } + end: Position{ line: 7, char: 0 } + } + } + } + + response := app.handle_inlay_hints(request) + + if response.result is []InlayHint { + hints := response.result + assert hints.len == 4 + labels := hints.map(it.label) + assert ': f64' in labels + assert ': string' in labels + assert ': int' in labels + assert ': bool' in labels + } else { + assert false, 'Expected []InlayHint' + } +} + +fn test_handle_inlay_hints_const_block() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + + uri := 'file:///test_inlay_const_block.v' + content := 'module main + +const ( + pi = 3.14 + app_name = \'vls\' + max_items = 50 + enabled = true +) +' + app.open_files[uri] = content + + request := Request{ + id: 35 + method: 'textDocument/inlayHint' + params: Params{ + text_document: TextDocumentIdentifier{ uri: uri } + range: LSPRange{ + start: Position{ line: 0, char: 0 } + end: Position{ line: 9, char: 0 } + } + } + } + + response := app.handle_inlay_hints(request) + + if response.result is []InlayHint { + hints := response.result + assert hints.len == 4 + labels := hints.map(it.label) + assert ': f64' in labels + assert ': string' in labels + assert ': int' in labels + assert ': bool' in labels + } else { + assert false, 'Expected []InlayHint' + } +} + +// ─── parse_imports ──────────────────────────────────────────────────────────── + +fn test_parse_imports_basic() { +content := 'module main\n\nimport os\nimport math\nimport strings\n' +mods := parse_imports(content) +assert 'os' in mods +assert 'math' in mods +assert 'strings' in mods +assert mods.len == 3 +} + +fn test_parse_imports_aliased() { +mods := parse_imports('import os as operating_system') +assert 'os' in mods +} + +fn test_parse_imports_submodule() { +mods := parse_imports('import math.big') +assert 'math.big' in mods +} + +fn test_parse_imports_empty() { +mods := parse_imports('module main\n\nfn main() {}') +assert mods.len == 0 +} + +// ─── extract_fn_call ────────────────────────────────────────────────────────── + +fn test_extract_fn_call_qualified() { +mod_name, fn_name := extract_fn_call('os.temp_dir()') +assert mod_name == 'os' +assert fn_name == 'temp_dir' +} + +fn test_extract_fn_call_plain() { +mod_name, fn_name := extract_fn_call('get_value()') +assert mod_name == '' +assert fn_name == 'get_value' +} + +fn test_extract_fn_call_with_args() { +mod_name, fn_name := extract_fn_call('os.join_path(a, b)') +assert mod_name == 'os' +assert fn_name == 'join_path' +} + +fn test_extract_fn_call_not_a_call() { +mod_name, fn_name := extract_fn_call('42') +assert mod_name == '' +assert fn_name == '' +} + +fn test_extract_fn_call_literal_not_a_call() { +mod_name, fn_name := extract_fn_call("'hello'") +assert mod_name == '' +assert fn_name == '' +} + +// ─── build_fn_index ─────────────────────────────────────────────────────────── + +fn test_build_fn_index_basic() { +mut app := create_test_app() +defer { cleanup_test_app(app) } + +src := 'module mymod\n\nfn get_value() int {\n\treturn 42\n}\n\npub fn get_name() string {\n\treturn "vls"\n}\n\nfn (mut app App) handle() string {\n\treturn ""\n}\n\nfn do_nothing() {\n}\n' +fpath := os.join_path(app.temp_dir, 'mymod.v') +os.write_file(fpath, src) or { assert false, 'write failed' } + +index := build_fn_index([fpath]) +assert index['get_value'] == 'int' +assert index['get_name'] == 'string' +assert 'handle' !in index +assert 'do_nothing' !in index +} + +// ─── lookup_fn_return_type ──────────────────────────────────────────────────── + +fn test_lookup_fn_return_type_qualified() { +index := {'os.temp_dir': 'string', 'temp_dir': 'string'} +assert lookup_fn_return_type('os.temp_dir()', index) == 'string' +} + +fn test_lookup_fn_return_type_plain() { +index := {'get_value': 'int'} +assert lookup_fn_return_type('get_value()', index) == 'int' +} + +fn test_lookup_fn_return_type_not_found() { +index := map[string]string{} +assert lookup_fn_return_type('unknown_fn()', index) == '' +} + +// ─── handle_inlay_hints with fn calls ───────────────────────────────────────── + +fn test_handle_inlay_hints_local_fn_call() { +mut app := create_test_app() +defer { cleanup_test_app(app) } + +helper_src := 'module main\n\nfn get_greeting() string {\n\treturn "hello"\n}\n' +os.write_file(os.join_path(app.temp_dir, 'helper.v'), helper_src) or { assert false, 'write failed' } + +uri := path_to_uri(os.join_path(app.temp_dir, 'main.v')) +app.open_files[uri] = 'module main\n\nfn main() {\n\tmsg := get_greeting()\n}\n' + +request := Request{ +id: 40 +method: 'textDocument/inlayHint' +params: Params{ +text_document: TextDocumentIdentifier{ uri: uri } +range: LSPRange{ start: Position{ line: 0, char: 0 }, end: Position{ line: 5, char: 0 } } +} +} +response := app.handle_inlay_hints(request) +if response.result is []InlayHint { +hints := response.result +assert hints.len == 1 +assert hints[0].label == ': string' +} else { +assert false, 'Expected []InlayHint' +} +} + +fn test_handle_inlay_hints_error_result_fn() { +mut app := create_test_app() +defer { cleanup_test_app(app) } + +helper_src := 'module main\n\nfn read_data() !string {\n\treturn "data"\n}\n' +os.write_file(os.join_path(app.temp_dir, 'reader.v'), helper_src) or { assert false, 'write failed' } + +uri := path_to_uri(os.join_path(app.temp_dir, 'main2.v')) +app.open_files[uri] = 'module main\n\nfn main() {\n\tdata := read_data() or { return }\n}\n' + +request := Request{ +id: 41 +method: 'textDocument/inlayHint' +params: Params{ +text_document: TextDocumentIdentifier{ uri: uri } +range: LSPRange{ start: Position{ line: 0, char: 0 }, end: Position{ line: 5, char: 0 } } +} +} +response := app.handle_inlay_hints(request) +if response.result is []InlayHint { +hints := response.result +assert hints.len == 1 +assert hints[0].label == ': string' +} else { +assert false, 'Expected []InlayHint' +} +} + +fn test_handle_inlay_hints_same_file_fn_call() { +mut app := create_test_app() +defer { cleanup_test_app(app) } + +// Function defined and called in the same open file +uri := 'file:///test_same_file.v' +content := 'module main + +fn get_greeting() string { +return "hello" +} + +fn main() { +greeting := get_greeting() +} +' +app.open_files[uri] = content + +request := Request{ +id: 50 +method: 'textDocument/inlayHint' +params: Params{ +text_document: TextDocumentIdentifier{ uri: uri } +range: LSPRange{ start: Position{ line: 0, char: 0 }, end: Position{ line: 9, char: 0 } } +} +} +response := app.handle_inlay_hints(request) +if response.result is []InlayHint { +hints := response.result +assert hints.len == 1 +assert hints[0].label == ': string' +} else { +assert false, 'Expected []InlayHint' +} +} diff --git a/lsp.v b/lsp.v index c171cdb6..71672fb6 100644 --- a/lsp.v +++ b/lsp.v @@ -21,6 +21,7 @@ struct TextDocumentIdentifier { struct Params { content_changes []ContentChange @[json: 'contentChanges'] position Position + range LSPRange text_document TextDocumentIdentifier @[json: 'textDocument'] new_name string @[json: 'newName'] } @@ -51,6 +52,7 @@ type ResponseResult = string | WorkspaceEdit | []TextEdit | []DocumentSymbol + | []InlayHint struct DocumentSymbol { name string @[json: 'name'] @@ -124,6 +126,7 @@ struct Capability { rename_provider bool @[json: 'renameProvider'] document_formatting_provider bool @[json: 'documentFormattingProvider'] document_symbol_provider bool @[json: 'documentSymbolProvider'] + inlay_hint_provider bool @[json: 'inlayHintProvider'] } struct CompletionItemCapability { @@ -178,6 +181,16 @@ struct TextEdit { new_text string @[json: 'newText'] } +// InlayHintKind 1 = Type hint, 2 = Parameter hint +const inlay_hint_kind_type = 1 + +struct InlayHint { + position Position + label string + kind int @[json: 'kind'] + padding_left bool @[json: 'paddingLeft'] +} + enum Method { unknown @['unknown'] initialize @['initialize'] @@ -192,6 +205,7 @@ enum Method { rename @['textDocument/rename'] formatting @['textDocument/formatting'] document_symbols @['textDocument/documentSymbol'] + inlay_hint @['textDocument/inlayHint'] set_trace @['$/setTrace'] cancel_request @['$/cancelRequest'] shutdown @['shutdown'] diff --git a/main.v b/main.v index 1fa192d4..ad5f40f3 100644 --- a/main.v +++ b/main.v @@ -126,6 +126,10 @@ fn (mut app App) handle_stdio_requests(mut reader io.BufferedReader) { resp := app.handle_document_symbols(request) write_response(resp) } + .inlay_hint { + resp := app.handle_inlay_hints(request) + write_response(resp) + } .did_change { log('DID_CHANGE') notification := app.on_did_change(request) or { continue } @@ -155,6 +159,7 @@ fn (mut app App) handle_stdio_requests(mut reader io.BufferedReader) { rename_provider: true document_formatting_provider: true document_symbol_provider: true + inlay_hint_provider: true } } } From 1c5558acde797ad133351eeb4256c19bc180ed42 Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Wed, 11 Mar 2026 19:48:45 -0400 Subject: [PATCH 2/2] fix formatting --- handlers.v | 3 +- handlers_test.v | 354 +++++++++++++++++++++++++++++------------------- main.v | 2 +- 3 files changed, 220 insertions(+), 139 deletions(-) diff --git a/handlers.v b/handlers.v index d50bf824..89f4d64c 100644 --- a/handlers.v +++ b/handlers.v @@ -459,8 +459,7 @@ fn infer_type_from_literal(rhs string) string { } } // Integer literal: hex (0x), octal (0o), binary (0b), or plain digits - if r.starts_with('0x') || r.starts_with('0X') || r.starts_with('0o') - || r.starts_with('0b') { + if r.starts_with('0x') || r.starts_with('0X') || r.starts_with('0o') || r.starts_with('0b') { return 'int' } mut is_int := true diff --git a/handlers_test.v b/handlers_test.v index 0f4e62dc..94ff3eda 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -1797,15 +1797,15 @@ fn test_handle_inlay_hints_basic() { } uri := 'file:///test_inlay.v' - content := 'module main + content := "module main fn main() { x := 42 - name := \'hello\' + name := 'hello' flag := true ratio := 3.14 obj := MyStruct{} -}' +}" app.open_files[uri] = content request := Request{ @@ -1815,9 +1815,15 @@ fn main() { text_document: TextDocumentIdentifier{ uri: uri } - range: LSPRange{ - start: Position{ line: 0, char: 0 } - end: Position{ line: 9, char: 0 } + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 9 + char: 0 + } } } } @@ -1859,9 +1865,15 @@ fn main() { text_document: TextDocumentIdentifier{ uri: uri } - range: LSPRange{ - start: Position{ line: 0, char: 0 } - end: Position{ line: 4, char: 0 } + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 4 + char: 0 + } } } } @@ -1898,9 +1910,15 @@ fn test_handle_inlay_hints_empty_file() { text_document: TextDocumentIdentifier{ uri: uri } - range: LSPRange{ - start: Position{ line: 0, char: 0 } - end: Position{ line: 0, char: 0 } + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 0 + char: 0 + } } } } @@ -1933,9 +1951,15 @@ fn test_handle_inlay_hints_mut_var() { text_document: TextDocumentIdentifier{ uri: uri } - range: LSPRange{ - start: Position{ line: 0, char: 0 } - end: Position{ line: 2, char: 0 } + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 2 + char: 0 + } } } } @@ -1958,23 +1982,31 @@ fn test_handle_inlay_hints_single_const() { } uri := 'file:///test_inlay_const_single.v' - content := 'module main + content := "module main const pi = 3.14 -const greeting = \'hello\' +const greeting = 'hello' const max_count = 100 const is_debug = false -' +" app.open_files[uri] = content request := Request{ id: 34 method: 'textDocument/inlayHint' params: Params{ - text_document: TextDocumentIdentifier{ uri: uri } + text_document: TextDocumentIdentifier{ + uri: uri + } range: LSPRange{ - start: Position{ line: 0, char: 0 } - end: Position{ line: 7, char: 0 } + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 7 + char: 0 + } } } } @@ -2001,25 +2033,33 @@ fn test_handle_inlay_hints_const_block() { } uri := 'file:///test_inlay_const_block.v' - content := 'module main + content := "module main const ( pi = 3.14 - app_name = \'vls\' + app_name = 'vls' max_items = 50 enabled = true ) -' +" app.open_files[uri] = content request := Request{ id: 35 method: 'textDocument/inlayHint' params: Params{ - text_document: TextDocumentIdentifier{ uri: uri } + text_document: TextDocumentIdentifier{ + uri: uri + } range: LSPRange{ - start: Position{ line: 0, char: 0 } - end: Position{ line: 9, char: 0 } + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 9 + char: 0 + } } } } @@ -2042,160 +2082,191 @@ const ( // ─── parse_imports ──────────────────────────────────────────────────────────── fn test_parse_imports_basic() { -content := 'module main\n\nimport os\nimport math\nimport strings\n' -mods := parse_imports(content) -assert 'os' in mods -assert 'math' in mods -assert 'strings' in mods -assert mods.len == 3 + content := 'module main\n\nimport os\nimport math\nimport strings\n' + mods := parse_imports(content) + assert 'os' in mods + assert 'math' in mods + assert 'strings' in mods + assert mods.len == 3 } fn test_parse_imports_aliased() { -mods := parse_imports('import os as operating_system') -assert 'os' in mods + mods := parse_imports('import os as operating_system') + assert 'os' in mods } fn test_parse_imports_submodule() { -mods := parse_imports('import math.big') -assert 'math.big' in mods + mods := parse_imports('import math.big') + assert 'math.big' in mods } fn test_parse_imports_empty() { -mods := parse_imports('module main\n\nfn main() {}') -assert mods.len == 0 + mods := parse_imports('module main\n\nfn main() {}') + assert mods.len == 0 } // ─── extract_fn_call ────────────────────────────────────────────────────────── fn test_extract_fn_call_qualified() { -mod_name, fn_name := extract_fn_call('os.temp_dir()') -assert mod_name == 'os' -assert fn_name == 'temp_dir' + mod_name, fn_name := extract_fn_call('os.temp_dir()') + assert mod_name == 'os' + assert fn_name == 'temp_dir' } fn test_extract_fn_call_plain() { -mod_name, fn_name := extract_fn_call('get_value()') -assert mod_name == '' -assert fn_name == 'get_value' + mod_name, fn_name := extract_fn_call('get_value()') + assert mod_name == '' + assert fn_name == 'get_value' } fn test_extract_fn_call_with_args() { -mod_name, fn_name := extract_fn_call('os.join_path(a, b)') -assert mod_name == 'os' -assert fn_name == 'join_path' + mod_name, fn_name := extract_fn_call('os.join_path(a, b)') + assert mod_name == 'os' + assert fn_name == 'join_path' } fn test_extract_fn_call_not_a_call() { -mod_name, fn_name := extract_fn_call('42') -assert mod_name == '' -assert fn_name == '' + mod_name, fn_name := extract_fn_call('42') + assert mod_name == '' + assert fn_name == '' } fn test_extract_fn_call_literal_not_a_call() { -mod_name, fn_name := extract_fn_call("'hello'") -assert mod_name == '' -assert fn_name == '' + mod_name, fn_name := extract_fn_call("'hello'") + assert mod_name == '' + assert fn_name == '' } // ─── build_fn_index ─────────────────────────────────────────────────────────── fn test_build_fn_index_basic() { -mut app := create_test_app() -defer { cleanup_test_app(app) } + mut app := create_test_app() + defer { cleanup_test_app(app) } -src := 'module mymod\n\nfn get_value() int {\n\treturn 42\n}\n\npub fn get_name() string {\n\treturn "vls"\n}\n\nfn (mut app App) handle() string {\n\treturn ""\n}\n\nfn do_nothing() {\n}\n' -fpath := os.join_path(app.temp_dir, 'mymod.v') -os.write_file(fpath, src) or { assert false, 'write failed' } + src := 'module mymod\n\nfn get_value() int {\n\treturn 42\n}\n\npub fn get_name() string {\n\treturn "vls"\n}\n\nfn (mut app App) handle() string {\n\treturn ""\n}\n\nfn do_nothing() {\n}\n' + fpath := os.join_path(app.temp_dir, 'mymod.v') + os.write_file(fpath, src) or { assert false, 'write failed' } -index := build_fn_index([fpath]) -assert index['get_value'] == 'int' -assert index['get_name'] == 'string' -assert 'handle' !in index -assert 'do_nothing' !in index + index := build_fn_index([fpath]) + assert index['get_value'] == 'int' + assert index['get_name'] == 'string' + assert 'handle' !in index + assert 'do_nothing' !in index } // ─── lookup_fn_return_type ──────────────────────────────────────────────────── fn test_lookup_fn_return_type_qualified() { -index := {'os.temp_dir': 'string', 'temp_dir': 'string'} -assert lookup_fn_return_type('os.temp_dir()', index) == 'string' + index := { + 'os.temp_dir': 'string' + 'temp_dir': 'string' + } + assert lookup_fn_return_type('os.temp_dir()', index) == 'string' } fn test_lookup_fn_return_type_plain() { -index := {'get_value': 'int'} -assert lookup_fn_return_type('get_value()', index) == 'int' + index := { + 'get_value': 'int' + } + assert lookup_fn_return_type('get_value()', index) == 'int' } fn test_lookup_fn_return_type_not_found() { -index := map[string]string{} -assert lookup_fn_return_type('unknown_fn()', index) == '' + index := map[string]string{} + assert lookup_fn_return_type('unknown_fn()', index) == '' } // ─── handle_inlay_hints with fn calls ───────────────────────────────────────── fn test_handle_inlay_hints_local_fn_call() { -mut app := create_test_app() -defer { cleanup_test_app(app) } + mut app := create_test_app() + defer { cleanup_test_app(app) } -helper_src := 'module main\n\nfn get_greeting() string {\n\treturn "hello"\n}\n' -os.write_file(os.join_path(app.temp_dir, 'helper.v'), helper_src) or { assert false, 'write failed' } + helper_src := 'module main\n\nfn get_greeting() string {\n\treturn "hello"\n}\n' + os.write_file(os.join_path(app.temp_dir, 'helper.v'), helper_src) or { + assert false, 'write failed' + } -uri := path_to_uri(os.join_path(app.temp_dir, 'main.v')) -app.open_files[uri] = 'module main\n\nfn main() {\n\tmsg := get_greeting()\n}\n' + uri := path_to_uri(os.join_path(app.temp_dir, 'main.v')) + app.open_files[uri] = 'module main\n\nfn main() {\n\tmsg := get_greeting()\n}\n' -request := Request{ -id: 40 -method: 'textDocument/inlayHint' -params: Params{ -text_document: TextDocumentIdentifier{ uri: uri } -range: LSPRange{ start: Position{ line: 0, char: 0 }, end: Position{ line: 5, char: 0 } } -} -} -response := app.handle_inlay_hints(request) -if response.result is []InlayHint { -hints := response.result -assert hints.len == 1 -assert hints[0].label == ': string' -} else { -assert false, 'Expected []InlayHint' -} + request := Request{ + id: 40 + method: 'textDocument/inlayHint' + params: Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 5 + char: 0 + } + } + } + } + response := app.handle_inlay_hints(request) + if response.result is []InlayHint { + hints := response.result + assert hints.len == 1 + assert hints[0].label == ': string' + } else { + assert false, 'Expected []InlayHint' + } } fn test_handle_inlay_hints_error_result_fn() { -mut app := create_test_app() -defer { cleanup_test_app(app) } + mut app := create_test_app() + defer { cleanup_test_app(app) } -helper_src := 'module main\n\nfn read_data() !string {\n\treturn "data"\n}\n' -os.write_file(os.join_path(app.temp_dir, 'reader.v'), helper_src) or { assert false, 'write failed' } + helper_src := 'module main\n\nfn read_data() !string {\n\treturn "data"\n}\n' + os.write_file(os.join_path(app.temp_dir, 'reader.v'), helper_src) or { + assert false, 'write failed' + } -uri := path_to_uri(os.join_path(app.temp_dir, 'main2.v')) -app.open_files[uri] = 'module main\n\nfn main() {\n\tdata := read_data() or { return }\n}\n' + uri := path_to_uri(os.join_path(app.temp_dir, 'main2.v')) + app.open_files[uri] = 'module main\n\nfn main() {\n\tdata := read_data() or { return }\n}\n' -request := Request{ -id: 41 -method: 'textDocument/inlayHint' -params: Params{ -text_document: TextDocumentIdentifier{ uri: uri } -range: LSPRange{ start: Position{ line: 0, char: 0 }, end: Position{ line: 5, char: 0 } } -} -} -response := app.handle_inlay_hints(request) -if response.result is []InlayHint { -hints := response.result -assert hints.len == 1 -assert hints[0].label == ': string' -} else { -assert false, 'Expected []InlayHint' -} + request := Request{ + id: 41 + method: 'textDocument/inlayHint' + params: Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 5 + char: 0 + } + } + } + } + response := app.handle_inlay_hints(request) + if response.result is []InlayHint { + hints := response.result + assert hints.len == 1 + assert hints[0].label == ': string' + } else { + assert false, 'Expected []InlayHint' + } } fn test_handle_inlay_hints_same_file_fn_call() { -mut app := create_test_app() -defer { cleanup_test_app(app) } + mut app := create_test_app() + defer { cleanup_test_app(app) } -// Function defined and called in the same open file -uri := 'file:///test_same_file.v' -content := 'module main + // Function defined and called in the same open file + uri := 'file:///test_same_file.v' + content := 'module main fn get_greeting() string { return "hello" @@ -2205,22 +2276,33 @@ fn main() { greeting := get_greeting() } ' -app.open_files[uri] = content + app.open_files[uri] = content -request := Request{ -id: 50 -method: 'textDocument/inlayHint' -params: Params{ -text_document: TextDocumentIdentifier{ uri: uri } -range: LSPRange{ start: Position{ line: 0, char: 0 }, end: Position{ line: 9, char: 0 } } -} -} -response := app.handle_inlay_hints(request) -if response.result is []InlayHint { -hints := response.result -assert hints.len == 1 -assert hints[0].label == ': string' -} else { -assert false, 'Expected []InlayHint' -} + request := Request{ + id: 50 + method: 'textDocument/inlayHint' + params: Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + range: LSPRange{ + start: Position{ + line: 0 + char: 0 + } + end: Position{ + line: 9 + char: 0 + } + } + } + } + response := app.handle_inlay_hints(request) + if response.result is []InlayHint { + hints := response.result + assert hints.len == 1 + assert hints[0].label == ': string' + } else { + assert false, 'Expected []InlayHint' + } } diff --git a/main.v b/main.v index ad5f40f3..a020ee70 100644 --- a/main.v +++ b/main.v @@ -159,7 +159,7 @@ fn (mut app App) handle_stdio_requests(mut reader io.BufferedReader) { rename_provider: true document_formatting_provider: true document_symbol_provider: true - inlay_hint_provider: true + inlay_hint_provider: true } } }