From c9c78de04fb757334c76df0e6ca8844304543357 Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Wed, 18 Feb 2026 01:23:24 -0500 Subject: [PATCH 1/2] feat: added outline support / document symbols --- handlers.v | 147 +++++++++++++++++ handlers_test.v | 430 ++++++++++++++++++++++++++++++++++++++++++++++++ lsp.v | 78 ++++++--- lsp_test.v | 239 ++++++++++++++++++++++++++- main.v | 5 + 5 files changed, 874 insertions(+), 25 deletions(-) diff --git a/handlers.v b/handlers.v index 5a6e61f3..88671c73 100644 --- a/handlers.v +++ b/handlers.v @@ -260,6 +260,153 @@ fn (mut app App) handle_formatting(request Request) Response { } } +// handle_document_symbols parses the current file's text using a simple +// line-by-line token scan and returns top-level declaration symbols so the +// editor can populate its Outline / breadcrumbs view. +fn (mut app App) handle_document_symbols(request Request) Response { + uri := request.params.text_document.uri + content := app.open_files[uri] or { '' } + symbols := parse_document_symbols(content) + return Response{ + id: request.id + result: symbols + } +} + +// 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 +// Outline view working quickly, not to replicate a full parser. +fn parse_document_symbols(content string) []DocumentSymbol { + lines := content.split_into_lines() + mut symbols := []DocumentSymbol{} + + for i, raw_line in lines { + line := raw_line.trim_space() + + // Skip blank lines and pure comment lines + if line == '' || line.starts_with('//') { + continue + } + + // Collect an optional leading `pub ` so we can strip it for name extraction + stripped := if line.starts_with('pub ') { line[4..] } else { line } + + if stripped.starts_with('fn ') { + name := extract_fn_name(stripped[3..]) + if name == '' { + continue + } + kind := if name.contains(') ') { + // receiver present → method + sym_kind_method + } else { + sym_kind_function + } + symbols << make_symbol(name, kind, i, raw_line) + } else if stripped.starts_with('struct ') { + name := first_word(stripped[7..]) + if name != '' { + symbols << make_symbol(name, sym_kind_struct, i, raw_line) + } + } else if stripped.starts_with('enum ') { + name := first_word(stripped[5..]) + if name != '' { + symbols << make_symbol(name, sym_kind_enum, i, raw_line) + } + } else if stripped.starts_with('interface ') { + name := first_word(stripped[10..]) + if name != '' { + symbols << make_symbol(name, sym_kind_interface, i, raw_line) + } + } else if stripped.starts_with('const ') { + name := extract_const_name(stripped[6..]) + if name != '' { + symbols << make_symbol(name, sym_kind_constant, i, raw_line) + } + } else if stripped.starts_with('type ') { + name := first_word(stripped[5..]) + if name != '' { + symbols << make_symbol(name, sym_kind_class, i, raw_line) + } + } + } + + return symbols +} + +// make_symbol builds a DocumentSymbol covering the single line `line_idx`. +fn make_symbol(name string, kind int, line_idx int, raw_line string) DocumentSymbol { + col_start := raw_line.index(name) or { 0 } + col_end := col_start + name.len + line_range := LSPRange{ + start: Position{ line: line_idx, char: 0 } + end: Position{ line: line_idx, char: raw_line.len } + } + sel_range := LSPRange{ + start: Position{ line: line_idx, char: col_start } + end: Position{ line: line_idx, char: col_end } + } + return DocumentSymbol{ + name: name + kind: kind + range: line_range + selection_range: sel_range + children: []DocumentSymbol{} + } +} + +// extract_fn_name returns the function/method name including a receiver if +// present, e.g. "(mut App) foo" → "(mut App) foo", "main" → "main". +// The input is everything after the leading `fn ` (and optional `pub `). +fn extract_fn_name(after_fn string) string { + t := after_fn.trim_space() + if t == '' { + return '' + } + if t.starts_with('(') { + // method: (recv) name(params... + close := t.index(')') or { return '' } + rest := t[close + 1..].trim_space() + name := first_word_paren(rest) + if name == '' { + return '' + } + receiver := t[1..close] + return '(${receiver}) ${name}' + } + return first_word_paren(t) +} + +// first_word returns the first space/tab-delimited token (stops at whitespace). +fn first_word(s string) string { + mut end := 0 + for end < s.len && s[end] != ` ` && s[end] != `\t` && s[end] != `{` { + end++ + } + return s[..end].trim_space() +} + +// first_word_paren returns the identifier before the first `(`, e.g. +// "foo(a int) string" → "foo". +fn first_word_paren(s string) string { + mut end := 0 + for end < s.len && s[end] != `(` && s[end] != ` ` && s[end] != `\t` { + end++ + } + return s[..end].trim_space() +} + +// extract_const_name handles both `const name = ...` and `const (` blocks +// by returning the identifier on the same line if available. +fn extract_const_name(after_const string) string { + t := after_const.trim_space() + if t == '' || t == '(' { + return '' + } + return first_word(t) +} + fn (mut app App) search_symbol_in_project(working_dir string, symbol string) []Location { mut locations := []Location{} v_files := os.walk_ext(working_dir, '.v') diff --git a/handlers_test.v b/handlers_test.v index e274c332..12faee31 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -1295,3 +1295,433 @@ fn test_handle_formatting_uses_open_file_content() { } } } + +// ============================================================================ +// Tests for parse_document_symbols +// ============================================================================ + +fn test_parse_document_symbols_empty_content() { + syms := parse_document_symbols('') + assert syms.len == 0 +} + +fn test_parse_document_symbols_only_comments() { + content := '// Copyright notice\n// module main\n\n// just a comment' + syms := parse_document_symbols(content) + assert syms.len == 0 +} + +fn test_parse_document_symbols_single_function() { + content := 'module main\n\nfn greet(name string) string {\n\treturn name\n}' + syms := parse_document_symbols(content) + assert syms.len == 1 + assert syms[0].name == 'greet' + assert syms[0].kind == sym_kind_function +} + +fn test_parse_document_symbols_pub_function() { + content := 'module main\n\npub fn greet(name string) string {\n\treturn name\n}' + syms := parse_document_symbols(content) + assert syms.len == 1 + assert syms[0].name == 'greet' + assert syms[0].kind == sym_kind_function +} + +fn test_parse_document_symbols_method() { + content := 'module main\n\nstruct App {}\n\nfn (mut app App) run() {\n}' + syms := parse_document_symbols(content) + // Should find struct and method + names := syms.map(it.name) + assert 'App' in names + method_sym := syms.filter(it.kind == sym_kind_method) + assert method_sym.len == 1 + assert method_sym[0].name.contains('run') +} + +fn test_parse_document_symbols_struct() { + content := 'module main\n\nstruct Person {\n\tname string\n\tage int\n}' + syms := parse_document_symbols(content) + assert syms.len == 1 + assert syms[0].name == 'Person' + assert syms[0].kind == sym_kind_struct +} + +fn test_parse_document_symbols_pub_struct() { + content := 'module main\n\npub struct Config {\n\tdebug bool\n}' + syms := parse_document_symbols(content) + assert syms.len == 1 + assert syms[0].name == 'Config' + assert syms[0].kind == sym_kind_struct +} + +fn test_parse_document_symbols_enum() { + content := 'module main\n\nenum Color {\n\tred\n\tgreen\n\tblue\n}' + syms := parse_document_symbols(content) + assert syms.len == 1 + assert syms[0].name == 'Color' + assert syms[0].kind == sym_kind_enum +} + +fn test_parse_document_symbols_interface() { + content := 'module main\n\ninterface Writer {\n\twrite(s string)\n}' + syms := parse_document_symbols(content) + assert syms.len == 1 + assert syms[0].name == 'Writer' + assert syms[0].kind == sym_kind_interface +} + +fn test_parse_document_symbols_const() { + content := 'module main\n\nconst max_size = 100' + syms := parse_document_symbols(content) + assert syms.len == 1 + assert syms[0].name == 'max_size' + assert syms[0].kind == sym_kind_constant +} + +fn test_parse_document_symbols_type_alias() { + content := 'module main\n\ntype MyInt = int' + syms := parse_document_symbols(content) + assert syms.len == 1 + assert syms[0].name == 'MyInt' + assert syms[0].kind == sym_kind_class +} + +fn test_parse_document_symbols_multiple_declarations() { + content := 'module main + +pub fn greet(name string) string { + return name +} + +struct Person { + name string + age int +} + +enum Color { + red + green + blue +} + +fn (p Person) say_hello() string { + return greet(p.name) +} + +const max_age = 120 +' + syms := parse_document_symbols(content) + names := syms.map(it.name) + assert 'greet' in names + assert 'Person' in names + assert 'Color' in names + assert 'max_age' in names + // method should be present + assert syms.any(it.kind == sym_kind_method) +} + +fn test_parse_document_symbols_correct_line_numbers() { + content := 'module main\n\nfn alpha() {}\n\nfn beta() {}' + // line 0: 'module main' + // line 1: '' + // line 2: 'fn alpha() {}' + // line 3: '' + // line 4: 'fn beta() {}' + syms := parse_document_symbols(content) + assert syms.len == 2 + alpha := syms.filter(it.name == 'alpha') + beta := syms.filter(it.name == 'beta') + assert alpha.len == 1 + assert beta.len == 1 + assert alpha[0].range.start.line == 2 + assert beta[0].range.start.line == 4 +} + +fn test_parse_document_symbols_const_block_paren_skipped() { + // `const (` alone should not produce a symbol with name '(' + content := 'module main\n\nconst (\n\ta = 1\n\tb = 2\n)' + syms := parse_document_symbols(content) + for sym in syms { + assert sym.name != '(' + } +} + +fn test_parse_document_symbols_selection_range_points_to_name() { + content := 'module main\n\nfn my_func() {}' + syms := parse_document_symbols(content) + assert syms.len == 1 + sym := syms[0] + // The selection range should start where the name begins in the raw line + line := 'fn my_func() {}' + expected_col := line.index('my_func') or { -1 } + assert expected_col >= 0 + assert sym.selection_range.start.char == expected_col + assert sym.selection_range.end.char == expected_col + 'my_func'.len +} + +// ============================================================================ +// Tests for extract_fn_name helper +// ============================================================================ + +fn test_extract_fn_name_simple() { + assert extract_fn_name('main() {}') == 'main' +} + +fn test_extract_fn_name_with_params() { + assert extract_fn_name('greet(name string) string') == 'greet' +} + +fn test_extract_fn_name_method_with_receiver() { + name := extract_fn_name('(mut app App) run()') + assert name.contains('run') + assert name.contains('mut app App') +} + +fn test_extract_fn_name_method_immutable_receiver() { + name := extract_fn_name('(p Person) say_hello() string') + assert name.contains('say_hello') + assert name.contains('p Person') +} + +fn test_extract_fn_name_empty_string() { + assert extract_fn_name('') == '' +} + +fn test_extract_fn_name_whitespace_only() { + assert extract_fn_name(' ') == '' +} + +// ============================================================================ +// Tests for first_word helper +// ============================================================================ + +fn test_first_word_simple() { + assert first_word('Person {}') == 'Person' +} + +fn test_first_word_with_tab() { + assert first_word("Color\t{") == 'Color' +} + +fn test_first_word_stops_at_brace() { + assert first_word('Writer{') == 'Writer' +} + +fn test_first_word_single_token() { + assert first_word('MyType') == 'MyType' +} + +fn test_first_word_empty() { + assert first_word('') == '' +} + +// ============================================================================ +// Tests for first_word_paren helper +// ============================================================================ + +fn test_first_word_paren_simple() { + assert first_word_paren('foo(a int) string') == 'foo' +} + +fn test_first_word_paren_no_paren() { + assert first_word_paren('main') == 'main' +} + +fn test_first_word_paren_empty() { + assert first_word_paren('') == '' +} + +fn test_first_word_paren_stops_at_space() { + assert first_word_paren('bar baz') == 'bar' +} + +// ============================================================================ +// Tests for extract_const_name helper +// ============================================================================ + +fn test_extract_const_name_simple() { + assert extract_const_name('max_size = 100') == 'max_size' +} + +fn test_extract_const_name_open_paren() { + // const ( block opening — should return empty + assert extract_const_name('(') == '' +} + +fn test_extract_const_name_empty() { + assert extract_const_name('') == '' +} + +fn test_extract_const_name_whitespace_only() { + assert extract_const_name(' ') == '' +} + +// ============================================================================ +// Tests for handle_document_symbols handler +// ============================================================================ + +fn test_handle_document_symbols_empty_file() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + + uri := 'file:///tmp/empty.v' + app.open_files[uri] = '' + + request := Request{ + id: 10 + method: 'textDocument/documentSymbol' + params: Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + } + } + + response := app.handle_document_symbols(request) + assert response.id == 10 + if response.result is []DocumentSymbol { + assert response.result.len == 0 + } else { + assert false, 'Expected []DocumentSymbol' + } +} + +fn test_handle_document_symbols_no_tracked_file() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + + // URI not in open_files — should still return an empty symbol list, not crash + request := Request{ + id: 11 + method: 'textDocument/documentSymbol' + params: Params{ + text_document: TextDocumentIdentifier{ + uri: 'file:///tmp/not_tracked.v' + } + } + } + + response := app.handle_document_symbols(request) + assert response.id == 11 + if response.result is []DocumentSymbol { + assert response.result.len == 0 + } else { + assert false, 'Expected []DocumentSymbol' + } +} + +fn test_handle_document_symbols_returns_correct_symbols() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + + uri := 'file:///tmp/test_sym.v' + app.open_files[uri] = 'module main\n\nfn hello() {}\n\nstruct Config {}\n\nenum Mode { on off }\n\nconst version = 1\n' + + request := Request{ + id: 12 + method: 'textDocument/documentSymbol' + params: Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + } + } + + response := app.handle_document_symbols(request) + assert response.id == 12 + + if response.result is []DocumentSymbol { + syms := response.result + names := syms.map(it.name) + assert 'hello' in names + assert 'Config' in names + assert 'Mode' in names + assert 'version' in names + } else { + assert false, 'Expected []DocumentSymbol' + } +} + +fn test_handle_document_symbols_preserves_request_id() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + + uri := 'file:///tmp/id_test.v' + app.open_files[uri] = 'module main\n\nfn foo() {}\n' + + for id in [1, 99, 1000, 0] { + request := Request{ + id: id + method: 'textDocument/documentSymbol' + params: Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + } + } + response := app.handle_document_symbols(request) + assert response.id == id + } +} + +fn test_handle_document_symbols_kinds_are_correct() { + mut app := create_test_app() + defer { + cleanup_test_app(app) + } + + uri := 'file:///tmp/kinds_test.v' + app.open_files[uri] = 'module main + +fn plain_fn() {} + +struct MyStruct {} + +enum MyEnum { a b } + +interface MyInterface { run() } + +type MyType = int + +const my_const = 42 +' + + request := Request{ + id: 20 + method: 'textDocument/documentSymbol' + params: Params{ + text_document: TextDocumentIdentifier{ + uri: uri + } + } + } + + response := app.handle_document_symbols(request) + + if response.result is []DocumentSymbol { + syms := response.result + fn_sym := syms.filter(it.name == 'plain_fn') + struct_sym := syms.filter(it.name == 'MyStruct') + enum_sym := syms.filter(it.name == 'MyEnum') + iface_sym := syms.filter(it.name == 'MyInterface') + type_sym := syms.filter(it.name == 'MyType') + const_sym := syms.filter(it.name == 'my_const') + + assert fn_sym.len == 1 && fn_sym[0].kind == sym_kind_function + assert struct_sym.len == 1 && struct_sym[0].kind == sym_kind_struct + assert enum_sym.len == 1 && enum_sym[0].kind == sym_kind_enum + assert iface_sym.len == 1 && iface_sym[0].kind == sym_kind_interface + assert type_sym.len == 1 && type_sym[0].kind == sym_kind_class + assert const_sym.len == 1 && const_sym[0].kind == sym_kind_constant + } else { + assert false, 'Expected []DocumentSymbol' + } +} diff --git a/lsp.v b/lsp.v index 0b1a4858..52e094e8 100644 --- a/lsp.v +++ b/lsp.v @@ -50,6 +50,34 @@ type ResponseResult = string | []Location | WorkspaceEdit | []TextEdit + | []DocumentSymbol + +struct DocumentSymbol { + name string @[json: 'name'] + kind int @[json: 'kind'] + range LSPRange @[json: 'range'] + selection_range LSPRange @[json: 'selectionRange'] + children []DocumentSymbol @[json: 'children'] +} + +// LSP SymbolKind constants for the most common V declarations +const sym_kind_file = 1 +const sym_kind_module = 2 +const sym_kind_namespace = 3 +const sym_kind_package = 4 +const sym_kind_class = 5 +const sym_kind_method = 6 +const sym_kind_property = 7 +const sym_kind_field = 8 +const sym_kind_enum = 10 +const sym_kind_interface = 11 +const sym_kind_function = 12 +const sym_kind_variable = 13 +const sym_kind_constant = 14 +const sym_kind_string = 15 +const sym_kind_struct = 23 +const sym_kind_enum_member = 22 +const sym_kind_type_parameter = 26 struct Notification { method string @@ -87,14 +115,15 @@ struct Capabilities { } struct Capability { - completion_provider CompletionProvider @[json: 'completionProvider'] - text_document_sync TextDocumentSyncOptions @[json: 'textDocumentSync'] - signature_help_provider SignatureHelpOptions @[json: 'signatureHelpProvider'] - definition_provider bool @[json: 'definitionProvider'] - hover_provider bool @[json: 'hoverProvider'] - references_provider bool @[json: 'referencesProvider'] - rename_provider bool @[json: 'renameProvider'] - document_formatting_provider bool @[json: 'documentFormattingProvider'] + completion_provider CompletionProvider @[json: 'completionProvider'] + text_document_sync TextDocumentSyncOptions @[json: 'textDocumentSync'] + signature_help_provider SignatureHelpOptions @[json: 'signatureHelpProvider'] + definition_provider bool @[json: 'definitionProvider'] + hover_provider bool @[json: 'hoverProvider'] + references_provider bool @[json: 'referencesProvider'] + rename_provider bool @[json: 'renameProvider'] + document_formatting_provider bool @[json: 'documentFormattingProvider'] + document_symbol_provider bool @[json: 'documentSymbolProvider'] } struct CompletionItemCapability { @@ -150,22 +179,23 @@ struct TextEdit { } enum Method { - unknown @['unknown'] - initialize @['initialize'] - initialized @['initialized'] - did_open @['textDocument/didOpen'] - did_change @['textDocument/didChange'] - definition @['textDocument/definition'] - completion @['textDocument/completion'] - signature_help @['textDocument/signatureHelp'] - hover @['textDocument/hover'] - references @['textDocument/references'] - rename @['textDocument/rename'] - formatting @['textDocument/formatting'] - set_trace @['$/setTrace'] - cancel_request @['$/cancelRequest'] - shutdown @['shutdown'] - exit @['exit'] + unknown @['unknown'] + initialize @['initialize'] + initialized @['initialized'] + did_open @['textDocument/didOpen'] + did_change @['textDocument/didChange'] + definition @['textDocument/definition'] + completion @['textDocument/completion'] + signature_help @['textDocument/signatureHelp'] + hover @['textDocument/hover'] + references @['textDocument/references'] + rename @['textDocument/rename'] + formatting @['textDocument/formatting'] + document_symbols @['textDocument/documentSymbol'] + set_trace @['$/setTrace'] + cancel_request @['$/cancelRequest'] + shutdown @['shutdown'] + exit @['exit'] } fn Method.from_string(s string) Method { diff --git a/lsp_test.v b/lsp_test.v index cc36140a..84fe8faa 100644 --- a/lsp_test.v +++ b/lsp_test.v @@ -77,7 +77,6 @@ fn test_method_from_string_unsupported_methods() { 'textDocument/rangeFormatting', 'textDocument/codeAction', 'textDocument/codeLens', - 'textDocument/documentSymbol', 'workspace/symbol', 'workspace/executeCommand', ] @@ -862,3 +861,241 @@ fn test_json_var_ac_json_decoding() { assert ac.details.len == 1 assert ac.details[0].label == 'test' } + +// ============================================================================ +// Tests for DocumentSymbol and sym_kind_* constants +// ============================================================================ + +fn test_document_symbol_default_values() { + sym := DocumentSymbol{} + assert sym.name == '' + assert sym.kind == 0 + assert sym.children.len == 0 +} + +fn test_document_symbol_with_values() { + sym := DocumentSymbol{ + name: 'greet' + kind: sym_kind_function + range: LSPRange{ + start: Position{ line: 2, char: 0 } + end: Position{ line: 2, char: 20 } + } + selection_range: LSPRange{ + start: Position{ line: 2, char: 3 } + end: Position{ line: 2, char: 8 } + } + children: []DocumentSymbol{} + } + assert sym.name == 'greet' + assert sym.kind == sym_kind_function + assert sym.range.start.line == 2 + assert sym.selection_range.start.char == 3 +} + +fn test_document_symbol_json_encoding() { + sym := DocumentSymbol{ + name: 'Person' + kind: sym_kind_struct + range: LSPRange{ + start: Position{ line: 5, char: 0 } + end: Position{ line: 5, char: 14 } + } + selection_range: LSPRange{ + start: Position{ line: 5, char: 7 } + end: Position{ line: 5, char: 13 } + } + children: []DocumentSymbol{} + } + encoded := json.encode(sym) + assert encoded.contains('"name":"Person"') + assert encoded.contains('"kind":${sym_kind_struct}') + assert encoded.contains('"selectionRange"') + assert encoded.contains('"children"') +} + +fn test_document_symbol_json_decoding() { + json_str := '{"name":"Color","kind":10,"range":{"start":{"line":8,"character":0},"end":{"line":8,"character":11}},"selectionRange":{"start":{"line":8,"character":5},"end":{"line":8,"character":10}},"children":[]}' + sym := json.decode(DocumentSymbol, json_str) or { + assert false, 'Failed to decode DocumentSymbol: ${err}' + return + } + assert sym.name == 'Color' + assert sym.kind == sym_kind_enum + assert sym.range.start.line == 8 + assert sym.selection_range.start.char == 5 +} + +fn test_document_symbol_with_children() { + sym := DocumentSymbol{ + name: 'App' + kind: sym_kind_struct + range: LSPRange{} + selection_range: LSPRange{} + children: [ + DocumentSymbol{ + name: 'run' + kind: sym_kind_method + range: LSPRange{} + selection_range: LSPRange{} + children: []DocumentSymbol{} + }, + ] + } + assert sym.children.len == 1 + assert sym.children[0].name == 'run' + assert sym.children[0].kind == sym_kind_method +} + +fn test_sym_kind_constants_values() { + // LSP SymbolKind spec values + assert sym_kind_file == 1 + assert sym_kind_module == 2 + assert sym_kind_namespace == 3 + assert sym_kind_package == 4 + assert sym_kind_class == 5 + assert sym_kind_method == 6 + assert sym_kind_property == 7 + assert sym_kind_field == 8 + assert sym_kind_enum == 10 + assert sym_kind_interface == 11 + assert sym_kind_function == 12 + assert sym_kind_variable == 13 + assert sym_kind_constant == 14 + assert sym_kind_string == 15 + assert sym_kind_enum_member == 22 + assert sym_kind_struct == 23 + assert sym_kind_type_parameter == 26 +} + +fn test_sym_kind_constants_are_distinct() { + kinds := [ + sym_kind_file, sym_kind_module, sym_kind_namespace, sym_kind_package, + sym_kind_class, sym_kind_method, sym_kind_property, sym_kind_field, + sym_kind_enum, sym_kind_interface, sym_kind_function, sym_kind_variable, + sym_kind_constant, sym_kind_string, sym_kind_enum_member, sym_kind_struct, + sym_kind_type_parameter, + ] + // Check no two constants are the same value + mut seen := map[int]bool{} + for k in kinds { + assert k !in seen, 'sym_kind constant ${k} is duplicated' + seen[k] = true + } +} + +// --- Method enum round-trip for document_symbols --- + +fn test_method_from_string_document_symbols() { + assert Method.from_string('textDocument/documentSymbol') == .document_symbols +} + +fn test_method_str_document_symbols() { + assert Method.document_symbols.str() == 'textDocument/documentSymbol' +} + +fn test_method_roundtrip_document_symbols() { + m := Method.document_symbols + assert Method.from_string(m.str()) == m +} + +fn test_method_from_string_unsupported_methods_updated() { + // textDocument/documentSymbol is now supported – it must NOT be unknown + assert Method.from_string('textDocument/documentSymbol') != .unknown + // Other unsupported methods still return unknown + assert Method.from_string('workspace/symbol') == .unknown + assert Method.from_string('textDocument/codeAction') == .unknown +} + +// --- ResponseResult with []DocumentSymbol --- + +fn test_response_result_document_symbols_empty() { + result := ResponseResult([]DocumentSymbol{}) + if result is []DocumentSymbol { + assert result.len == 0 + } else { + assert false, 'Expected []DocumentSymbol result' + } +} + +fn test_response_result_document_symbols_with_data() { + syms := [ + DocumentSymbol{ + name: 'main' + kind: sym_kind_function + range: LSPRange{} + selection_range: LSPRange{} + children: []DocumentSymbol{} + }, + DocumentSymbol{ + name: 'App' + kind: sym_kind_struct + range: LSPRange{} + selection_range: LSPRange{} + children: []DocumentSymbol{} + }, + ] + result := ResponseResult(syms) + if result is []DocumentSymbol { + assert result.len == 2 + assert result[0].name == 'main' + assert result[0].kind == sym_kind_function + assert result[1].name == 'App' + assert result[1].kind == sym_kind_struct + } else { + assert false, 'Expected []DocumentSymbol result' + } +} + +fn test_response_with_document_symbols_json_encoding() { + syms := [ + DocumentSymbol{ + name: 'greet' + kind: sym_kind_function + range: LSPRange{ + start: Position{ line: 2, char: 0 } + end: Position{ line: 2, char: 25 } + } + selection_range: LSPRange{ + start: Position{ line: 2, char: 3 } + end: Position{ line: 2, char: 8 } + } + children: []DocumentSymbol{} + }, + ] + resp := Response{ + id: 7 + result: syms + } + encoded := json.encode(resp) + assert encoded.contains('"id":7') + assert encoded.contains('"name":"greet"') + assert encoded.contains('"kind":${sym_kind_function}') + assert encoded.contains('"selectionRange"') +} + +// --- Capability.document_symbol_provider --- + +fn test_capability_document_symbol_provider_true() { + cap := Capability{ + document_symbol_provider: true + } + assert cap.document_symbol_provider == true +} + +fn test_capability_document_symbol_provider_false_by_default() { + cap := Capability{} + assert cap.document_symbol_provider == false +} + +fn test_capability_document_symbol_provider_json_encoding() { + caps := Capabilities{ + capabilities: Capability{ + document_symbol_provider: true + definition_provider: true + } + } + encoded := json.encode(caps) + assert encoded.contains('"documentSymbolProvider":true') + assert encoded.contains('"definitionProvider":true') +} diff --git a/main.v b/main.v index 1d6d50cb..1fa192d4 100644 --- a/main.v +++ b/main.v @@ -122,6 +122,10 @@ fn (mut app App) handle_stdio_requests(mut reader io.BufferedReader) { resp := app.handle_formatting(request) write_response(resp) } + .document_symbols { + resp := app.handle_document_symbols(request) + write_response(resp) + } .did_change { log('DID_CHANGE') notification := app.on_did_change(request) or { continue } @@ -150,6 +154,7 @@ fn (mut app App) handle_stdio_requests(mut reader io.BufferedReader) { references_provider: true rename_provider: true document_formatting_provider: true + document_symbol_provider: true } } } From 7a05ddb197e4850263c1f6043ed23771a7a1c130 Mon Sep 17 00:00:00 2001 From: Dylan Donnell Date: Thu, 19 Feb 2026 21:23:44 +0000 Subject: [PATCH 2/2] run `v fmt -w .` --- handlers.v | 20 ++++++++-- handlers_test.v | 2 +- lsp.v | 88 ++++++++++++++++++++-------------------- lsp_test.v | 104 +++++++++++++++++++++++++++++++++++------------- 4 files changed, 137 insertions(+), 77 deletions(-) diff --git a/handlers.v b/handlers.v index 88671c73..be0ec5b5 100644 --- a/handlers.v +++ b/handlers.v @@ -340,12 +340,24 @@ fn make_symbol(name string, kind int, line_idx int, raw_line string) DocumentSym col_start := raw_line.index(name) or { 0 } col_end := col_start + name.len line_range := LSPRange{ - start: Position{ line: line_idx, char: 0 } - end: Position{ line: line_idx, char: raw_line.len } + start: Position{ + line: line_idx + char: 0 + } + end: Position{ + line: line_idx + char: raw_line.len + } } sel_range := LSPRange{ - start: Position{ line: line_idx, char: col_start } - end: Position{ line: line_idx, char: col_end } + start: Position{ + line: line_idx + char: col_start + } + end: Position{ + line: line_idx + char: col_end + } } return DocumentSymbol{ name: name diff --git a/handlers_test.v b/handlers_test.v index 12faee31..c53d444e 100644 --- a/handlers_test.v +++ b/handlers_test.v @@ -1500,7 +1500,7 @@ fn test_first_word_simple() { } fn test_first_word_with_tab() { - assert first_word("Color\t{") == 'Color' + assert first_word('Color\t{') == 'Color' } fn test_first_word_stops_at_brace() { diff --git a/lsp.v b/lsp.v index 52e094e8..c171cdb6 100644 --- a/lsp.v +++ b/lsp.v @@ -53,29 +53,29 @@ type ResponseResult = string | []DocumentSymbol struct DocumentSymbol { - name string @[json: 'name'] - kind int @[json: 'kind'] - range LSPRange @[json: 'range'] - selection_range LSPRange @[json: 'selectionRange'] - children []DocumentSymbol @[json: 'children'] + name string @[json: 'name'] + kind int @[json: 'kind'] + range LSPRange @[json: 'range'] + selection_range LSPRange @[json: 'selectionRange'] + children []DocumentSymbol @[json: 'children'] } // LSP SymbolKind constants for the most common V declarations -const sym_kind_file = 1 -const sym_kind_module = 2 +const sym_kind_file = 1 +const sym_kind_module = 2 const sym_kind_namespace = 3 -const sym_kind_package = 4 -const sym_kind_class = 5 -const sym_kind_method = 6 -const sym_kind_property = 7 -const sym_kind_field = 8 -const sym_kind_enum = 10 +const sym_kind_package = 4 +const sym_kind_class = 5 +const sym_kind_method = 6 +const sym_kind_property = 7 +const sym_kind_field = 8 +const sym_kind_enum = 10 const sym_kind_interface = 11 -const sym_kind_function = 12 -const sym_kind_variable = 13 -const sym_kind_constant = 14 -const sym_kind_string = 15 -const sym_kind_struct = 23 +const sym_kind_function = 12 +const sym_kind_variable = 13 +const sym_kind_constant = 14 +const sym_kind_string = 15 +const sym_kind_struct = 23 const sym_kind_enum_member = 22 const sym_kind_type_parameter = 26 @@ -115,15 +115,15 @@ struct Capabilities { } struct Capability { - completion_provider CompletionProvider @[json: 'completionProvider'] - text_document_sync TextDocumentSyncOptions @[json: 'textDocumentSync'] - signature_help_provider SignatureHelpOptions @[json: 'signatureHelpProvider'] - definition_provider bool @[json: 'definitionProvider'] - hover_provider bool @[json: 'hoverProvider'] - references_provider bool @[json: 'referencesProvider'] - rename_provider bool @[json: 'renameProvider'] - document_formatting_provider bool @[json: 'documentFormattingProvider'] - document_symbol_provider bool @[json: 'documentSymbolProvider'] + completion_provider CompletionProvider @[json: 'completionProvider'] + text_document_sync TextDocumentSyncOptions @[json: 'textDocumentSync'] + signature_help_provider SignatureHelpOptions @[json: 'signatureHelpProvider'] + definition_provider bool @[json: 'definitionProvider'] + hover_provider bool @[json: 'hoverProvider'] + references_provider bool @[json: 'referencesProvider'] + rename_provider bool @[json: 'renameProvider'] + document_formatting_provider bool @[json: 'documentFormattingProvider'] + document_symbol_provider bool @[json: 'documentSymbolProvider'] } struct CompletionItemCapability { @@ -179,23 +179,23 @@ struct TextEdit { } enum Method { - unknown @['unknown'] - initialize @['initialize'] - initialized @['initialized'] - did_open @['textDocument/didOpen'] - did_change @['textDocument/didChange'] - definition @['textDocument/definition'] - completion @['textDocument/completion'] - signature_help @['textDocument/signatureHelp'] - hover @['textDocument/hover'] - references @['textDocument/references'] - rename @['textDocument/rename'] - formatting @['textDocument/formatting'] - document_symbols @['textDocument/documentSymbol'] - set_trace @['$/setTrace'] - cancel_request @['$/cancelRequest'] - shutdown @['shutdown'] - exit @['exit'] + unknown @['unknown'] + initialize @['initialize'] + initialized @['initialized'] + did_open @['textDocument/didOpen'] + did_change @['textDocument/didChange'] + definition @['textDocument/definition'] + completion @['textDocument/completion'] + signature_help @['textDocument/signatureHelp'] + hover @['textDocument/hover'] + references @['textDocument/references'] + rename @['textDocument/rename'] + formatting @['textDocument/formatting'] + document_symbols @['textDocument/documentSymbol'] + set_trace @['$/setTrace'] + cancel_request @['$/cancelRequest'] + shutdown @['shutdown'] + exit @['exit'] } fn Method.from_string(s string) Method { diff --git a/lsp_test.v b/lsp_test.v index 84fe8faa..950c6cf9 100644 --- a/lsp_test.v +++ b/lsp_test.v @@ -875,17 +875,29 @@ fn test_document_symbol_default_values() { fn test_document_symbol_with_values() { sym := DocumentSymbol{ - name: 'greet' - kind: sym_kind_function - range: LSPRange{ - start: Position{ line: 2, char: 0 } - end: Position{ line: 2, char: 20 } + name: 'greet' + kind: sym_kind_function + range: LSPRange{ + start: Position{ + line: 2 + char: 0 + } + end: Position{ + line: 2 + char: 20 + } } selection_range: LSPRange{ - start: Position{ line: 2, char: 3 } - end: Position{ line: 2, char: 8 } + start: Position{ + line: 2 + char: 3 + } + end: Position{ + line: 2 + char: 8 + } } - children: []DocumentSymbol{} + children: []DocumentSymbol{} } assert sym.name == 'greet' assert sym.kind == sym_kind_function @@ -895,17 +907,29 @@ fn test_document_symbol_with_values() { fn test_document_symbol_json_encoding() { sym := DocumentSymbol{ - name: 'Person' - kind: sym_kind_struct - range: LSPRange{ - start: Position{ line: 5, char: 0 } - end: Position{ line: 5, char: 14 } + name: 'Person' + kind: sym_kind_struct + range: LSPRange{ + start: Position{ + line: 5 + char: 0 + } + end: Position{ + line: 5 + char: 14 + } } selection_range: LSPRange{ - start: Position{ line: 5, char: 7 } - end: Position{ line: 5, char: 13 } + start: Position{ + line: 5 + char: 7 + } + end: Position{ + line: 5 + char: 13 + } } - children: []DocumentSymbol{} + children: []DocumentSymbol{} } encoded := json.encode(sym) assert encoded.contains('"name":"Person"') @@ -928,11 +952,11 @@ fn test_document_symbol_json_decoding() { fn test_document_symbol_with_children() { sym := DocumentSymbol{ - name: 'App' - kind: sym_kind_struct - range: LSPRange{} + name: 'App' + kind: sym_kind_struct + range: LSPRange{} selection_range: LSPRange{} - children: [ + children: [ DocumentSymbol{ name: 'run' kind: sym_kind_method @@ -970,10 +994,22 @@ fn test_sym_kind_constants_values() { fn test_sym_kind_constants_are_distinct() { kinds := [ - sym_kind_file, sym_kind_module, sym_kind_namespace, sym_kind_package, - sym_kind_class, sym_kind_method, sym_kind_property, sym_kind_field, - sym_kind_enum, sym_kind_interface, sym_kind_function, sym_kind_variable, - sym_kind_constant, sym_kind_string, sym_kind_enum_member, sym_kind_struct, + sym_kind_file, + sym_kind_module, + sym_kind_namespace, + sym_kind_package, + sym_kind_class, + sym_kind_method, + sym_kind_property, + sym_kind_field, + sym_kind_enum, + sym_kind_interface, + sym_kind_function, + sym_kind_variable, + sym_kind_constant, + sym_kind_string, + sym_kind_enum_member, + sym_kind_struct, sym_kind_type_parameter, ] // Check no two constants are the same value @@ -1053,12 +1089,24 @@ fn test_response_with_document_symbols_json_encoding() { name: 'greet' kind: sym_kind_function range: LSPRange{ - start: Position{ line: 2, char: 0 } - end: Position{ line: 2, char: 25 } + start: Position{ + line: 2 + char: 0 + } + end: Position{ + line: 2 + char: 25 + } } selection_range: LSPRange{ - start: Position{ line: 2, char: 3 } - end: Position{ line: 2, char: 8 } + start: Position{ + line: 2 + char: 3 + } + end: Position{ + line: 2 + char: 8 + } } children: []DocumentSymbol{} },