From c92224893c9c2dd3cb980976b63ed8406db8cdca Mon Sep 17 00:00:00 2001 From: Sebastian Pfitzner Date: Wed, 15 Jul 2026 16:46:04 +0000 Subject: [PATCH] feat: infer type of assignments from a type-asserted RHS Field completion now narrows through a local type assertion: an assignment whose right-hand side is a `::` type assertion (`y = x::T` or `x = x::T`) gives the assigned binding the asserted type, the same way a `::T` parameter declaration does. Because each assignment is its own binding, narrowing is scoped correctly by StaticLint's existing resolution, including per-branch. Co-Authored-By: Claude Opus 4.8 --- src/StaticLint/type_inf.jl | 5 ++ test/test_completions.jl | 100 +++++++++++++++++++++++++++++++++++++ test/test_typeinf.jl | 35 +++++++++++++ 3 files changed, 140 insertions(+) diff --git a/src/StaticLint/type_inf.jl b/src/StaticLint/type_inf.jl index e669cfd..1afee0c 100644 --- a/src/StaticLint/type_inf.jl +++ b/src/StaticLint/type_inf.jl @@ -56,6 +56,11 @@ function infer_type_assignment_rhs(binding, state, scope) if ref isa Binding && ref.val isa EXPR settype!(binding, infer_eltype(ref.val, state)) end + elseif CSTParser.isdeclaration(rhs) && length(rhs.args) == 2 && !is_destructuring + # RHS is a type assertion (`y = x::T`, `x = x::T`): the assigned binding + # takes the asserted type, the same way a `::T` parameter declaration + # does. This lets field completion narrow through a local assertion. + infer_type_decl(binding, rhs.args[2], state, scope) else if CSTParser.is_func_call(rhs) if CSTParser.istuple(lhs) && !is_destructuring diff --git a/test/test_completions.jl b/test/test_completions.jl index 3144036..12cf3d3 100644 --- a/test/test_completions.jl +++ b/test/test_completions.jl @@ -973,3 +973,103 @@ end result2 = get_completions(jw2, uri2, index2) @test result2 isa CompletionResult end + +@testitem "Completions: getfield via type-asserted assignment" begin + using JuliaWorkspaces: JuliaWorkspace, add_file!, TextFile, SourceText, get_completions + using JuliaWorkspaces.URIs2: URI + + structs = """ + abstract type Parent end + struct Child1 <: Parent + field1::Int + end + struct Child2 <: Parent + field2::String + end + """ + + function fields_at(source, marker) + uri = URI("file:///compassert/test.jl") + jw = JuliaWorkspace() + add_file!(jw, TextFile(uri, SourceText(source, "julia"))) + index = findfirst(marker, source)[end] + sort([item.label for item in get_completions(jw, uri, index).items]) + end + + # Rebinding through an assertion: `x = x::Child1` + rebind = structs * """ + function foo(x::Parent) + x = x::Child1 + x. + end + """ + @test fields_at(rebind, "\n x.\n") == ["field1"] + + # Fresh variable bound to an asserted value: `y = x::Child1` + freshvar = structs * """ + function foo(x::Parent) + y = x::Child1 + y. + end + """ + @test fields_at(freshvar, "\n y.\n") == ["field1"] + + # Untyped parameter still narrows through the assertion. + untyped = structs * """ + function foo(x) + y = x::Child1 + y. + end + """ + @test fields_at(untyped, "\n y.\n") == ["field1"] + + # Partial field text still matches. + partial = structs * """ + function foo(x::Parent) + y = x::Child1 + y.fie + end + """ + @test fields_at(partial, "y.fie") == ["field1"] + + # Baseline: an abstract declared type with no assertion yields no fields. + baseline = structs * """ + function foo(x::Parent) + x. + end + """ + @test fields_at(baseline, "\n x.\n") == String[] +end + +@testitem "Completions: getfield type assertion narrows per branch" begin + using JuliaWorkspaces: JuliaWorkspace, add_file!, TextFile, SourceText, get_completions + using JuliaWorkspaces.URIs2: URI + + source = """ + abstract type Parent end + struct Child1 <: Parent + field1::Int + end + struct Child2 <: Parent + field2::String + end + function f(x::Parent) + if x isa Child1 + y = x::Child1 + y. + else + y = x::Child2 + y. + end + end + """ + uri = URI("file:///compassertbranch/test.jl") + jw = JuliaWorkspace() + add_file!(jw, TextFile(uri, SourceText(source, "julia"))) + + dots = collect(findall("y.\n", source)) + if_fields = sort([i.label for i in get_completions(jw, uri, dots[1][end]).items]) + else_fields = sort([i.label for i in get_completions(jw, uri, dots[2][end]).items]) + @test if_fields == ["field1"] + @test else_fields == ["field2"] +end diff --git a/test/test_typeinf.jl b/test/test_typeinf.jl index 2385e6c..b4a6998 100644 --- a/test/test_typeinf.jl +++ b/test/test_typeinf.jl @@ -323,3 +323,38 @@ end @test z !== nothing @test z.type === nothing end + +@testitem "type assertion assignment inference" setup=[shared_static_lint] begin + using JuliaWorkspaces.StaticLint: scopeof + + cst, meta_dict = parse_and_pass(""" + abstract type Parent end + struct Child1 <: Parent + field1::Int + end + + function rebind(x::Parent) + x = x::Child1 + x + end + + function freshvar(x::Parent) + y = x::Child1 + y + end + + function untyped(x) + y = x::Child1 + y + end + """); + + Child1 = scopeof(cst, meta_dict).names["Child1"] + + # `x = x::Child1` narrows the reassignment binding to Child1. + @test scopeof(scopeof(cst, meta_dict).names["rebind"].val, meta_dict).names["x"].type === Child1 + # `y = x::Child1` gives the fresh binding the asserted type. + @test scopeof(scopeof(cst, meta_dict).names["freshvar"].val, meta_dict).names["y"].type === Child1 + # Works even when the source variable is untyped. + @test scopeof(scopeof(cst, meta_dict).names["untyped"].val, meta_dict).names["y"].type === Child1 +end