Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add hover for structs #1916

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

WillLillis
Copy link
Sponsor Contributor

This PR adds hover support for structs, enums, and unions without displaying member functions, as discussed in #1568. Going off of this comment from this issue, I also changed the final layout so that doc comments appear on top rather than the bottom of the hover window. If this needs to be discussed further/ moved into a separate PR I'm happy to do so!

A few examples:

const S = struct {
	fn foo() void {
		// many lines here
    }

	fld: u8,
};

image

    const ComptimeReason = union(enum) {
        c_import: struct {
            block: *Block,
            src: LazySrcLoc,
        },
        comptime_ret_ty: struct {
            block: *Block,
            func: Air.Inst.Ref,
            func_src: LazySrcLoc,
            return_ty: Type,
        },

        fn explain(cr: ComptimeReason, sema: *Sema, msg: ?*Module.ErrorMsg) !void {
            const parent = msg orelse return;
        // many lines later...
    };

image

cc: @llogick
Closes #1568

@leecannon
Copy link
Member

leecannon commented Jun 10, 2024

Hover over blah.

pub const blah = struct {
    const str = "something";
};
backtrace
thread 111256 panic: attempt to use null value
/home/lee/src/zls/src/analysis.zig:515:100: 0x156596e in getContainerFieldMembers (zls)
                const field_container_decl = tree.fullContainerDecl(&buf, inner_decl.ast.init_node).?;
                                                                                                   ^
/home/lee/src/zls/src/analysis.zig:453:41: 0x14a4d8f in getVariableSignature (zls)
            try getContainerFieldMembers(allocator, &desc, tree, container_decl, start_token, true, 0);
                                        ^
/home/lee/src/zls/src/features/hover.zig:71:61: 0x1400e03 in hoverSymbolRecursive (zls)
                break :def try Analyser.getVariableSignature(arena, tree, var_decl, true);
                                                            ^
/home/lee/src/zls/src/features/hover.zig:23:32: 0x137c000 in hoverSymbol (zls)
    return hoverSymbolRecursive(analyser, arena, decl_handle, markup_kind, &doc_strings);
                               ^
/home/lee/src/zls/src/features/hover.zig:279:42: 0x12fa5c2 in hoverDefinitionGlobal (zls)
                .value = (try hoverSymbol(analyser, arena, decl, markup_kind)) orelse return null,
                                         ^
/home/lee/src/zls/src/features/hover.zig:427:49: 0x12f6596 in hover (zls)
        .var_access => try hoverDefinitionGlobal(analyser, arena, handle, source_index, markup_kind, offset_encoding),
                                                ^
/home/lee/src/zls/src/Server.zig:1469:41: 0x1293a71 in hoverHandler (zls)
    const response = hover_handler.hover(&analyser, arena, handle, source_index, markup_kind, server.offset_encoding);
                                        ^
/home/lee/src/zls/src/Server.zig:1803:58: 0x1253827 in sendRequestSync__anon_21614 (zls)
        .@"textDocument/hover" => try server.hoverHandler(arena, params),
                                                         ^
/home/lee/src/zls/src/Server.zig:1881:62: 0x120d045 in processMessage (zls)
                    const result = try server.sendRequestSync(arena_allocator.allocator(), @tagName(method), params);
                                                             ^
/home/lee/src/zls/src/Server.zig:1908:33: 0x11c2295 in processMessageReportError (zls)
    return server.processMessage(message) catch |err| {
                                ^
/home/lee/src/zls/src/Server.zig:1946:62: 0x11a405f in processJob (zls)
            const response = server.processMessageReportError(parsed_message.value) orelse return;
                                                             ^
/home/lee/zig/0.14.0-dev.14+ec337051a/files/lib/std/Thread/Pool.zig:152:39: 0x11a31be in runFn (zls)
            @call(.auto, func, closure.arguments);
                                      ^
/home/lee/zig/0.14.0-dev.14+ec337051a/files/lib/std/Thread/Pool.zig:191:18: 0x124a2f4 in worker (zls)
            runFn(&run_node.data);
                 ^
debug: (server): Took 0ms to process notification-$/cancelRequest on Thread 111255
/home/lee/zig/0.14.0-dev.14+ec337051a/files/lib/std/Thread.zig:408:13: 0x11fdfdd in callFn__anon_19979 (zls)
            @call(.auto, f, args);
            ^
/home/lee/zig/0.14.0-dev.14+ec337051a/files/lib/std/Thread.zig:1226:30: 0x11b1c17 in entryFn (zls)
                return callFn(f, self.fn_args);
                             ^
/home/lee/zig/0.14.0-dev.14+ec337051a/files/lib/c.zig:239:13: 0x17d4290 in clone (c)
            asm volatile (
            ^
???:?:?: 0xaaaaaaaaaaaaaaa9 in ??? (???)
Unwind information for `???:0xaaaaaaaaaaaaaaa9` was not available, trace may be incomplete

[Info  - 21:54:16] Connection to server got closed. Server will restart.

@WillLillis
Copy link
Sponsor Contributor Author

Oh wow, I completely blanked on covering that case. I'll be able to take another look later tonight. Sorry about that!

@WillLillis
Copy link
Sponsor Contributor Author

WillLillis commented Jun 11, 2024

I reworked the second case to avoid the crash and added a "catchall" case for variable declarations that aren't full container decls.

image

Given that the number of different nodes that can get caught in the "} else if (Ast.fullVarDecl(tree, member)) |inner_decl| {" case is rather large, I wasn't sure how best to tackle displaying this information without using tokensToSlice(), pulling in directly from the source file. For example, the funky indentation for .fld and }; in the following example...

const EdgeCases = struct {
    const str = "something";
    const s = S{
                .fld = 0,
        };
};

...shows up in the hover window using this approach:

image

I'm not sure how to better handle such a general parsing problem without needlessly reinventing the wheel. Maybe zig fmt could be plugged in to handle this case?

@llogick
Copy link
Member

llogick commented Jun 11, 2024

Thank you for working on this !

My opinion:
Every member should be a single line, except for

field: struct {
...
},

where surely no one would put more than 5 fields and absolutely no decls.

To me, the perfect balance would be

// Could be in any file
const T = struct {
    inner: u8,

    pub const default: T = .{ .inner = 1 };
    const Self = @This();
    pub fn inc(self: Self) void {
        self.inner += 1;
    }
};

test {
    // or as fn param, ie v: T
    const v = T.default; // Hover over T, hovering over `default` would still show `pub const default: T = .{ .inner = 1 };`
    // much code here
    _ = v;
}

Result (only fields and pub decls, ie only what's available at the point of the var decl) :

const T = struct {
    inner: u8,

    pub const default: T
    pub fn inc(self: Self) void
};

This gives me a general idea of what v is and how it is/can be used in the code following it's decl.

@WillLillis
Copy link
Sponsor Contributor Author

WillLillis commented Jun 11, 2024

I spent some more time with this and worked out how to get utilize some pieces of zig fmt for that edge case. I believe the other cases could be simplified by also using this approach, but at the cost of performance as it does perform some extra allocations, tokenization, parsing, etc. Any thoughts here regarding the tradeoff?

I also wanted to mention that I currently have an extra empty line between separate decls, e.g. the following code snippet

const EdgeCases = struct {
    const str = "something";
    const s = S{
                .fld = 0,
        };
};

has the following hover window (with the extra empty line in between str and s):

image

I think this helps with readability, but I'd be fine removing said extra line if that's preferred :)

@WillLillis
Copy link
Sponsor Contributor Author

@llogick Funny timing! Sorry I didn't see your comment before pushing again. I'll take another look at this tomorrow :)

@WillLillis
Copy link
Sponsor Contributor Author

WillLillis commented Jun 11, 2024

Alright I added support for functions, changed the existing code so that only pub decls show up, and removed the extra space between decls (there is still an empty line going from a field to a decl). I held off on writing any additional tests because I'd like to cement some of this stuff first, but I am planning on covering these new cases once the code is a bit more finalized. I added an additional test with some private decls and a public member function.

Here's what llogick's example looks like with the current code. I think it might be worth it to keep default values (e.g. the = .{ .inner = 1 }; below), but I'd be happy to rework if need be.

image

@WillLillis WillLillis force-pushed the hover_struct branch 3 times, most recently from cce9fad to cf2edd7 Compare June 19, 2024 03:09
@diocletiann
Copy link
Contributor

Would it be possible to show @sizeOf? Thanks!

@WillLillis
Copy link
Sponsor Contributor Author

Would it be possible to show @sizeOf? Thanks!

I like this idea, but don't have a great idea of how I'd implement it. I'll spend some time on it this weekend. :)

Related: #1677

@WillLillis WillLillis force-pushed the hover_struct branch 2 times, most recently from 2f8b76a to 8470cb3 Compare June 21, 2024 03:21
Copy link
Member

@Techatrix Techatrix left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dealing with formatted code in ZLS is a complicated problem and we currently do not have the right tools to deal with it. std,zig.Ast.render can't be used because it can easily crash on code with syntax errors. Also, don't use tabs.

I have an alternative solution, just use the source code as-is and then remove extra indentation.

diff --git a/src/analysis.zig b/src/analysis.zig
index f2c0761f..dc794cdc 100644
--- a/src/analysis.zig
+++ b/src/analysis.zig
@@ -398,7 +398,7 @@ pub fn firstParamIs(
 }
 
 pub fn getVariableSignature(
-    allocator: std.mem.Allocator,
+    arena: std.mem.Allocator,
     tree: Ast,
     var_decl: Ast.full.VarDecl,
     include_name: bool,
@@ -421,7 +421,7 @@ pub fn getVariableSignature(
     const end_token = switch (node_tags[init_node]) {
         .merge_error_sets => {
             if (!include_name) return "error";
-            return try std.fmt.allocPrint(allocator, "{s} error", .{
+            return try std.fmt.allocPrint(arena, "{s} error", .{
                 offsets.tokensToSlice(tree, start_token, tree.firstToken(init_node) - 1),
             });
         },
@@ -458,6 +458,32 @@ pub fn getVariableSignature(
                 offset += 1;
             }
 
+            if (container_decl.ast.members.len != 0) container_fields: {
+                var first_field: ?Ast.Node.Index = null;
+                var last_field: ?Ast.Node.Index = null;
+
+                for (container_decl.ast.members) |member| {
+                    switch (tree.nodes.items(.tag)[member]) {
+                        .container_field_init, .container_field_align, .container_field => {},
+                        else => continue,
+                    }
+                    if (first_field == null) first_field = member;
+                    last_field = member;
+                }
+                if (first_field == null or last_field == null) break :container_fields;
+
+                const first_field_line_start = offsets.lineLocUntilIndex(tree.source, offsets.tokenToIndex(tree, tree.firstToken(first_field.?))).start;
+                const fields_source_indented = tree.source[first_field_line_start..offsets.tokenToLoc(tree, ast.lastToken(tree, last_field.?)).end];
+                const fields_source = try trimCommonIndentation(arena, fields_source_indented, 4);
+
+                return try std.mem.concat(arena, u8, &.{
+                    offsets.tokensToSlice(tree, start_token, token + offset),
+                    " {\n",
+                    fields_source,
+                    "\n}",
+                });
+            }
+
             break :blk token + offset;
         },
         else => ast.lastToken(tree, init_node),
@@ -466,6 +492,68 @@ pub fn getVariableSignature(
     return offsets.tokensToSlice(tree, start_token, end_token);
 }
 
+fn trimCommonIndentation(allocator: std.mem.Allocator, str: []const u8, preserved_indentation_amount: usize) error{OutOfMemory}![]u8 {
+    var line_it = std.mem.splitScalar(u8, str, '\n');
+
+    var non_empty_lines: usize = 0;
+    var min_indentation: ?usize = null;
+    while (line_it.next()) |line| {
+        if (line.len == 0) continue;
+        const indentation = for (line, 0..) |c, count| {
+            if (!std.ascii.isWhitespace(c)) break count;
+        } else line.len;
+        min_indentation = if (min_indentation) |old| @min(old, indentation) else indentation;
+        non_empty_lines += 1;
+    }
+
+    var common_indent = min_indentation orelse return try allocator.dupe(u8, str);
+    common_indent -|= preserved_indentation_amount;
+    if (common_indent == 0) return try allocator.dupe(u8, str);
+
+    const capacity = str.len - non_empty_lines * common_indent;
+    var output = try std.ArrayListUnmanaged(u8).initCapacity(allocator, capacity);
+    std.debug.assert(capacity == output.capacity);
+    errdefer @compileError("error would leak here");
+
+    line_it = std.mem.splitScalar(u8, str, '\n');
+    var is_first_line = true;
+    while (line_it.next()) |line| {
+        if (!is_first_line) output.appendAssumeCapacity('\n');
+        if (line.len != 0) {
+            output.appendSliceAssumeCapacity(line[common_indent..]);
+        }
+        is_first_line = false;
+    }
+
+    std.debug.assert(output.items.len == output.capacity);
+    return output.items;
+}
+
+test trimCommonIndentation {
+    const cases = [_]struct { []const u8, []const u8, usize }{
+        .{ "", "", 0 },
+        .{ "\n", "\n", 0 },
+        .{ "foo", "foo", 0 },
+        .{ "foo", "  foo", 0 },
+        .{ "foo  ", "    foo  ", 0 },
+        .{ "foo\nbar", "    foo\n    bar", 0 },
+        .{ "foo\nbar\n", "  foo\n  bar\n", 0 },
+        .{ "  foo\nbar", "    foo\n  bar", 0 },
+        .{ "foo\n  bar", "    foo\n      bar", 0 },
+        .{ "  foo\n\nbar", "    foo\n\n  bar", 0 },
+
+        .{ "  foo\n  bar", "    foo\n    bar", 2 },
+        .{ "    foo\n    bar", "    foo\n    bar", 4 },
+        .{ "    foo\n    bar", "    foo\n    bar", 8 },
+    };
+
+    for (cases) |case| {
+        const actual = try trimCommonIndentation(std.testing.allocator, case[1], case[2]);
+        defer std.testing.allocator.free(actual);
+        try std.testing.expectEqualStrings(case[0], actual);
+    }
+}
+
 pub fn getContainerFieldSignature(tree: Ast, field: Ast.full.ContainerField) ?[]const u8 {
     if (field.ast.type_expr == 0) return null;
     const end_node = if (field.ast.value_expr != 0) field.ast.value_expr else if (field.ast.align_expr != 0) field.ast.align_expr else field.ast.type_expr;

@WillLillis
Copy link
Sponsor Contributor Author

I have an alternative solution, just use the source code as-is and then remove extra indentation.

Nice! I reworked it a bit to also show pub functions and decls.

fix crash, add catch-all case

Better fix for default values

Only show 'pub' members, add support for functions, update existing tests

add hover test for public fn, private decls
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

proposal: struct hovers should show entire struct, minus functions
5 participants