diff --git a/spec/cra/workspace/completion_resolve_spec.cr b/spec/cra/workspace/completion_resolve_spec.cr index 5d1b6a3..9cbba64 100644 --- a/spec/cra/workspace/completion_resolve_spec.cr +++ b/spec/cra/workspace/completion_resolve_spec.cr @@ -74,7 +74,7 @@ describe CRA::Workspace do contents = resolved.documentation.not_nil!.as_h contents["kind"].as_s.should eq("markdown") value = contents["value"].as_s - value.should contain("def Greeter#greet(name)") + value.should contain("def Greeter.greet(name)") value.should contain("Says hello.") end end diff --git a/spec/cra/workspace/completion_spec.cr b/spec/cra/workspace/completion_spec.cr index d64c031..ecb34c6 100644 --- a/spec/cra/workspace/completion_spec.cr +++ b/spec/cra/workspace/completion_spec.cr @@ -448,4 +448,5 @@ describe CRA::Workspace do labels(items).should contain("foo/baz") end end + end diff --git a/spec/cra/workspace/diagnostic_spec.cr b/spec/cra/workspace/diagnostic_spec.cr index 34f2b3e..011c58a 100644 --- a/spec/cra/workspace/diagnostic_spec.cr +++ b/spec/cra/workspace/diagnostic_spec.cr @@ -141,6 +141,41 @@ describe CRA::Workspace do end end + it "warns on empty rescue" do + with_tmpdir do |dir| + code = <<-CR + begin + foo + rescue + end + CR + path = File.join(dir, "empty_rescue.cr") + File.write(path, code) + ws = workspace_for(dir) + + params = ws.publish_diagnostics("file://#{path}") + params.diagnostics.any? { |d| d.source == "lint" && d.message.includes?("Empty rescue") }.should be_true + end + end + + it "does not warn on non-empty rescue" do + with_tmpdir do |dir| + code = <<-CR + begin + foo + rescue + ch.send(nil) + end + CR + path = File.join(dir, "rescue_body.cr") + File.write(path, code) + ws = workspace_for(dir) + + params = ws.publish_diagnostics("file://#{path}") + params.diagnostics.any? { |d| d.message.includes?("Empty rescue") }.should be_false + end + end + it "hints trailing whitespace" do with_tmpdir do |dir| code = <<-CR @@ -221,6 +256,23 @@ describe CRA::Workspace do end end + it "does not flag abstract def params as unused" do + with_tmpdir do |dir| + code = <<-CR + abstract class Foo + abstract def foo(a, _b, c) + abstract def bar(a : Int32) : String + end + CR + path = File.join(dir, "abstract_args.cr") + File.write(path, code) + ws = workspace_for(dir) + + params = ws.publish_diagnostics("file://#{path}") + params.diagnostics.any? { |d| d.source == "lint" && d.message.includes?("Unused argument") }.should be_false + end + end + it "hints unused block args" do with_tmpdir do |dir| code = <<-CR diff --git a/spec/cra/workspace/hover_spec.cr b/spec/cra/workspace/hover_spec.cr index 12f63e6..f9603af 100644 --- a/spec/cra/workspace/hover_spec.cr +++ b/spec/cra/workspace/hover_spec.cr @@ -62,8 +62,1538 @@ describe CRA::Workspace do contents = hover.not_nil!.contents.as_h contents["kind"].as_s.should eq("markdown") value = contents["value"].as_s - value.should contain("def Greeter#greet(name)") + value.should contain("def Greeter.greet(name)") value.should contain("Says hello.") end end + + it "wraps instance method hover in crystal code fence with dot separator" do + code = <<-CRYSTAL + class Foo + def bar(name : String) : String + name + end + end + + def call + f = Foo.new + f.bar("baz") + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_dot.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "bar(\"baz\")") + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("```crystal") + value.should contain("def Foo.bar(name : String) : String") + value.should_not contain("Foo#bar") + end + end + + it "shows Bool type for local assigned from boolean literal" do + code = <<-CRYSTAL + def call + ipv6_native = false + ipv6_native + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_bool.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "ipv6_native", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("ipv6_native : Bool") + end + end + + it "shows String type for local assigned from string literal" do + code = <<-CRYSTAL + def call + name = "hello" + name + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_string.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "name", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("name : String") + end + end + + it "shows Int32 type for local assigned from integer literal" do + code = <<-CRYSTAL + def call + count = 42 + count + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_int.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "count", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("count : Int32") + end + end + + it "shows inferred type for local assigned from class method call" do + code = <<-CRYSTAL + class Resolver + def self.resolve(name) : String + end + end + + def call + result = Resolver.resolve("foo") + result + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_class_method.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "result", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("result : String") + end + end + + it "shows inferred type for local assigned from class-level [] constructor" do + code = <<-CRYSTAL + class Slice(T) + end + + def call + ipv4 = Slice[127u8, 0u8, 0u8, 1u8] + ipv4 + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_bracket.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "ipv4", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("ipv4 : Slice(UInt8)") + end + end + + it "shows inferred type for block parameter from method-call-assigned receiver" do + code = <<-CRYSTAL + class Resolver + def self.resolve(names) : Array(String) + end + end + + def call + results = Resolver.resolve(["a", "b"]) + results.each do |item| + item + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_block_param.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "item", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("item : String") + end + end + + it "shows inferred type for block parameter from method block signature" do + code = <<-CRYSTAL + class Fetcher + def self.fetch(& : (String, Int32) -> Nil) + end + end + + def call + Fetcher.fetch do |name, count| + name + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_block_sig.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "name", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("name : String") + end + end + + it "shows inferred type for local assigned from .new" do + code = <<-CRYSTAL + class Greeter + def greet(name) + end + end + + def call + greeter = Greeter.new + greeter + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_new.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "greeter", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("greeter : Greeter") + end + end + + it "shows inferred type for local from generic method return type" do + code = <<-CRYSTAL + class Config + def self.fetch(key : String, default : T) : T forall T + end + end + + def call + env = Config.fetch("MY_ENV", "") + env + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_generic.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "env", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("env : String") + end + end + + it "deduplicates union type when generic resolves to same type" do + code = <<-CRYSTAL + class Config + def self.fetch(key : String, default : T) : String | T forall T + end + end + + def call + env = Config.fetch("MY_ENV", "") + env + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_union_dedup.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "env", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("env : String") + value.should_not contain("String | String") + end + end + + it "infers Array(UInt8) from array literal with typed elements" do + code = <<-CRYSTAL + def call + bytes = [0u8, 1u8, 2u8] + bytes + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_array_literal.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "bytes", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("bytes : Array(UInt8)") + end + end + + it "shows inferred type from block body when method has no return type" do + code = <<-CRYSTAL + class FileReader + def self.open(path : String, &) + end + end + + class IniParser + def self.parse(source : FileReader) : Hash(String, String) + end + end + + def call + result = FileReader.open("path.ini") { |file| IniParser.parse(file) } + result + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_block_return.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "result", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("result : Hash(String, String)") + end + end + + it "preserves union type when variable is reassigned inside conditional" do + code = <<-CRYSTAL + class Env + def self.fetch(key : String, default : T) : T forall T + end + end + + def call + key = Env.fetch("KEY", [0u8]) + if true + key = "override" + end + key + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_conditional_union.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "key", 3) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("Array(UInt8)") + value.should contain("String") + end + end + + it "narrows type inside is_a? check" do + code = <<-CRYSTAL + def call(x : String | Int32) + if x.is_a?(String) + x + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_isa.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "x", 2) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("x : String") + value.should_not contain("Int32") + end + end + + it "narrows nilable type on truthiness check" do + code = <<-CRYSTAL + def call(x : String | Nil) + if x + x + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_truthy.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "x", 2) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("x : String") + value.should_not contain("Nil") + end + end + + it "narrows type with chained && conditions" do + code = <<-CRYSTAL + def call(x : String | Nil, y : Int32 | Nil) + if x && y + x + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_and_chain.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "x", 2) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("x : String") + value.should_not contain("Nil") + end + end + + it "narrows type in case/when with type pattern" do + code = <<-CRYSTAL + def call(x : String | Int32) + case x + when String + x + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_case_when.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "x", 2) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("x : String") + value.should_not contain("Int32") + end + end + + it "shows inferred type for block parameter from chained method call" do + code = <<-CRYSTAL + class MessageBuilder + def self.generate(host : String) : Array(String) + end + end + + def call + MessageBuilder.generate("localhost").map do |message| + message + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_chained_block.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "message", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("message : String") + end + end + + it "infers return type of map from block body" do + code = <<-CRYSTAL + class Array(T) + def map(& : T -> U) : Array(U) forall U + end + end + + class Builder + def self.generate(host : String) : Array(String) + end + + def self.package(msg : String) : Int32 + end + end + + def call + packages = Builder.generate("localhost").map { |msg| Builder.package(msg) } + packages + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_map_return.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "packages", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("packages : Array(Int32)") + end + end + + it "narrows type after early return with is_a? check" do + code = <<-CRYSTAL + class Slice(T) + end + + def call(ip : Slice(UInt16) | Slice(UInt8)) + return ip if ip.is_a?(Slice(UInt8)) + ip + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_early_return.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "ip", 3) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("ip : Slice(UInt16)") + value.should_not contain("UInt8") + end + end + + it "infers Pointer type from .null constructor" do + code = <<-CRYSTAL + lib LibC + struct IfAddrs + ifa_name : UInt8* + end + end + + def call + ifap = Pointer(LibC::IfAddrs).null + ifap + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_pointer_null.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "ifap", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("ifap : Pointer(LibC::IfAddrs)") + end + end + + it "infers Proc type from proc literal assignment" do + code = <<-CRYSTAL + def call + handler = ->(x : Int32, y : String) { y } + handler + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_proc.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "handler", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("handler : Proc(Int32, String, Nil)") + end + end + + it "resolves Pointer#current to the pointee type" do + code = <<-CRYSTAL + lib LibC + struct IfAddrs + ifa_name : UInt8* + end + end + + def call + ptr = Pointer(LibC::IfAddrs).null + ifa = ptr.current + ifa + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_pointer_current.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "ifa", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("ifa : LibC::IfAddrs") + end + end + + it "shows type for method parameter on hover" do + code = <<-CRYSTAL + class Foo + def self.generate(ip : StaticArray(UInt8, 4) | StaticArray(UInt8, 16)? = nil) + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_arg.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "ip", 0) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("ip : StaticArray(UInt8, 4) | StaticArray(UInt8, 16) | Nil") + end + end + + it "narrows nilable union type inside if truthiness check" do + code = <<-CRYSTAL + class Foo + def self.generate(ip : StaticArray(UInt8, 4) | StaticArray(UInt8, 16)? = nil) + if ip + ip + end + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_narrow_nilable.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "ip", 2) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("StaticArray(UInt8, 4)") + value.should contain("StaticArray(UInt8, 16)") + value.should_not contain("Nil") + end + end + + it "resolves C struct field types through chained access" do + code = <<-CRYSTAL + lib LibC + type SaFamilyT = UInt8 + + struct Sockaddr + sa_family : SaFamilyT + end + + struct IfAddrs + ifa_addr : Sockaddr* + end + end + + def call + ptr = Pointer(LibC::IfAddrs).null + ifa = ptr.value + addr = ifa.ifa_addr + sock = addr.value + family = sock.sa_family + family + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_struct_chain.cr") + File.write(path, code) + + ws = workspace_for(dir) + uri = "file://#{path}" + + # addr should be Pointer(LibC::Sockaddr) — occ=3 skips "addr" inside Sockaddr/ifa_addr + index = index_for(code, "addr", 3) + pos = position_for(code, index) + hover = ws.hover(hover_request(uri, pos)) + hover.should_not be_nil + hover.not_nil!.contents.as_h["value"].as_s.should contain("addr : Pointer(LibC::Sockaddr)") + + # .value on Pointer(Sockaddr) should give Sockaddr + index = index_for(code, "sock", 1) + pos = position_for(code, index) + hover = ws.hover(hover_request(uri, pos)) + hover.should_not be_nil + hover.not_nil!.contents.as_h["value"].as_s.should contain("sock : LibC::Sockaddr") + + # .sa_family should resolve to SaFamilyT + index = index_for(code, "family", 1) + pos = position_for(code, index) + hover = ws.hover(hover_request(uri, pos)) + hover.should_not be_nil + hover.not_nil!.contents.as_h["value"].as_s.should contain("family : SaFamilyT") + end + end + + it "resolves self.class.method to class method" do + code = <<-CRYSTAL + class Sender + def self.send(msg : String) : Bool + end + + def call + self.class.send("hello") + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_self_class.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "send", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("def Sender.send(msg : String) : Bool") + end + end + + it "infers receiver type for unresolved class method calls" do + code = <<-CRYSTAL + def call(io, format) + ts = Int64.from_io(io, format) + ts + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_class_fallback.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "ts", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("ts : Int64") + end + end + + it "resolves inherited class method through superclass chain" do + code = <<-CRYSTAL + struct Number + end + + struct Int < Number + def self.from_io(io, format) : self + end + end + + struct Int64 < Int + end + + def call(io, format) + ts = Int64.from_io(io, format) + ts + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_inherited.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "from_io", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("def Int.from_io") + + index = index_for(code, "ts", 1) + pos = position_for(code, index) + hover = ws.hover(hover_request(uri, pos)) + hover.should_not be_nil + hover.not_nil!.contents.as_h["value"].as_s.should contain("ts : Int64") + end + end + + it "infers type from if-expression assignment" do + code = <<-CRYSTAL + class Bytes + end + + def call(family : UInt8) + ip = if family == 4_u8 + Bytes.new(4) + elsif family == 6_u8 + Bytes.new(16) + else + raise "Unknown" + end + ip + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_if_expr.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "ip", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("ip : Bytes") + end + end + + it "infers union type from if-expression with different branch types" do + code = <<-CRYSTAL + class Foo + end + class Bar + end + + def call(x : Bool) + result = if x + Foo.new + else + Bar.new + end + result + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_if_union.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "result", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("Foo") + value.should contain("Bar") + end + end + + it "infers type from uninitialized variable declaration" do + code = <<-CRYSTAL + def call + buffer = uninitialized UInt8[16] + buffer + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_uninit.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "buffer", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("buffer : StaticArray(UInt8, 16)") + end + end + + it "resolves ivar type from getter macro" do + code = <<-CRYSTAL + class Bytes + end + + class Msg + getter ip : Bytes + getter family : Symbol + + def initialize(@ip, @family) + end + + def call + @ip + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_getter_ivar.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + # Hover on @ip inside method body + index = index_for(code, "@ip", 1) + pos = position_for(code, index) + hover = ws.hover(hover_request(uri, pos)) + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("@ip : Bytes") + + # Hover on getter name itself + index = index_for(code, "ip", 0) + pos = position_for(code, index) + hover = ws.hover(hover_request(uri, pos)) + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("ip : Bytes") + + # Hover on @ip in initialize param (first char) + index = index_for(code, "@ip", 0) + pos = position_for(code, index) + hover = ws.hover(hover_request(uri, pos)) + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("Bytes") + + # Hover on last char of @ip param (the 'p') — tests NodeFinder @-prefix fix + index = index_for(code, "@ip", 0) + 2 + pos = position_for(code, index) + hover = ws.hover(hover_request(uri, pos)) + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("Bytes") + end + end + + it "resolves inherited ivar types from parent getter" do + code = <<-CRYSTAL + struct Base + getter version : Int32 + getter ts : Int64 + + def initialize(@version, @ts) + end + end + + struct Child < Base + def call + @version + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_child_struct.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "@version", 1) + pos = position_for(code, index) + hover = ws.hover(hover_request(uri, pos)) + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("@version : Int32") + end + end + + it "infers block param type for ClassName.new { |p| }" do + code = <<-CRYSTAL + class Conn + def self.new(&) + new.tap { |inst| yield inst } + end + end + + def call + Conn.new do |conn| + conn + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_new_block.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "conn", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("conn : Conn") + end + end + + it "infers block param type from yield in method body" do + code = <<-CRYSTAL + class Bar + def self.build(capacity = 64, &) + builder = new + yield builder + builder.to_s + end + end + + def call + Bar.build do |baz| + baz + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_yield.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "baz", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("baz : Bar") + end + end + + it "infers block param type from chained yield through another builder" do + code = <<-CRYSTAL + class Baz + def self.build(capacity = 64, &) + builder = new + yield builder + builder.to_s + end + end + + class Foo + def self.build(capacity = 64, &) + Baz.build(capacity) do |builder| + yield builder + end + end + end + + def call + Foo.build do |bar| + bar + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_chain_yield.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "bar", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("bar : Baz") + end + end + + it "infers block param type across files via pending yield resolution" do + builder_code = <<-CRYSTAL + class Foo + class Builder + def self.build(capacity = 64, &) + builder = new + yield builder + builder.to_s + end + end + end + CRYSTAL + + main_code = <<-CRYSTAL + class Foo + def self.build(capacity = 64, &) + Foo::Builder.build(capacity) do |builder| + yield builder + end + end + end + + def call + Foo.build do |bar| + bar + end + end + CRYSTAL + + with_tmpdir do |dir| + File.write(File.join(dir, "builder.cr"), builder_code) + path = File.join(dir, "main.cr") + File.write(path, main_code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(main_code, "bar", 1) + pos = position_for(main_code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("bar : Foo::Builder") + end + end + + it "resolves Self block param type to the owner class" do + code = <<-CRYSTAL + class Foo + def self.build(capacity : Int32, & : (self) -> Nil) : self + end + end + + def call + Foo.build(16) do |bar| + bar + end + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_self_block.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "bar", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("bar : Foo") + end + end + + it "resolves top-level variable types" do + code = <<-CRYSTAL + foo = "hello" + bar = 42 + baz = true + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_toplevel.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "foo", 0) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("foo : String") + + index = index_for(code, "bar", 0) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("bar : Int32") + end + end + + it "resolves outer variables and typed params inside proc literals" do + code = <<-CRYSTAL + def call + foo = "hello" + bar = 42 + cb = ->(x : Int32) { + foo + bar + x + } + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_proc.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "foo", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("foo : String") + + index = index_for(code, "x", 1) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("x : Int32") + end + end + + it "shows return type for getter with default value" do + code = <<-CRYSTAL + class Greeter + getter name = "world" + getter count = 0 + + def initialize(@name = "world", @count = 0) + end + end + + def call + g = Greeter.new + g.name + g.count + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_getter_default.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, ".name") + pos = position_for(code, index + 1) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("name : String") + value.should_not contain("def ") + + index = index_for(code, ".count") + pos = position_for(code, index + 1) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("count : Int32") + value.should_not contain("def ") + end + end + + it "resolves classes defined inside macro-if blocks" do + code = <<-CRYSTAL + {% if true %} + class Greeter + def greet(name : String) : String + name + end + end + {% else %} + class Greeter + def greet(name : String) : String + name + end + end + {% end %} + + def call + g = Greeter.new + g.greet("foo") + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_macro_if.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "greet", 2) + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("Greeter.greet") + value.should contain(": String") + end + end + + it "shows simple getter hover as property type" do + code = <<-CRYSTAL + class Item + getter name : String + getter count : Int32 + + def initialize(@name : String, @count : Int32) + end + end + + def call + item = Item.new("foo", 1) + item.name + item.count + end + CRYSTAL + + with_tmpdir do |dir| + path = File.join(dir, "hover_getter_prop.cr") + File.write(path, code) + + ws = workspace_for(dir) + + uri = "file://#{path}" + index = index_for(code, "item.name") + "item.".size + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("name : String") + value.should_not contain("def ") + + index = index_for(code, "item.count") + "item.".size + pos = position_for(code, index) + request = hover_request(uri, pos) + hover = ws.hover(request) + + hover.should_not be_nil + value = hover.not_nil!.contents.as_h["value"].as_s + value.should contain("count : Int32") + value.should_not contain("def ") + end + end end diff --git a/spec/cra/workspace/signature_help_spec.cr b/spec/cra/workspace/signature_help_spec.cr index 78c2320..edf2cea 100644 --- a/spec/cra/workspace/signature_help_spec.cr +++ b/spec/cra/workspace/signature_help_spec.cr @@ -62,7 +62,7 @@ describe CRA::Workspace do help = help.not_nil! help.signatures.should_not be_empty help.active_parameter.should eq(1) - help.signatures.first.label.should contain("Greeter#greet") + help.signatures.first.label.should contain("Greeter.greet") doc = help.signatures.first.documentation doc.should_not be_nil doc_value = doc.not_nil!.as_h["value"].as_s diff --git a/src/cra/analysis/macro_expander.cr b/src/cra/analysis/macro_expander.cr index 7a5d797..6eb95cf 100644 --- a/src/cra/analysis/macro_expander.cr +++ b/src/cra/analysis/macro_expander.cr @@ -146,6 +146,7 @@ module CRA name = target.var.to_s type_decl = target.declared_type.to_s end + type_decl ||= infer_type_name(arg.value) end return if name.empty? @@ -171,6 +172,43 @@ module CRA io.puts "end" end + private def self.infer_type_name(node : Crystal::ASTNode) : String? + case node + when Crystal::StringLiteral then "String" + when Crystal::CharLiteral then "Char" + when Crystal::BoolLiteral then "Bool" + when Crystal::SymbolLiteral then "Symbol" + when Crystal::NilLiteral then "Nil" + when Crystal::NumberLiteral + case node.kind + when .i8? then "Int8" + when .i16? then "Int16" + when .i32? then "Int32" + when .i64? then "Int64" + when .i128? then "Int128" + when .u8? then "UInt8" + when .u16? then "UInt16" + when .u32? then "UInt32" + when .u64? then "UInt64" + when .u128? then "UInt128" + when .f32? then "Float32" + when .f64? then "Float64" + else + node.value.includes?('.') ? "Float64" : "Int32" + end + when Crystal::ArrayLiteral + if of_type = node.of + "Array(#{of_type})" + end + when Crystal::HashLiteral + if of_entry = node.of + "Hash(#{of_entry.key}, #{of_entry.value})" + end + else + nil + end + end + private def self.write_setter(io, name, type) if type io.puts "def #{name}=(#{name} : #{type})" diff --git a/src/cra/semantic/ast.cr b/src/cra/semantic/ast.cr index 1e5f8f0..c74ce6a 100644 --- a/src/cra/semantic/ast.cr +++ b/src/cra/semantic/ast.cr @@ -101,10 +101,14 @@ module CRA getter return_type : String getter return_type_ref : TypeRef? getter parameters : Array(String) + getter param_type_refs : Array(TypeRef?) + getter free_vars : Array(String) getter min_arity : Int32 getter max_arity : Int32? getter class_method : Bool getter owner : PsiElement | Nil + property block_arg_types : Array(TypeRef) + getter block_return_type_ref : TypeRef? def initialize( @file : String?, @name : String, @@ -115,6 +119,10 @@ module CRA @owner : PsiElement | Nil, @parameters : Array(String) = [] of String, @return_type_ref : TypeRef? = nil, + @param_type_refs : Array(TypeRef?) = [] of TypeRef?, + @free_vars : Array(String) = [] of String, + @block_arg_types : Array(TypeRef) = [] of TypeRef, + @block_return_type_ref : TypeRef? = nil, @location : Location? = nil, @doc : String? = nil) end @@ -157,8 +165,9 @@ module CRA end class LocalVar < PsiElement + getter type : String getter owner : PsiElement | Nil - def initialize(@file : String?, @name : String, @owner : PsiElement | Nil = nil, @location : Location? = nil, @doc : String? = nil) + def initialize(@file : String?, @name : String, @type : String = "", @owner : PsiElement | Nil = nil, @location : Location? = nil, @doc : String? = nil) end end diff --git a/src/cra/semantic/indexers.cr b/src/cra/semantic/indexers.cr index d80e824..3aba862 100644 --- a/src/cra/semantic/indexers.cr +++ b/src/cra/semantic/indexers.cr @@ -87,6 +87,46 @@ module CRA::Psi false end + def visit(node : Crystal::LibDef) : Bool + name = node.name.full + owner = @owner_stack.last?.as?(CRA::Psi::Module) + if !owner && name.includes?("::") + if parent_name = parent_name_of(name) + owner = @index.find_module(parent_name, true) + end + elsif owner + name = "#{owner.name}::#{name}" + end + module_element = @index.ensure_module( + name, + owner, + @index.location_for(node), + [] of String, + node.doc + ) + @owner_stack << module_element + node.accept_children(self) + @owner_stack.pop + false + end + + def visit(node : Crystal::CStructOrUnionDef) : Bool + owner = @owner_stack.last? + name = owner ? "#{owner.name}::#{node.name}" : node.name + parent = owner.as?(CRA::Psi::Module | CRA::Psi::Class) + class_element = @index.ensure_class( + name, + parent, + @index.location_for(node), + [] of String, + node.doc + ) + @owner_stack << class_element + node.accept_children(self) + @owner_stack.pop + false + end + def visit(node : Crystal::Def) : Bool false end @@ -95,6 +135,39 @@ module CRA::Psi false end + def visit(node : Crystal::MacroIf) : Bool + expand_macro_if_text(node) + false + end + + private def expand_macro_if_text(node : Crystal::MacroIf) + expand_macro_branch(node.then) + expand_macro_branch(node.else) + end + + private def expand_macro_branch(node : Crystal::ASTNode) + text = String.build { |io| collect_macro_literals(node, io) } + return if text.blank? + begin + parser = Crystal::Parser.new(text) + parsed = parser.parse + parsed.accept(self) + rescue + end + end + + private def collect_macro_literals(node : Crystal::ASTNode, io : IO) + case node + when Crystal::Expressions + node.expressions.each { |e| collect_macro_literals(e, io) } + when Crystal::MacroLiteral + io << node.value + when Crystal::MacroIf + collect_macro_literals(node.then, io) + collect_macro_literals(node.else, io) + end + end + private def qualified_name(path : Crystal::Path) : String name = path.full return name if name.includes?("::") @@ -225,6 +298,99 @@ module CRA::Psi false end + def visit(node : Crystal::LibDef) : Bool + name = node.name.full + owner = @owner_stack.last?.as?(CRA::Psi::Module) + if !owner && name.includes?("::") + if parent_name = parent_name_of(name) + owner = @index.find_module(parent_name, true) + end + elsif owner + name = "#{owner.name}::#{name}" + end + module_element = @index.ensure_module( + name, + owner, + @index.location_for(node), + [] of String, + node.doc + ) + @owner_stack << module_element + node.accept_children(self) + @owner_stack.pop + false + end + + def visit(node : Crystal::CStructOrUnionDef) : Bool + owner = @owner_stack.last? + name = owner ? "#{owner.name}::#{node.name}" : node.name + parent = owner.as?(CRA::Psi::Module | CRA::Psi::Class) + class_element = @index.ensure_class( + name, + parent, + @index.location_for(node), + [] of String, + node.doc + ) + fields = case body = node.body + when Crystal::Expressions then body.expressions + when Crystal::TypeDeclaration then [body.as(Crystal::ASTNode)] + else [] of Crystal::ASTNode + end + fields.each do |expr| + next unless expr.is_a?(Crystal::TypeDeclaration) + field_var = expr.var + next unless field_var.is_a?(Crystal::Var) + field_type_ref = type_ref_from_type(expr.declared_type) + next unless field_type_ref + method_element = CRA::Psi::Method.new( + file: @index.current_file, + name: field_var.name, + min_arity: 0, + max_arity: 0, + class_method: false, + owner: class_element, + return_type: expr.declared_type.to_s, + return_type_ref: field_type_ref, + parameters: [] of String, + location: @index.location_for(expr), + doc: expr.doc + ) + @index.attach method_element, class_element + @index.register_method(method_element) + end + false + end + + def visit(node : Crystal::FunDef) : Bool + owner = @owner_stack.last? + return false unless owner + return false unless owner.is_a?(CRA::Psi::Module) || owner.is_a?(CRA::Psi::Class) + + return_type_ref = node.return_type ? type_ref_from_type(node.return_type.not_nil!) : nil + param_type_refs = node.args.map { |arg| + restriction = arg.restriction + restriction ? type_ref_from_type(restriction) : nil + } + method_element = CRA::Psi::Method.new( + file: @index.current_file, + name: node.name, + min_arity: node.args.size, + max_arity: node.args.size, + class_method: true, + owner: owner, + return_type: node.return_type ? node.return_type.to_s : "Nil", + return_type_ref: return_type_ref, + parameters: node.args.map(&.name), + param_type_refs: param_type_refs, + location: @index.location_for(node), + doc: node.doc + ) + @index.attach method_element, owner + @index.register_method(method_element) + false + end + def visit(node : Crystal::Alias) : Bool name = qualified_name(node.name) target = type_ref_from_type(node.value) @@ -250,6 +416,17 @@ module CRA::Psi if return_type = node.return_type return_type_ref = type_ref_from_type(return_type) end + block_arg_types = extract_block_arg_types(node) + has_untyped_block = block_arg_types.empty? && node.block_arg + if has_untyped_block && (body = node.body) + block_arg_types = extract_yield_arg_types(body, node, owner) + end + block_return_type_ref = extract_block_return_type(node) + param_type_refs = node.args.map { |arg| + restriction = arg.restriction + restriction ? type_ref_from_type(restriction) : nil + } + free_vars = node.free_vars || [] of String method_element = CRA::Psi::Method.new( file: @index.current_file, name: node.name, @@ -260,11 +437,18 @@ module CRA::Psi return_type: node.return_type ? node.return_type.to_s : "Nil", return_type_ref: return_type_ref, parameters: node.args.map(&.name), + param_type_refs: param_type_refs, + free_vars: free_vars, + block_arg_types: block_arg_types, + block_return_type_ref: block_return_type_ref, location: @index.location_for(node), doc: node.doc ) @index.attach method_element, owner @index.register_method(method_element) + if has_untyped_block && block_arg_types.empty? + @index.add_pending_yield_def(node, owner, method_element) + end if body = node.body context_name = owner.responds_to?(:name) ? owner.name : nil body.accept(CallCollector.new(@index, method_element, node, context_name)) @@ -284,6 +468,39 @@ module CRA::Psi true end + def visit(node : Crystal::MacroIf) : Bool + expand_macro_if_text(node) + false + end + + private def expand_macro_if_text(node : Crystal::MacroIf) + expand_macro_branch(node.then) + expand_macro_branch(node.else) + end + + private def expand_macro_branch(node : Crystal::ASTNode) + text = String.build { |io| collect_macro_literals(node, io) } + return if text.blank? + begin + parser = Crystal::Parser.new(text) + parsed = parser.parse + parsed.accept(self) + rescue + end + end + + private def collect_macro_literals(node : Crystal::ASTNode, io : IO) + case node + when Crystal::Expressions + node.expressions.each { |e| collect_macro_literals(e, io) } + when Crystal::MacroLiteral + io << node.value + when Crystal::MacroIf + collect_macro_literals(node.then, io) + collect_macro_literals(node.else, io) + end + end + private def current_scope : String @owner_stack.last?.try(&.name) || "" end @@ -313,6 +530,157 @@ module CRA::Psi {min: required, max: max} end + private def extract_block_arg_types(node : Crystal::Def) : Array(CRA::Psi::TypeRef) + block_arg = node.block_arg + return [] of CRA::Psi::TypeRef unless block_arg + restriction = block_arg.restriction + return [] of CRA::Psi::TypeRef unless restriction.is_a?(Crystal::ProcNotation) + inputs = restriction.inputs + return [] of CRA::Psi::TypeRef unless inputs + types = [] of CRA::Psi::TypeRef + inputs.each do |input| + if type_ref = type_ref_from_type(input) + types << type_ref + end + end + types + end + + private def extract_block_return_type(node : Crystal::Def) : CRA::Psi::TypeRef? + block_arg = node.block_arg + return nil unless block_arg + restriction = block_arg.restriction + return nil unless restriction.is_a?(Crystal::ProcNotation) + output = restriction.output + return nil unless output + type_ref_from_type(output) + end + + private def extract_yield_arg_types(body : Crystal::ASTNode, def_node : Crystal::Def, owner : PsiElement) : Array(CRA::Psi::TypeRef) + extractor = YieldTypeExtractor.new(def_node, owner, @index) + body.accept(extractor) + extractor.types + end + + # Extracts types from the first yield statement in a method body. + # Handles simple patterns: yield with vars assigned from .new, typed args, + # or self references. Also tracks block params from calls whose methods + # have already been indexed. + class YieldTypeExtractor < Crystal::Visitor + include TypeRefHelper + + getter types : Array(CRA::Psi::TypeRef) + + def initialize(@def_node : Crystal::Def, @owner : PsiElement, @index : SemanticIndex) + @types = [] of CRA::Psi::TypeRef + @locals = {} of String => CRA::Psi::TypeRef + @found = false + end + + def visit(node : Crystal::ASTNode) : Bool + !@found + end + + def visit(node : Crystal::Call) : Bool + return false if @found + if block = node.block + hints = resolve_block_hints(node) + block.args.each_with_index do |arg, idx| + if hint = hints[idx]? + @locals[arg.name] = hint + end + end + end + true + end + + def visit(node : Crystal::Assign) : Bool + return false if @found + if (target = node.target).is_a?(Crystal::Var) + ref = type_ref_from_value(node.value) + if ref.nil? && (call = node.value).is_a?(Crystal::Call) + if (call.name == "new" || call.name == "null" || call.name == "malloc") && call.obj.nil? + ref = CRA::Psi::TypeRef.named(@owner.name) + end + end + @locals[target.name] = ref if ref + end + true + end + + def visit(node : Crystal::Def) : Bool + false + end + + def visit(node : Crystal::Yield) : Bool + return false if @found + @found = true + resolved = [] of CRA::Psi::TypeRef + node.exps.each do |exp| + type_ref = infer_yield_exp(exp) + if type_ref + resolved << type_ref + else + return false + end + end + @types = resolved + false + end + + private def resolve_block_hints(call : Crystal::Call) : Array(CRA::Psi::TypeRef) + obj = call.obj + return [] of CRA::Psi::TypeRef unless obj + receiver_ref = case obj + when Crystal::Path + CRA::Psi::TypeRef.named(obj.full) + when Crystal::Generic + type_ref_from_type(obj) + else + nil + end + return [] of CRA::Psi::TypeRef unless receiver_ref + owner = @index.resolve_type_ref_public(receiver_ref) + return [] of CRA::Psi::TypeRef unless owner + class_method = obj.is_a?(Crystal::Path) || obj.is_a?(Crystal::Generic) + candidates = @index.find_methods_in_type(owner, call.name, class_method) + return [] of CRA::Psi::TypeRef if candidates.empty? + method = candidates.first + types = method.block_arg_types + owner_name = method.owner.try(&.name) || owner.name + types.map { |t| t.name == "self" ? CRA::Psi::TypeRef.named(owner_name) : t } + end + + private def infer_yield_exp(node : Crystal::ASTNode) : CRA::Psi::TypeRef? + if ref = type_ref_from_value(node) + return ref + end + case node + when Crystal::Var + if node.name == "self" + return CRA::Psi::TypeRef.named(@owner.name) + end + @def_node.args.each do |arg| + if arg.name == node.name && (restriction = arg.restriction) + return type_ref_from_type(restriction) + end + end + @locals[node.name]? + when Crystal::InstanceVar + nil + when Crystal::Call + if node.name == "new" || node.name == "null" || node.name == "malloc" + if obj = node.obj + return type_ref_from_type(obj) + end + end + nil + else + nil + end + end + end + # Collects call edges from a method body to resolved method definitions. class CallCollector < Crystal::Visitor def initialize(@index : SemanticIndex, @from_method : CRA::Psi::Method, @scope_def : Crystal::Def, @context_name : String?) diff --git a/src/cra/semantic/semantic_index/collectors.cr b/src/cra/semantic/semantic_index/collectors.cr index e0a6ae8..28f14b2 100644 --- a/src/cra/semantic/semantic_index/collectors.cr +++ b/src/cra/semantic/semantic_index/collectors.cr @@ -8,8 +8,12 @@ module CRA::Psi @env : TypeEnv, @cursor : Crystal::Location?, @collect_locals : Bool, - @fill_only : Bool = false + @fill_only : Bool = false, + @infer_callback : Proc(Crystal::ASTNode, TypeRef?)? = nil, + @block_hints_callback : Proc(Crystal::Call, TypeRef?, Array(TypeRef))? = nil ) + @saved_locals_stack = [] of Hash(String, TypeRef) + @narrowings = [] of {String, Bool, TypeRef?} end def visit(node : Crystal::ASTNode) : Bool @@ -20,6 +24,9 @@ module CRA::Psi if block = node.block receiver_type = receiver_type_ref(node) hints = block_param_type_hints(node, receiver_type) + if hints.empty? && (cb = @block_hints_callback) + hints = cb.call(node, receiver_type) + end block.args.each_with_index do |arg, idx| type_ref = nil if arg.is_a?(Crystal::Arg) @@ -43,6 +50,15 @@ module CRA::Psi true end + def visit(node : Crystal::UninitializedVar) : Bool + return false unless before_cursor?(node) + + if type_ref = type_ref_from_type(node.declared_type) + assign_type(node.var, type_ref) + end + true + end + def visit(node : Crystal::Assign) : Bool return false unless before_cursor?(node) @@ -59,6 +75,7 @@ module CRA::Psi else nil end + type_ref ||= @infer_callback.try(&.call(node.value)) assign_type(node.target, type_ref) if type_ref end true @@ -80,6 +97,7 @@ module CRA::Psi else nil end + type_ref ||= @infer_callback.try(&.call(node.value)) assign_type(node.target, type_ref) if type_ref end true @@ -101,6 +119,96 @@ module CRA::Psi false end + def visit(node : Crystal::If) : Bool + return false unless before_cursor?(node) + push_locals_snapshot + node.cond.accept(self) + apply_is_a_narrowing(node.cond) + node.then.accept(self) + if cursor_in?(node.then) + restore_locals_with_union(node) + return false + end + unapply_narrowing + if always_exits?(node.then) + apply_inverse_is_a_narrowing(node.cond) + if else_node = node.else + else_node.accept(self) + end + @saved_locals_stack.pop? + else + if else_node = node.else + else_node.accept(self) + end + restore_locals_with_union(node) + end + false + end + + def visit(node : Crystal::Unless) : Bool + return false unless before_cursor?(node) + push_locals_snapshot + true + end + + def end_visit(node : Crystal::Unless) + restore_locals_with_union(node) + end + + def visit(node : Crystal::Case) : Bool + return false unless before_cursor?(node) + push_locals_snapshot + case_var = case cond = node.cond + when Crystal::Var then {cond.name, false} + when Crystal::InstanceVar then {cond.name, true} + else nil + end + node.whens.each do |wh| + if case_var + narrowed_type = extract_when_type(wh) + if narrowed_type + name, is_ivar = case_var + env = is_ivar ? @env.ivars : @env.locals + prev = env[name]? + env[name] = narrowed_type + wh.body.accept(self) + if cursor_in?(wh.body) + restore_locals_with_union(node) + return false + end + env[name] = prev if prev + next + end + end + wh.body.accept(self) + end + if else_body = node.else + else_body.accept(self) + end + restore_locals_with_union(node) + false + end + + def visit(node : Crystal::While) : Bool + return false unless before_cursor?(node) + push_locals_snapshot + true + end + + def end_visit(node : Crystal::While) + restore_locals_with_union(node) + end + + def visit(node : Crystal::ExceptionHandler) : Bool + return false unless before_cursor?(node) + push_locals_snapshot + true + end + + def end_visit(node : Crystal::ExceptionHandler) + restore_locals_with_union(node) + end + def register_arg(arg : Crystal::Arg) if restriction = arg.restriction if type_ref = type_ref_from_type(restriction) @@ -124,19 +232,157 @@ module CRA::Psi end end + private def push_locals_snapshot + @saved_locals_stack << @env.locals.dup + end + + private def restore_locals_with_union(node : Crystal::ASTNode) + saved = @saved_locals_stack.pop? || return + return unless ended_before_cursor?(node) + @env.locals.each do |name, current_ref| + if prev_ref = saved[name]? + next if prev_ref.display == current_ref.display + @env.locals[name] = union_type_refs(prev_ref, current_ref) + end + end + end + + private def ended_before_cursor?(node : Crystal::ASTNode) : Bool + cursor = @cursor + return true unless cursor + end_loc = node.end_location + return true unless end_loc + end_loc.line_number < cursor.line_number || + (end_loc.line_number == cursor.line_number && end_loc.column_number < cursor.column_number) + end + + private def union_type_refs(a : TypeRef, b : TypeRef) : TypeRef + members = [] of TypeRef + seen = Set(String).new + {a, b}.each do |ref| + if ref.union? + ref.union_types.each do |member| + members << member if seen.add?(member.display) + end + else + members << ref if seen.add?(ref.display) + end + end + members.size == 1 ? members.first : TypeRef.union(members) + end + + private def apply_is_a_narrowing(cond : Crystal::ASTNode) + @narrowings.clear + collect_narrowings(cond) + end + + private def collect_narrowings(cond : Crystal::ASTNode) + case cond + when Crystal::And + collect_narrowings(cond.left) + collect_narrowings(cond.right) + when Crystal::IsA + var_name = case obj = cond.obj + when Crystal::Var then obj.name + when Crystal::InstanceVar then obj.name + end + return unless var_name + narrowed_type = type_ref_from_type(cond.const) + return unless narrowed_type + is_ivar = cond.obj.is_a?(Crystal::InstanceVar) + env = is_ivar ? @env.ivars : @env.locals + prev = env[var_name]? + @narrowings << {var_name, is_ivar, prev} + env[var_name] = narrowed_type + when Crystal::Var + narrow_nil(cond.name, false) + when Crystal::InstanceVar + narrow_nil(cond.name, true) + end + end + + private def narrow_nil(name : String, is_ivar : Bool) + env = is_ivar ? @env.ivars : @env.locals + existing = env[name]? + return unless existing && existing.union? + non_nil = existing.union_types.reject { |t| t.name == "Nil" || t.name == "::Nil" } + return if non_nil.size == existing.union_types.size + @narrowings << {name, is_ivar, existing} + narrowed = non_nil.size == 1 ? non_nil.first : TypeRef.union(non_nil) + env[name] = narrowed + end + + private def unapply_narrowing + @narrowings.reverse_each do |name, is_ivar, prev| + env = is_ivar ? @env.ivars : @env.locals + if prev + env[name] = prev + else + env.delete(name) + end + end + @narrowings.clear + end + + private def always_exits?(node : Crystal::ASTNode) : Bool + last = case node + when Crystal::Expressions then node.expressions.last? + else node + end + return false unless last + last.is_a?(Crystal::Return) || last.is_a?(Crystal::Break) || last.is_a?(Crystal::Next) + end + + private def apply_inverse_is_a_narrowing(cond : Crystal::ASTNode) + case cond + when Crystal::IsA + var_name = case obj = cond.obj + when Crystal::Var then obj.name + when Crystal::InstanceVar then obj.name + else return + end + is_ivar = cond.obj.is_a?(Crystal::InstanceVar) + env = is_ivar ? @env.ivars : @env.locals + existing = env[var_name]? + return unless existing && existing.union? + checked = type_ref_from_type(cond.const) + return unless checked + checked_display = checked.display + remaining = existing.union_types.reject { |t| t.display == checked_display } + return if remaining.empty? + env[var_name] = remaining.size == 1 ? remaining.first : TypeRef.union(remaining) + when Crystal::And + apply_inverse_is_a_narrowing(cond.left) + apply_inverse_is_a_narrowing(cond.right) + end + end + + private def extract_when_type(wh : Crystal::When) : TypeRef? + return nil unless wh.conds.size == 1 + cond = wh.conds.first + case cond + when Crystal::Path, Crystal::Generic, Crystal::Union + type_ref_from_type(cond) + else + nil + end + end + private def receiver_type_ref(call : Crystal::Call) : TypeRef? obj = call.obj return nil unless obj case obj when Crystal::Var - @env.locals[obj.name]? + @env.locals[obj.name]? || @infer_callback.try(&.call(obj)) when Crystal::InstanceVar @env.ivars[obj.name]? when Crystal::ClassVar @env.cvars[obj.name]? when Crystal::Path, Crystal::Generic, Crystal::Metaclass, Crystal::Union, Crystal::Self type_ref_from_type(obj) + when Crystal::Call + @infer_callback.try(&.call(obj)) else type_ref_from_value(obj) end @@ -156,6 +402,12 @@ module CRA::Psi return hints end + # ClassName.new { |instance| ... } — block receives the new instance. + if method_name == "new" + hints << receiver_type + return hints + end + if name base = name.starts_with?("::") ? name[2..] : name case base @@ -201,6 +453,18 @@ module CRA::Psi loc.line_number < cursor.line_number || (loc.line_number == cursor.line_number && loc.column_number <= cursor.column_number) end + + private def cursor_in?(node : Crystal::ASTNode) : Bool + cursor = @cursor + return false unless cursor + start_loc = node.location + return false unless start_loc + end_loc = node.end_location || start_loc + (cursor.line_number > start_loc.line_number || + (cursor.line_number == start_loc.line_number && cursor.column_number >= start_loc.column_number)) && + (cursor.line_number < end_loc.line_number || + (cursor.line_number == end_loc.line_number && cursor.column_number <= end_loc.column_number)) + end end # Collects instance variable assignments inside initialize methods. @@ -290,6 +554,12 @@ module CRA::Psi true end + def visit(node : Crystal::UninitializedVar) : Bool + return false unless before_cursor?(node) + record_target(node.var) + true + end + def visit(node : Crystal::Block) : Bool return false unless cursor_in?(node) node.args.each do |arg| @@ -459,6 +729,53 @@ module CRA::Psi end end + # Finds the RHS value of the last assignment to a given local variable before the cursor. + class AssignmentValueCollector < Crystal::Visitor + getter value : Crystal::ASTNode? + + def initialize(@name : String, @cursor : Crystal::Location?) + end + + def visit(node : Crystal::ASTNode) : Bool + true + end + + def visit(node : Crystal::Assign) : Bool + return false unless before_cursor?(node) + if target = node.target.as?(Crystal::Var) + if target.name == @name + @value = node.value + end + end + true + end + + def visit(node : Crystal::Def) : Bool + false + end + + def visit(node : Crystal::ClassDef) : Bool + false + end + + def visit(node : Crystal::ModuleDef) : Bool + false + end + + def visit(node : Crystal::Macro) : Bool + false + end + + private def before_cursor?(node : Crystal::ASTNode) : Bool + cursor = @cursor + return true unless cursor + loc = node.location + return true unless loc + loc.line_number < cursor.line_number || + (loc.line_number == cursor.line_number && loc.column_number <= cursor.column_number) + end + end + # Collects class variable names within a class scope. class ClassVarNameCollector < Crystal::Visitor getter names : Hash(String, Crystal::ASTNode) diff --git a/src/cra/semantic/semantic_index/completion.cr b/src/cra/semantic/semantic_index/completion.cr index df56719..ac0c944 100644 --- a/src/cra/semantic/semantic_index/completion.cr +++ b/src/cra/semantic/semantic_index/completion.cr @@ -94,7 +94,13 @@ module CRA::Psi end # Builds a local type env from class-level declarations, initialize, and the current def. - private def build_type_env(scope_def : Crystal::Def?, scope_class : Crystal::ClassDef?, cursor : Crystal::Location?) : TypeEnv + private def build_type_env( + scope_def : Crystal::Def?, + scope_class : Crystal::ClassDef?, + cursor : Crystal::Location?, + context : String? = nil, + deep : Bool = false + ) : TypeEnv env = TypeEnv.new if scope_class # Collect ivar declarations and assignments at class level. @@ -103,6 +109,10 @@ module CRA::Psi scope_class.body.accept(InitializeCollector.new(TypeCollector.new(env, nil, false))) # Fill missing ivars/cvars from other method bodies (best-effort). scope_class.body.accept(DefIvarCollector.new(TypeCollector.new(env, nil, false, true))) + # Fill missing ivars from getter/property return types (macro-expanded). + if context && (owner = find_type(context)) + fill_ivars_from_getters(env, owner) + end end if scope_def scope_def.args.each do |arg| @@ -112,11 +122,83 @@ module CRA::Psi end end end - scope_def.body.accept(TypeCollector.new(env, cursor, true)) + infer_cb = if deep + ->(node : Crystal::ASTNode) { infer_type_ref(node, context, scope_def, scope_class, cursor) } + end + block_hints_cb = if deep + ->(call : Crystal::Call, receiver_type : TypeRef?) { block_param_types_for_call(call, receiver_type, context) } + end + scope_def.body.accept(TypeCollector.new(env, cursor, true, false, infer_cb, block_hints_cb)) end env end + # Getter/property macros expand to methods with return types but don't + # produce explicit ivar type declarations. Infer @name from the method's + # return type for any ivar not already typed in the env. Walks ancestors + # so child structs/classes inherit getter types. + private def fill_ivars_from_getters(env : TypeEnv, owner : PsiElement) + fill_ivars_from_getters_walk(env, owner, {} of String => Bool) + end + + private def fill_ivars_from_getters_walk(env : TypeEnv, owner : PsiElement, visited : Hash(String, Bool)) + return unless owner.is_a?(CRA::Psi::Class) || owner.is_a?(CRA::Psi::Module) + return if visited[owner.name]? + visited[owner.name] = true + + owner.methods.each do |method| + next if method.class_method + next unless method.min_arity == 0 && method.max_arity == 0 + ivar_name = "@#{method.name}" + next if env.ivars[ivar_name]? + if ret = method.return_type_ref + env.ivars[ivar_name] = ret + end + end + + case owner + when CRA::Psi::Class + if includes = @class_includes[owner.name]? + includes.each do |inc| + if resolved = resolve_type_node(inc, owner.name) + fill_ivars_from_getters_walk(env, resolved, visited) + end + end + end + if super_node = @class_superclass[owner.name]? + if resolved = resolve_type_node(super_node, owner.name) + fill_ivars_from_getters_walk(env, resolved, visited) + end + end + when CRA::Psi::Module + if includes = @module_includes[owner.name]? + includes.each do |inc| + if resolved = resolve_type_node(inc, owner.name) + fill_ivars_from_getters_walk(env, resolved, visited) + end + end + end + end + end + + private def block_param_types_for_call(call : Crystal::Call, receiver_type : TypeRef?, context : String?) : Array(TypeRef) + return [] of TypeRef unless receiver_type + class_method = false + if obj = call.obj + class_method = obj.is_a?(Crystal::Path) || obj.is_a?(Crystal::Generic) || obj.is_a?(Crystal::Metaclass) + end + owner = resolve_type_ref(receiver_type, context) + return [] of TypeRef unless owner + candidates = find_methods_with_ancestors(owner, call.name, class_method) + return [] of TypeRef if candidates.empty? + narrowed = filter_methods_by_arity_strict(candidates, call) + method = (narrowed.empty? ? candidates : narrowed).first + owner_name = method.owner.try(&.name) || owner.name + method.block_arg_types.map do |t| + t.name == "self" ? TypeRef.named(owner_name) : t + end + end + private def complete_members( context : CRA::CompletionContext, prefix : String, @@ -413,18 +495,23 @@ module CRA::Psi when Crystal::Call if type_ref = infer_type_ref(receiver, context, scope_def, scope_class, cursor) if owner = resolve_type_ref(type_ref, context) - return {owner, false} + is_class_call = receiver.name == "class" + return {owner, is_class_call} end end when Crystal::Var - type_env ||= build_type_env(scope_def, scope_class, cursor) - if type_ref = type_env.locals[receiver.name]? + type_env ||= build_type_env(scope_def, scope_class, cursor, context, deep: true) + type_ref = type_env.locals[receiver.name]? + unless type_ref + type_ref = infer_type_ref(receiver, context, scope_def, scope_class, cursor) + end + if type_ref if owner = resolve_type_ref(type_ref, context) return {owner, false} end end when Crystal::InstanceVar - type_env ||= build_type_env(scope_def, scope_class, cursor) + type_env ||= build_type_env(scope_def, scope_class, cursor, context, deep: true) if type_ref = type_env.ivars[receiver.name]? if owner = resolve_type_ref(type_ref, context) return {owner, false} @@ -434,7 +521,7 @@ module CRA::Psi return {owner, false} end when Crystal::ClassVar - type_env ||= build_type_env(scope_def, scope_class, cursor) + type_env ||= build_type_env(scope_def, scope_class, cursor, context, deep: true) if type_ref = type_env.cvars[receiver.name]? if owner = resolve_type_ref(type_ref, context) return {owner, true} @@ -525,12 +612,12 @@ module CRA::Psi else "#{method.min_arity}+" end - "#{owner_name}#{method.class_method ? "." : "#"}#{method.name} (arity #{arity})" + "#{owner_name}.#{method.name} (arity #{arity})" end private def method_signature(method : CRA::Psi::Method) : String owner_name = method.owner.try(&.name) || "self" - separator = method.class_method ? "." : "#" + separator = "." params = method.parameters.join(", ") signature = "def #{owner_name}#{separator}#{method.name}" signature += "(#{params})" unless params.empty? diff --git a/src/cra/semantic/semantic_index/indexing.cr b/src/cra/semantic/semantic_index/indexing.cr index fab9e99..75cd7a0 100644 --- a/src/cra/semantic/semantic_index/indexing.cr +++ b/src/cra/semantic/semantic_index/indexing.cr @@ -165,6 +165,28 @@ module CRA::Psi end end + # Crystal primitive types have implicit superclass relationships baked into + # the compiler that don't appear in the source code. Register them so that + # ancestor method lookups (e.g., Int64 → Int.from_io) work correctly. + PRIMITIVE_SUPERCLASSES = { + "Int8" => "Int", "Int16" => "Int", "Int32" => "Int", "Int64" => "Int", "Int128" => "Int", + "UInt8" => "Int", "UInt16" => "Int", "UInt32" => "Int", "UInt64" => "Int", "UInt128" => "Int", + "Float32" => "Float", "Float64" => "Float", + "Int" => "Number", "Float" => "Number", + "Number" => "Value", "Value" => "Object", + "Reference" => "Object", + "String" => "Reference", "Symbol" => "Value", + "Bool" => "Value", "Char" => "Value", "Nil" => "Value", + } + + def register_primitive_superclasses + PRIMITIVE_SUPERCLASSES.each do |child, parent| + next if @class_superclass[child]? + next unless find_class(child) || find_type(child) + @class_superclass[child] = Crystal::Path.new(parent) + end + end + def set_superclass(name : String, superclass : Crystal::ASTNode) file = @current_file if file diff --git a/src/cra/semantic/semantic_index/inference.cr b/src/cra/semantic/semantic_index/inference.cr index 80d6b5e..91955bd 100644 --- a/src/cra/semantic/semantic_index/inference.cr +++ b/src/cra/semantic/semantic_index/inference.cr @@ -8,7 +8,7 @@ module CRA::Psi cursor : Crystal::Location?, depth : Int32 = 0 ) : TypeRef? - return nil if depth > 4 + return nil if depth > 12 if type_ref = type_ref_from_value(node) return type_ref @@ -17,8 +17,30 @@ module CRA::Psi type_env : TypeEnv? = nil case node when Crystal::Var - type_env ||= build_type_env(scope_def, scope_class, cursor) - type_env.locals[node.name]? + if node.name == "self" && context + return TypeRef.named(context) + end + if scope_def + # Fast path: check def args directly. + scope_def.args.each do |arg| + if arg.name == node.name + if restriction = arg.restriction + return type_ref_from_type(restriction) + end + return nil + end + end + # Non-deep env handles ordered assignments and self-referential cases. + type_env ||= build_type_env(scope_def, scope_class, cursor) + if ref = type_env.locals[node.name]? + return ref + end + # Fall back to assignment value inference for chains (e.g., ptr.value). + if assign_val = find_local_assignment_value(scope_def, node.name, cursor) + return infer_type_ref(assign_val, context, scope_def, scope_class, cursor, depth + 1) + end + end + nil when Crystal::InstanceVar type_env ||= build_type_env(scope_def, scope_class, cursor) type_env.ivars[node.name]? @@ -29,6 +51,10 @@ module CRA::Psi type_ref_from_type(node) when Crystal::Call infer_type_ref_from_call(node, context, scope_def, scope_class, cursor, depth + 1) + when Crystal::If + infer_type_ref_from_if(node, context, scope_def, scope_class, cursor, depth + 1) + when Crystal::Case + infer_type_ref_from_case(node, context, scope_def, scope_class, cursor, depth + 1) else nil end @@ -42,7 +68,7 @@ module CRA::Psi cursor : Crystal::Location?, depth : Int32 ) : TypeRef? - if call.name == "new" + if call.name == "new" || call.name == "null" || call.name == "malloc" if obj = call.obj return type_ref_from_type(obj) end @@ -55,6 +81,9 @@ module CRA::Psi class_method = obj.is_a?(Crystal::Path) || obj.is_a?(Crystal::Generic) || obj.is_a?(Crystal::Metaclass) class_method = scope_def && scope_def.receiver ? true : false if obj.is_a?(Crystal::Self) receiver_type = infer_type_ref(obj, context, scope_def, scope_class, cursor, depth + 1) + if call.name == "class" && receiver_type + return receiver_type + end elsif context receiver_type = TypeRef.named(context) class_method = scope_def && scope_def.receiver ? true : false @@ -66,24 +95,255 @@ module CRA::Psi return indexed end end + if pointee = infer_pointer_deref_type(receiver_type, call.name) + return pointee + end owner = resolve_type_ref(receiver_type, context) - return nil unless owner + unless owner + # For unresolved class method calls (e.g., Int64.from_io), assume the + # return type is the receiver type since most class methods are factories. + return receiver_type if class_method + return nil + end candidates = find_methods_with_ancestors(owner, call.name, class_method) - return nil if candidates.empty? + if candidates.empty? + if class_method && call.name == "[]" + return infer_class_bracket_type(receiver_type, call) + end + # For class methods not in the index (e.g., stdlib), fall back to the + # receiver type. + return receiver_type if class_method + return nil + end narrowed = filter_methods_by_arity_strict(candidates, call) candidates = narrowed unless narrowed.empty? - method = candidates.find(&.return_type_ref) || candidates.first? + method = if call.block + candidates.find { |m| m.return_type_ref.nil? } || candidates.first? + else + candidates.find(&.return_type_ref) || candidates.first? + end return nil unless method - infer_method_return_type(method, receiver_type) + result = infer_method_return_type(method, receiver_type, call, context, scope_def, scope_class, cursor, depth) + if result.nil? && (block = call.block) + result = infer_block_body_type(block, context, scope_def, scope_class, cursor, depth) + end + result + end + + private def infer_type_ref_from_if( + node : Crystal::If, + context : String?, + scope_def : Crystal::Def?, + scope_class : Crystal::ClassDef?, + cursor : Crystal::Location?, + depth : Int32 + ) : TypeRef? + types = [] of TypeRef + seen = Set(String).new + + # Collect the then branch type. + if then_type = infer_branch_type(node.then, context, scope_def, scope_class, cursor, depth) + types << then_type if seen.add?(then_type.display) + end + + # Walk the elsif/else chain (Crystal models elsif as nested If in the else). + else_node = node.else + while else_node + case else_node + when Crystal::If + if then_type = infer_branch_type(else_node.then, context, scope_def, scope_class, cursor, depth) + types << then_type if seen.add?(then_type.display) + end + else_node = else_node.else + when Crystal::Nop + break + else + if else_type = infer_branch_type(else_node, context, scope_def, scope_class, cursor, depth) + types << else_type if seen.add?(else_type.display) + end + break + end + end + + return nil if types.empty? + return types.first if types.size == 1 + TypeRef.union(types) + end + + private def infer_type_ref_from_case( + node : Crystal::Case, + context : String?, + scope_def : Crystal::Def?, + scope_class : Crystal::ClassDef?, + cursor : Crystal::Location?, + depth : Int32 + ) : TypeRef? + types = [] of TypeRef + seen = Set(String).new + + node.whens.each do |wh| + if wh_type = infer_branch_type(wh.body, context, scope_def, scope_class, cursor, depth) + types << wh_type if seen.add?(wh_type.display) + end + end + + if else_body = node.else + if else_type = infer_branch_type(else_body, context, scope_def, scope_class, cursor, depth) + types << else_type if seen.add?(else_type.display) + end + end + + return nil if types.empty? + return types.first if types.size == 1 + TypeRef.union(types) end - private def infer_method_return_type(method : CRA::Psi::Method, receiver_type : TypeRef) : TypeRef? + # Infers the type of the last expression in a branch body, skipping + # branches that always exit (raise, return, break, next). + private def infer_branch_type( + body : Crystal::ASTNode, + context : String?, + scope_def : Crystal::Def?, + scope_class : Crystal::ClassDef?, + cursor : Crystal::Location?, + depth : Int32 + ) : TypeRef? + last = case body + when Crystal::Expressions then body.expressions.last? + when Crystal::Nop then return nil + else body + end + return nil unless last + return nil if branch_exits?(last) + infer_type_ref(last, context, scope_def, scope_class, cursor, depth) + end + + private def branch_exits?(node : Crystal::ASTNode) : Bool + node.is_a?(Crystal::Return) || node.is_a?(Crystal::Break) || node.is_a?(Crystal::Next) || + (node.is_a?(Crystal::Call) && node.name == "raise") + end + + # When a method has no return type and is called with a block, + # infer the type from the block body's last expression. + private def infer_block_body_type( + block : Crystal::Block, + context : String?, + scope_def : Crystal::Def?, + scope_class : Crystal::ClassDef?, + cursor : Crystal::Location?, + depth : Int32 + ) : TypeRef? + body = block.body + return nil unless body + last_expr = body.is_a?(Crystal::Expressions) ? body.expressions.last? : body + return nil unless last_expr + infer_type_ref(last_expr, context, scope_def, scope_class, cursor, depth + 1) + end + + # Infers the return type of a class-level [] call (e.g., Slice[1u8, 2u8]). + # These are typically macros that construct an instance of the receiver type. + private def infer_class_bracket_type(receiver_type : TypeRef, call : Crystal::Call) : TypeRef? + name = receiver_type.name + return receiver_type unless name + + if first_arg = call.args.first? + if elem_ref = type_ref_from_value(first_arg) + return TypeRef.named(name, [elem_ref]) + end + end + + receiver_type + end + + private def infer_method_return_type( + method : CRA::Psi::Method, + receiver_type : TypeRef, + call : Crystal::Call? = nil, + context : String? = nil, + scope_def : Crystal::Def? = nil, + scope_class : Crystal::ClassDef? = nil, + cursor : Crystal::Location? = nil, + depth : Int32 = 0 + ) : TypeRef? return nil unless return_ref = method.return_type_ref substitutions = type_vars_for_owner(method.owner, receiver_type) - substitute_type_ref(return_ref, substitutions, receiver_type) + if call + infer_free_var_substitutions(method, call, substitutions, context, scope_def, scope_class, cursor, depth) + end + result = substitute_type_ref(return_ref, substitutions, receiver_type) + owner_context = method.owner.try(&.name) + qualify_type_ref(result, owner_context) + end + + private def infer_free_var_substitutions( + method : CRA::Psi::Method, + call : Crystal::Call, + substitutions : Hash(String, TypeRef), + context : String?, + scope_def : Crystal::Def?, + scope_class : Crystal::ClassDef?, + cursor : Crystal::Location?, + depth : Int32 + ) + type_var_names = method.free_vars.to_set + if type_var_names.empty? && (return_ref = method.return_type_ref) + collect_type_var_candidates(return_ref, method.param_type_refs, type_var_names, context) + end + return if type_var_names.empty? + + method.param_type_refs.each_with_index do |param_ref, idx| + next unless param_ref + name = param_ref.name + next unless name + next if substitutions[name]? + next unless type_var_names.includes?(name) + + arg = call.args[idx]? + next unless arg + + if arg_type = infer_type_ref(arg, context, scope_def, scope_class, cursor, depth + 1) + substitutions[name] = arg_type + end + end + + if (block = call.block) && (block_ret_ref = method.block_return_type_ref) + block_ret_name = block_ret_ref.name + if block_ret_name && !substitutions[block_ret_name]? && type_var_names.includes?(block_ret_name) + if body_type = infer_block_body_type(block, context, scope_def, scope_class, cursor, depth) + substitutions[block_ret_name] = body_type + end + end + end + end + + private def collect_type_var_candidates( + return_ref : TypeRef, + param_type_refs : Array(TypeRef?), + candidates : Set(String), + context : String? + ) + names = [] of String + collect_type_ref_names(return_ref, names) + names.each do |name| + next if resolve_type_name(name, context) + if param_type_refs.any? { |pr| pr && pr.name == name } + candidates << name + end + end + end + + private def collect_type_ref_names(type_ref : TypeRef, names : Array(String)) + if type_ref.union? + type_ref.union_types.each { |member| collect_type_ref_names(member, names) } + return + end + if name = type_ref.name + names << name + end + type_ref.args.each { |arg| collect_type_ref_names(arg, names) } end private def type_vars_for_owner(owner : PsiElement | Nil, receiver_type : TypeRef) : Hash(String, TypeRef) @@ -109,7 +369,9 @@ module CRA::Psi ) : TypeRef if type_ref.union? types = type_ref.union_types.map { |member| substitute_type_ref(member, substitutions, receiver_type) } - return TypeRef.union(types) + seen = Set(String).new + types = types.select { |t| seen.add?(t.display) } + return types.size == 1 ? types.first : TypeRef.union(types) end name = type_ref.name @@ -121,6 +383,28 @@ module CRA::Psi TypeRef.named(name, args) end + private def qualify_type_ref(type_ref : TypeRef, context : String?) : TypeRef + return type_ref unless context + if type_ref.union? + types = type_ref.union_types.map { |m| qualify_type_ref(m, context) } + return TypeRef.union(types) + end + name = type_ref.name + return type_ref unless name + args = type_ref.args.empty? ? type_ref.args : type_ref.args.map { |a| qualify_type_ref(a, context) } + return TypeRef.named(name, args) if name.includes?("::") + return TypeRef.named(name, args) if find_type(name) + parts = context.split("::") + while parts.size > 0 + qualified = (parts + [name]).join("::") + if find_type(qualified) + return TypeRef.named(qualified, args) + end + parts.pop + end + TypeRef.named(name, args) + end + private def nil_type?(type_ref : TypeRef) : Bool return false if type_ref.union? name = type_ref.name @@ -155,6 +439,15 @@ module CRA::Psi end end + private def infer_pointer_deref_type(receiver_type : TypeRef, method_name : String) : TypeRef? + return nil unless {"current", "value", "[]"}.includes?(method_name) + name = receiver_type.name + return nil unless name + base_name = name.starts_with?("::") ? name[2..] : name + return nil unless base_name == "Pointer" + receiver_type.args.first? + end + private def range_index?(call : Crystal::Call) : Bool call.args.any? { |arg| arg.is_a?(Crystal::RangeLiteral) } end @@ -253,6 +546,12 @@ module CRA::Psi end end + private def find_local_assignment_value(scope_def : Crystal::Def, name : String, cursor : Crystal::Location?) : Crystal::ASTNode? + collector = AssignmentValueCollector.new(name, cursor) + scope_def.body.accept(collector) + collector.value + end + private def resolve_path(path : Crystal::Path, context : String?) : CRA::Psi::Module | CRA::Psi::Class | CRA::Psi::Enum | Nil name = path.full return find_type(name) if path.global? diff --git a/src/cra/semantic/semantic_index/resolution.cr b/src/cra/semantic/semantic_index/resolution.cr index d4f3d5f..7e3efe7 100644 --- a/src/cra/semantic/semantic_index/resolution.cr +++ b/src/cra/semantic/semantic_index/resolution.cr @@ -78,7 +78,8 @@ module CRA::Psi scope_def : Crystal::Def? = nil, scope_class : Crystal::ClassDef? = nil, cursor : Crystal::Location? = nil, - current_file : String? = nil + current_file : String? = nil, + proc_def : Crystal::Def? = nil ) : Array(PsiElement) results = [] of PsiElement type_env : TypeEnv? = nil @@ -100,21 +101,85 @@ module CRA::Psi if context && (owner = find_type(context)) results.concat(find_methods_with_ancestors(owner, node.name)) end + when Crystal::Arg + type_refs = type_refs_for_node(node, context, scope_def, scope_class, cursor) + if type_refs.empty? && context + # For ivar-backed params (@name in initialize), infer from getter type. + type_env ||= build_type_env(scope_def, scope_class, cursor, context, deep: true) + if ivar_ref = type_env.ivars["@#{node.name}"]? + type_refs = [ivar_ref] + end + end + if type_refs.any? + file = current_file || @current_file + arg_type = type_refs.map(&.display).join(" | ") + results << CRA::Psi::LocalVar.new( + file: file, + name: node.name, + type: arg_type, + location: location_for(node) + ) + end when Crystal::Var - if scope_def + if proc_def + proc_def.args.each do |arg| + next unless arg.name == node.name + if restriction = arg.restriction + if type_ref = type_ref_from_type(restriction) + results << CRA::Psi::LocalVar.new( + file: current_file || @current_file, + name: node.name, + type: type_ref.display, + location: location_for(arg) + ) + end + end + break + end + end + if results.empty? && scope_def if def_node = local_definition(scope_def, node.name, cursor) file = current_file || @current_file + type_env ||= build_type_env(scope_def, scope_class, cursor, context, deep: true) + local_type = type_env.locals[node.name]?.try(&.display) || "" + if local_type.empty? + if type_ref = infer_type_ref(node, context, scope_def, scope_class, cursor) + local_type = type_ref.display + end + end results << CRA::Psi::LocalVar.new( file: file, name: node.name, + type: local_type, location: location_for(def_node) ) end end + if results.empty? && context && (owner = find_type(context)) + # Class-level var (e.g., inside getter/property/setter declaration). + # Look for a matching accessor method (including ancestors) to get the type. + methods = find_methods_with_ancestors(owner, node.name, false) + if method = methods.find(&.return_type_ref) + file = current_file || @current_file + results << CRA::Psi::LocalVar.new( + file: file, + name: node.name, + type: method.return_type_ref.not_nil!.display, + location: location_for(node) + ) + end + end when Crystal::InstanceVar - if def_node = instance_var_definition(scope_def, scope_class, node.name, cursor) + def_node = instance_var_definition(scope_def, scope_class, node.name, cursor) + # When no local definition is found (e.g., inherited ivar), still try + # the type env which walks ancestors via fill_ivars_from_getters. + unless def_node + type_env ||= build_type_env(scope_def, scope_class, cursor, context, deep: true) + def_node = node if type_env.ivars[node.name]? + end + if def_node file = current_file || @current_file - type_env ||= build_type_env(scope_def, scope_class, cursor) + type_env ||= build_type_env(scope_def, scope_class, cursor, context, deep: true) ivar_type = type_env.ivars[node.name]?.try(&.display) || "Unknown" if context && (owner = find_class(context)) results << CRA::Psi::InstanceVar.new( @@ -150,21 +215,25 @@ module CRA::Psi end end when Crystal::Var - type_env ||= build_type_env(scope_def, scope_class, cursor) - if type_ref = type_env.locals[obj.name]? + type_env ||= build_type_env(scope_def, scope_class, cursor, context, deep: true) + type_ref = type_env.locals[obj.name]? + unless type_ref + type_ref = infer_type_ref(obj, context, scope_def, scope_class, cursor) + end + if type_ref if owner = resolve_type_ref(type_ref, context) candidates.concat(find_methods_with_ancestors(owner, node.name, false)) end end when Crystal::InstanceVar - type_env ||= build_type_env(scope_def, scope_class, cursor) + type_env ||= build_type_env(scope_def, scope_class, cursor, context, deep: true) if type_ref = type_env.ivars[obj.name]? if owner = resolve_type_ref(type_ref, context) candidates.concat(find_methods_with_ancestors(owner, node.name, false)) end end when Crystal::ClassVar - type_env ||= build_type_env(scope_def, scope_class, cursor) + type_env ||= build_type_env(scope_def, scope_class, cursor, context, deep: true) if type_ref = type_env.cvars[obj.name]? if owner = resolve_type_ref(type_ref, context) candidates.concat(find_methods_with_ancestors(owner, node.name, false)) @@ -173,7 +242,8 @@ module CRA::Psi else if type_ref = infer_type_ref(obj, context, scope_def, scope_class, cursor) if owner = resolve_type_ref(type_ref, context) - candidates.concat(find_methods_with_ancestors(owner, node.name, false)) + is_class_call = obj.is_a?(Crystal::Call) && obj.name == "class" + candidates.concat(find_methods_with_ancestors(owner, node.name, is_class_call)) end end end diff --git a/src/cra/semantic/semantic_index/state.cr b/src/cra/semantic/semantic_index/state.cr index 4e81501..ec20926 100644 --- a/src/cra/semantic/semantic_index/state.cr +++ b/src/cra/semantic/semantic_index/state.cr @@ -31,6 +31,7 @@ module CRA::Psi @reverse_call_graph : Hash(String, Array(CallEdge)) = {} of String => Array(CallEdge) @method_by_key : Hash(String, CRA::Psi::Method) = {} of String => CRA::Psi::Method @methods_by_file : Hash(String, Array(CRA::Psi::Method)) = {} of String => Array(CRA::Psi::Method) + @pending_yield_defs : Array({Crystal::Def, PsiElement, Method}) = [] of {Crystal::Def, PsiElement, Method} struct CallEdge getter target_key : String @@ -66,6 +67,33 @@ module CRA::Psi end end + # Public wrappers for indexer use (e.g. YieldTypeExtractor). + def resolve_type_ref_public(type_ref : TypeRef) : CRA::Psi::Module | CRA::Psi::Class | CRA::Psi::Enum | Nil + resolve_type_ref(type_ref, nil) + end + + def find_methods_in_type(owner : CRA::Psi::PsiElement, name : String, class_method : Bool) : Array(Method) + find_methods_with_ancestors(owner, name, class_method) + end + + def add_pending_yield_def(def_node : Crystal::Def, owner : PsiElement, method : Method) + @pending_yield_defs << {def_node, owner, method} + end + + def resolve_pending_yield_types + @pending_yield_defs.each do |def_node, owner, method| + next unless method.block_arg_types.empty? + body = def_node.body + next unless body + extractor = SemanticIndexer::YieldTypeExtractor.new(def_node, owner, self) + body.accept(extractor) + unless extractor.types.empty? + method.block_arg_types = extractor.types + end + end + @pending_yield_defs.clear + end + # Lightweight type hints collected from the current lexical scope. class TypeEnv getter locals : Hash(String, TypeRef) diff --git a/src/cra/semantic/type_ref_helper.cr b/src/cra/semantic/type_ref_helper.cr index 30c799a..338f5ab 100644 --- a/src/cra/semantic/type_ref_helper.cr +++ b/src/cra/semantic/type_ref_helper.cr @@ -29,7 +29,11 @@ module CRA::Psi types = [] of TypeRef node.types.each do |type| if ref = type_ref_from_type(type) - types << ref + if ref.union? + types.concat(ref.union_types) + else + types << ref + end end end return nil if types.empty? @@ -37,6 +41,10 @@ module CRA::Psi TypeRef.union(types) when Crystal::Self TypeRef.named("self") + when Crystal::NumberLiteral + TypeRef.named(node.value) + when Crystal::ProcNotation + proc_type_ref_from_notation(node) else nil end @@ -44,12 +52,26 @@ module CRA::Psi private def type_ref_from_value(node : Crystal::ASTNode) : TypeRef? case node + when Crystal::BoolLiteral + TypeRef.named("Bool") + when Crystal::NilLiteral + TypeRef.named("Nil") + when Crystal::StringLiteral + TypeRef.named("String") + when Crystal::CharLiteral + TypeRef.named("Char") + when Crystal::SymbolLiteral + TypeRef.named("Symbol") + when Crystal::RegexLiteral + TypeRef.named("Regex") + when Crystal::NumberLiteral + TypeRef.named(number_literal_type(node)) when Crystal::Cast type_ref_from_type(node.to) when Crystal::NilableCast type_ref_from_type(node.to) when Crystal::Call - if node.name == "new" + if node.name == "new" || node.name == "null" || node.name == "malloc" if obj = node.obj type_ref_from_type(obj) end @@ -59,6 +81,10 @@ module CRA::Psi if inner = type_ref_from_type(of_type) TypeRef.named("Array", [inner]) end + elsif first_elem = node.elements.first? + if inner = type_ref_from_value(first_elem) + TypeRef.named("Array", [inner]) + end end when Crystal::HashLiteral if of_entry = node.of @@ -67,10 +93,76 @@ module CRA::Psi if key && value TypeRef.named("Hash", [key, value]) end + elsif first_entry = node.entries.first? + key = type_ref_from_value(first_entry.key) + value = type_ref_from_value(first_entry.value) + if key && value + TypeRef.named("Hash", [key, value]) + end end + when Crystal::ProcLiteral + proc_type_ref_from_literal(node) else nil end end + + private def proc_type_ref_from_literal(node : Crystal::ProcLiteral) : TypeRef? + proc_def = node.def + args = [] of TypeRef + proc_def.args.each do |arg| + if restriction = arg.restriction + if ref = type_ref_from_type(restriction) + args << ref + else + return nil + end + else + return nil + end + end + ret = if ret_type = proc_def.return_type + type_ref_from_type(ret_type) + end + args << (ret || TypeRef.named("Nil")) + TypeRef.named("Proc", args) + end + + private def proc_type_ref_from_notation(node : Crystal::ProcNotation) : TypeRef? + args = [] of TypeRef + if inputs = node.inputs + inputs.each do |input| + if ref = type_ref_from_type(input) + args << ref + else + return nil + end + end + end + ret = if output = node.output + type_ref_from_type(output) + end + args << (ret || TypeRef.named("Nil")) + TypeRef.named("Proc", args) + end + + private def number_literal_type(node : Crystal::NumberLiteral) : String + case node.kind + when .i8? then "Int8" + when .i16? then "Int16" + when .i32? then "Int32" + when .i64? then "Int64" + when .i128? then "Int128" + when .u8? then "UInt8" + when .u16? then "UInt16" + when .u32? then "UInt32" + when .u64? then "UInt64" + when .u128? then "UInt128" + when .f32? then "Float32" + when .f64? then "Float64" + else + node.value.includes?('.') ? "Float64" : "Int32" + end + end end end diff --git a/src/cra/workspace.cr b/src/cra/workspace.cr index 8c9c8d8..60edb34 100644 --- a/src/cra/workspace.cr +++ b/src/cra/workspace.cr @@ -64,6 +64,8 @@ module CRA scan_path(lib_path, seen) if Dir.exists?(lib_path.to_s) scan_path(@path, seen) + @analyzer.resolve_pending_yield_types + @analyzer.register_primitive_superclasses @analyzer.dump_roots if ENV["CRA_DUMP_ROOTS"]? == "1" end @@ -159,6 +161,7 @@ module CRA reindexed << dep_uri end + @analyzer.resolve_pending_yield_types reindexed rescue ex : Exception Log.error { "Error reindexing #{uri}: #{ex.message}" } @@ -233,10 +236,11 @@ module CRA definitions = @analyzer.find_definitions( n, finder.enclosing_type_name, - finder.enclosing_def, + effective_scope_def(finder, doc), finder.enclosing_class, finder.cursor_location, - request.text_document.uri + request.text_document.uri, + proc_def: finder.enclosing_proc_def ) return elements_to_locations(definitions) end @@ -315,10 +319,11 @@ module CRA definitions = @analyzer.find_definitions( node, finder.enclosing_type_name, - finder.enclosing_def, + effective_scope_def(finder, document), finder.enclosing_class, finder.cursor_location, - request.text_document.uri + request.text_document.uri, + proc_def: finder.enclosing_proc_def ) return nil if definitions.empty? @@ -587,18 +592,47 @@ module CRA def_loc = def_node.location def_file = def_node.file next unless def_loc && def_file - uri = def_file.starts_with?("file://") ? def_file : "file://#{def_file}" - key = "#{uri}:#{def_loc.start_line}:#{def_loc.start_character}:#{def_loc.end_line}:#{def_loc.end_character}" + uri, range = resolve_macro_uri(def_file, def_loc) + key = "#{uri}:#{range.start_position.line}:#{range.start_position.character}:#{range.end_position.line}:#{range.end_position.character}" next if seen[key]? seen[key] = true - locations << Types::Location.new( - uri: uri, - range: def_loc.to_range - ) + locations << Types::Location.new(uri: uri, range: range) end locations end + private def resolve_macro_uri(file : String, loc : Psi::Location) : {String, Types::Range} + if file.starts_with?("crystal-macro:") + # Format: crystal-macro:{path}/{macro_name}/{line}_{col}.cr + raw = file.sub("crystal-macro:", "") + if match = raw.match(/^(.+)\/\w+\/(\d+)_(\d+)\.cr$/) + original_path = match[1] + line = match[2].to_i - 1 + col = match[3].to_i - 1 + uri = "file://#{original_path}" + range = Types::Range.new( + start_position: Types::Position.new(line: line, character: col), + end_position: Types::Position.new(line: line, character: col) + ) + return {uri, range} + end + end + uri = file.starts_with?("file://") ? file : "file://#{file}" + {uri, loc.to_range} + end + + # For top-level code (no enclosing def), create a synthetic Def so that + # build_type_env / TypeCollector can collect local variable types. + private def effective_scope_def(finder : NodeFinder, document : WorkspaceDocument) : Crystal::Def? + finder.enclosing_def || file_scope_def(document) + end + + private def file_scope_def(document : WorkspaceDocument) : Crystal::Def? + if body = document.program + Crystal::Def.new("__file__", [] of Crystal::Arg, body) + end + end + private def hover_contents(definitions : Array(Psi::PsiElement)) : JSON::Any sections = [] of String seen = {} of String => Bool @@ -649,12 +683,23 @@ module CRA private def hover_signature(definition : Psi::PsiElement) : String case definition when Psi::Method + # Simple getter: 0-param instance method with a return type — show as property. + if !definition.class_method && definition.min_arity == 0 && definition.max_arity == 0 && + (definition.return_type_ref || definition.return_type != "Nil") + return "#{definition.name} : #{definition.return_type}" + end owner_name = definition.owner.try(&.name) || "self" - separator = definition.class_method ? "." : "#" - params = definition.parameters.join(", ") + separator = "." + params = definition.parameters.map_with_index { |name, i| + if type_ref = definition.param_type_refs[i]? + "#{name} : #{type_ref.display}" + else + name + end + }.join(", ") signature = "def #{owner_name}#{separator}#{definition.name}" signature += "(#{params})" unless params.empty? - if definition.return_type_ref + if definition.return_type_ref || definition.return_type != "Nil" signature += " : #{definition.return_type}" end signature @@ -678,6 +723,12 @@ module CRA when Psi::ClassVar type_name = definition.type.empty? ? "Unknown" : definition.type "#{definition.name} : #{type_name}" + when Psi::LocalVar + if definition.type.empty? + definition.name + else + "#{definition.name} : #{definition.type}" + end else definition.name end diff --git a/src/cra/workspace/document.cr b/src/cra/workspace/document.cr index 58cfae9..e6aea91 100644 --- a/src/cra/workspace/document.cr +++ b/src/cra/workspace/document.cr @@ -168,16 +168,6 @@ module CRA ) end - if line.strip == "rescue" - start_pos = Types::Position.new(line: idx, character: 0) - end_pos = Types::Position.new(line: idx, character: line.size) - @diagnostics << Types::Diagnostic.new( - range: Types::Range.new(start_pos, end_pos), - severity: Types::DiagnosticSeverity::Warning, - message: "Empty rescue block?", - source: "lint" - ) - end if line =~ /\S\s+$/ start_pos = Types::Position.new(line: idx, character: line.rstrip.size) @@ -254,6 +244,8 @@ module CRA program.accept(collector) block_collector = UnusedBlockArgCollector.new(@diagnostics) program.accept(block_collector) + rescue_collector = EmptyRescueCollector.new(@diagnostics) + program.accept(rescue_collector) end # Collect unused def args (ignores names starting with underscore). @@ -266,6 +258,7 @@ module CRA continue_all def visit(node : Crystal::Def) : Bool + return true if node.abstract? args = node.args.reject { |arg| arg.name.starts_with?("_") } return true if args.empty? @@ -355,6 +348,34 @@ module CRA end end + # Detects bare rescue blocks with empty bodies. + class EmptyRescueCollector < Crystal::Visitor + include Workspace::VisitorHelpers + + def initialize(@diagnostics : Array(CRA::Types::Diagnostic)) + end + + continue_all + + def visit(node : Crystal::ExceptionHandler) : Bool + node.rescues.try &.each do |rescue_node| + next unless rescue_node.types.nil? && rescue_node.name.nil? + next unless rescue_node.body.is_a?(Crystal::Nop) + if loc = rescue_node.location + start_pos = CRA::Types::Position.new(line: loc.line_number - 1, character: loc.column_number - 1) + end_pos = CRA::Types::Position.new(line: loc.line_number - 1, character: loc.column_number - 1 + "rescue".size) + @diagnostics << CRA::Types::Diagnostic.new( + range: CRA::Types::Range.new(start_pos, end_pos), + severity: CRA::Types::DiagnosticSeverity::Warning, + message: "Empty rescue block?", + source: "lint" + ) + end + end + true + end + end + private def offset_to_line_col(text : String, idx : Int32) : {Int32, Int32} line = 0 col = 0 diff --git a/src/cra/workspace/node_finder.cr b/src/cra/workspace/node_finder.cr index f9af00c..6a1c11f 100644 --- a/src/cra/workspace/node_finder.cr +++ b/src/cra/workspace/node_finder.cr @@ -18,6 +18,7 @@ module CRA @node_path = [] of Crystal::ASTNode @previous_node_path = [] of Crystal::ASTNode @stack = [] of Crystal::ASTNode + @best_depth = 0 @cursor_location = Crystal::Location.new( filename: "", line_number: @line + 1, @@ -29,9 +30,10 @@ module CRA def visit(node : Crystal::ASTNode) : Bool return false unless traversable?(node) @stack << node - if hits?(node) + if hits?(node) && @stack.size >= @best_depth @node = node @node_path = @stack.dup + @best_depth = @stack.size end update_previous(node) node.accept_children(self) @@ -67,11 +69,20 @@ module CRA def enclosing_def : Crystal::Def? context_path.reverse_each do |node| + next if node.is_a?(Crystal::Def) && node.name == "->" return node if node.is_a?(Crystal::Def) end nil end + def enclosing_proc_def : Crystal::Def? + context_path.reverse_each do |node| + return node if node.is_a?(Crystal::Def) && node.name == "->" + return nil if node.is_a?(Crystal::Def) + end + nil + end + def enclosing_class : Crystal::ClassDef? context_path.reverse_each do |node| return node if node.is_a?(Crystal::ClassDef) @@ -119,6 +130,10 @@ module CRA size = node.name_size return nil if size <= 0 + # Crystal::Arg location starts at '@' for ivar-backed params but + # name_size only counts the name without the '@' prefix. + size += 1 if node.is_a?(Crystal::Arg) && !node.name_location + end_loc = Crystal::Location.new( filename: loc.filename, line_number: loc.line_number,