Skip to content

Commit 8ddee69

Browse files
committed
Merge feat/layer-and-widened-hints: Layer RIn and widened hint seams
2 parents 41f8592 + e5ceffb commit 8ddee69

4 files changed

Lines changed: 183 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,15 @@ pick back here to render through this plugin's own templates:
327327
```
328328
│ ◈ Forgot to provide: Greeter | Database
329329
│ ⚡ Jev: .pipe(Effect.provide(AppLive))
330-
│ ↳ layer AppLive 0.99 · where here 0.60
330+
│ ↳ this expression is the program boundary · 0.99
331+
```
332+
333+
A `Layer` missing its own `RIn` gets `Layer.provide(X) inside this layer`.
334+
A channel that is only `unknown` has no service to name, so the "annotate
335+
the effect to find where R widened" line becomes the place itself:
336+
337+
```
338+
│ ⚡ Jev: annotate steps (src/jobs.ts:145), where R widened
331339
```
332340

333341
It also replaces the TS2769 overload heuristic when Jev is confident about
@@ -369,14 +377,18 @@ require("effect-error-pretty.parse").parse(message, { effect = true })
369377
-- Seams for an external judge (this is what jury.nvim registers into).
370378
-- The resolver runs inside vim.diagnostic's formatter: synchronous, fast.
371379
pretty.set_hint_resolver(function(parsed, family, names, diagnostic)
372-
-- family is "services" or "errors"; return nil for the generic hint, or
373-
-- { label = "Jev", line = "...", detail = "..." } to replace it, or
374-
-- { lean = "..." } to keep it and add a line underneath.
380+
-- family is "services", "errors" or "widened"; return nil for the generic
381+
-- hint, or { label = "Jev", line = "...", detail = "..." } to replace it,
382+
-- or { lean = "..." } to keep it and add a line underneath.
383+
-- "services" with parsed.tag == "layer" is a Layer's missing RIn.
384+
-- "widened": names[1] is the channel label (R, RIn or E) that is only
385+
-- unknown/any; the job is to say which definition lost its type.
375386
end)
376387
pretty.set_overload_picker(function(msg, candidates) return index_or_nil end)
377-
pretty.hint_family(parsed) -- "services"|"errors"|nil, names
388+
pretty.hint_family(parsed) -- "services"|"errors"|"widened"|nil, names
378389
pretty.templates.provide(layer, names, where)
379390
pretty.templates.unhandled(fix, names, target)
391+
pretty.templates.widened(label, name, file, line)
380392
```
381393

382394
## Extending

‎lua/effect-error-pretty/hints.lua‎

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,73 @@ local M = {}
1010

1111
-- ── families ──────────────────────────────────────────────────────────────
1212

13+
local function is_uninferred(name)
14+
return name == "unknown" or name == "any"
15+
end
16+
17+
--- True when a channel holds nothing but `unknown`/`any`: inference gave up
18+
--- upstream and there is no service or error to name.
19+
---@param names string[]|nil
20+
---@return boolean
21+
function M.all_uninferred(names)
22+
if type(names) ~= "table" or #names == 0 then
23+
return false
24+
end
25+
for _, n in ipairs(names) do
26+
if not is_uninferred(n) then
27+
return false
28+
end
29+
end
30+
return true
31+
end
32+
1333
--- Which hint family a parsed result belongs to, and the names involved.
1434
--- Only families with a concrete fix shape are returned. Scope-only results
15-
--- are excluded on purpose: `Effect.scoped` needs no judgment.
35+
--- are excluded on purpose: `Effect.scoped` needs no judgment. A Layer's
36+
--- missing RIn is "services" too; `parsed.tag == "layer"` tells the
37+
--- resolver that the fix is Layer.provide inside the layer, not a pipe.
38+
--- A channel that is only `unknown`/`any` is "widened": `names` is then the
39+
--- channel label (R, RIn or E) and the resolver's job is to say where
40+
--- upstream the type was lost.
1641
---@param parsed table result of parse.parse
17-
---@return "services"|"errors"|nil family, string[]|nil names
42+
---@return "services"|"errors"|"widened"|nil family, string[]|nil names
1843
function M.family(parsed)
1944
if type(parsed) ~= "table" then
2045
return nil
2146
end
2247
if parsed.kind == "effect_mismatch" then
23-
if parsed.missing_services and #parsed.missing_services > 0 and not parsed.scope_required then
24-
return "services", parsed.missing_services
48+
-- Only the single-channel boxes carry a hint line; the tri-channel
49+
-- view and the identical-signature box do not, so asking is waste.
50+
if parsed.diff_count ~= 1 then
51+
return nil
52+
end
53+
local labels = parsed.labels or { "A", "E", "R" }
54+
if parsed.missing_services and #parsed.missing_services > 0 then
55+
if M.all_uninferred(parsed.missing_services) then
56+
return "widened", { labels[3] }
57+
end
58+
if not parsed.scope_required then
59+
return "services", parsed.missing_services
60+
end
61+
return nil
2562
end
2663
if parsed.unhandled_errors and #parsed.unhandled_errors > 0 then
64+
if M.all_uninferred(parsed.unhandled_errors) then
65+
return "widened", { labels[2] }
66+
end
2767
return "errors", parsed.unhandled_errors
2868
end
29-
elseif parsed.kind == "missing_context" and parsed.tag == "effect" and not parsed.scope_required then
30-
return "services", parsed.services
69+
elseif parsed.kind == "missing_context" then
70+
if M.all_uninferred(parsed.services) then
71+
return "widened", { parsed.tag == "layer" and "RIn" or "R" }
72+
end
73+
if not parsed.scope_required then
74+
return "services", parsed.services
75+
end
3176
elseif parsed.kind == "missing_errors" then
77+
if M.all_uninferred(parsed.errors) then
78+
return "widened", { "E" }
79+
end
3280
return "errors", parsed.errors
3381
end
3482
return nil
@@ -78,6 +126,17 @@ function M.templates.unhandled(fix, names, target)
78126
return nil
79127
end
80128

129+
--- Concrete hint for a widened channel: name the definition to annotate.
130+
---@param label string R, RIn or E
131+
---@param name string the identifier that most likely lost its type
132+
---@param file string|nil
133+
---@param line integer|nil
134+
---@return string
135+
function M.templates.widened(label, name, file, line)
136+
local where = file and (line and (" (%s:%d)"):format(file, line) or (" (%s)"):format(file)) or ""
137+
return ("annotate %s%s, where %s widened"):format(name, where, label)
138+
end
139+
81140
-- ── the seam ──────────────────────────────────────────────────────────────
82141

83142
---@class EffectErrorPretty.Hint

‎lua/effect-error-pretty/render.lua‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,9 @@ end
274274
M.is_uninferred = is_uninferred
275275

276276
-- Why a channel came out `unknown`, and what to do about it. Deliberately
277-
-- names no service: there isn't one to name.
278-
local function uninferred_lines(name, label, found)
277+
-- names no service: there isn't one to name. A resolver may name the
278+
-- definition that lost its type; that replaces the "go look" line.
279+
local function uninferred_lines(name, label, found, hint)
279280
local lines = {
280281
"╭─ ⚠ " .. name .. " — " .. label .. " Not Inferred",
281282
"│",
@@ -286,7 +287,12 @@ local function uninferred_lines(name, label, found)
286287
"│ ⚡ Hint: ",
287288
"something upstream is untyped — an `any`, a missing annotation, or a generic that never got inferred"
288289
)
289-
push_prose(lines, "│ ⚡ Hint: ", "annotate the effect to find where " .. label .. " widened")
290+
local generic = "annotate the effect to find where " .. label .. " widened"
291+
if hint then
292+
vim.list_extend(lines, hints.lines(hint, generic))
293+
else
294+
push_prose(lines, "│ ⚡ Hint: ", generic)
295+
end
290296
return lines
291297
end
292298

@@ -299,7 +305,7 @@ local function missing_services_lines(name, services, is_layer, only_scope, scop
299305

300306
-- Nothing actionable in the channel: the whole box would be a lie.
301307
if #real == 0 and #unresolved > 0 then
302-
return uninferred_lines(name, label, unresolved[1])
308+
return uninferred_lines(name, label, unresolved[1], hint)
303309
end
304310

305311
local title
@@ -321,7 +327,13 @@ local function missing_services_lines(name, services, is_layer, only_scope, scop
321327
if only_scope then
322328
push_prose(lines, "│ ⚡ Hint: ", "wrap in Effect.scoped(...) — Scope is required")
323329
elseif is_layer then
324-
push_prose(lines, "│ ⚡ Hint: ", "compose with Layer.provide(...) or Layer.merge(...)")
330+
-- Same seam as the effect branch: a resolver may name the layer to
331+
-- compose in, or add a lean under the generic line.
332+
if hint then
333+
vim.list_extend(lines, hints.lines(hint, "compose with Layer.provide(...) or Layer.merge(...)"))
334+
else
335+
push_prose(lines, "│ ⚡ Hint: ", "compose with Layer.provide(...) or Layer.merge(...)")
336+
end
325337
else
326338
-- A registered resolver may replace the generic provide hint with a
327339
-- concrete one, or add a lean under it. Everything else is unchanged.
@@ -342,7 +354,7 @@ end
342354
local function unhandled_errors_lines(name, errors, hint)
343355
local real, unresolved = partition_uninferred(errors)
344356
if #real == 0 and #unresolved > 0 then
345-
return uninferred_lines(name, "E", unresolved[1])
357+
return uninferred_lines(name, "E", unresolved[1], hint)
346358
end
347359

348360
local lines = { "╭─ ⚠ " .. name .. " — Unhandled Errors", "│" }

‎tests/hints_spec.lua‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ local UNHANDLED = "Type 'Effect<string, NotFound, Database>' is not assignable t
1111
local LS_ERRORS = "Missing 'NotFound | Timeout' in the expected Effect errors."
1212
local LS_CTX = "This Effect requires a service that is missing from the expected Effect context: `Greeter`."
1313
local SCOPE = "Type 'Effect<string, never, Scope>' is not assignable to type 'Effect<string, never, never>'"
14+
local LAYER_RIN = "Type 'Layer<{ readonly get: (id: string) => Effect<User, DbError, never>; }, never, Database>' is not assignable to type 'Layer<{ readonly get: (id: string) => Effect<User, DbError, never>; }, never, never>'."
15+
local LS_LAYER = "Missing 'Database' in the expected Layer context."
16+
local WIDE_R = "Argument of type 'Effect<string, never, unknown>' is not assignable to parameter of type 'Effect<string, never, never>'" .. SUFFIX
17+
local WIDE_E = "Type 'Effect<string, unknown, never>' is not assignable to type 'Effect<string, never, never>'" .. SUFFIX
18+
local LS_WIDE_E = "Missing 'unknown' in the expected Effect errors."
19+
local LS_WIDE_RIN = "Missing 'any' in the expected Layer context."
20+
local MULTI = "Type 'Effect<string, NotFound, Database>' is not assignable to type 'Effect<number, never, never>'" .. SUFFIX
1421

1522
local NO_OVERLOAD = table.concat({
1623
"No overload matches this call.",
@@ -44,6 +51,40 @@ describe("hints.family", function()
4451
assert.are.same({ "NotFound", "Timeout" }, names)
4552
end)
4653

54+
it("classifies a Layer's missing RIn as services and marks the tag", function()
55+
local parsed = parse.parse(LAYER_RIN)
56+
local f, names = hints.family(parsed)
57+
assert.are.equal("services", f)
58+
assert.are.same({ "Database" }, names)
59+
assert.are.equal("layer", parsed.tag)
60+
parsed = parse.parse(LS_LAYER)
61+
f, names = hints.family(parsed)
62+
assert.are.equal("services", f)
63+
assert.are.same({ "Database" }, names)
64+
assert.are.equal("layer", parsed.tag)
65+
end)
66+
67+
it("classifies a channel that is only unknown/any as widened, named by its label", function()
68+
local f, names = hints.family(parse.parse(WIDE_R))
69+
assert.are.equal("widened", f)
70+
assert.are.same({ "R" }, names)
71+
f, names = hints.family(parse.parse(WIDE_E))
72+
assert.are.equal("widened", f)
73+
assert.are.same({ "E" }, names)
74+
f, names = hints.family(parse.parse(LS_WIDE_E))
75+
assert.are.equal("widened", f)
76+
assert.are.same({ "E" }, names)
77+
f, names = hints.family(parse.parse(LS_WIDE_RIN))
78+
assert.are.equal("widened", f)
79+
assert.are.same({ "RIn" }, names)
80+
end)
81+
82+
it("asks nothing when more than one channel differs", function()
83+
local parsed = parse.parse(MULTI)
84+
assert.is_true(parsed.diff_count > 1)
85+
assert.is_nil(hints.family(parsed))
86+
end)
87+
4788
it("leaves scope-only results alone", function()
4889
assert.is_nil(hints.family(parse.parse(SCOPE)))
4990
assert.is_nil(hints.family(nil))
@@ -95,6 +136,48 @@ describe("hints resolver seam", function()
95136
assert.is_falsy(box:find("SomeLayer", 1, true))
96137
end)
97138

139+
it("honors the resolver in the Layer RIn box, from a type diff and from the language service", function()
140+
hints.set_resolver(function(parsed, family, names)
141+
assert.are.equal("services", family)
142+
assert.are.equal("layer", parsed.tag)
143+
return { label = "Jev", line = hints.templates.provide("DatabaseLive", names, "layer"), detail = "layer DatabaseLive 0.93" }
144+
end)
145+
for _, case in ipairs({ { LAYER_RIN, "typescript" }, { LS_LAYER, "effect" } }) do
146+
local box = render.artistic(diag(case[1], case[2]), { effect = true })
147+
assert.is_truthy(box:find("Missing RIn", 1, true))
148+
assert.is_truthy(box:find("⚡ Jev: Layer.provide(DatabaseLive) inside this layer", 1, true))
149+
assert.is_truthy(box:find("↳ layer DatabaseLive 0.93", 1, true))
150+
assert.is_falsy(box:find("Layer.merge", 1, true))
151+
end
152+
hints.set_resolver(function()
153+
return { lean = "jev unsure: layer none 0.40" }
154+
end)
155+
local box = render.artistic(diag(LAYER_RIN), { effect = true })
156+
assert.is_truthy(box:find("⚡ Hint: compose with Layer.provide(...) or Layer.merge(...)", 1, true))
157+
assert.is_truthy(box:find("↳ jev unsure: layer none 0.40", 1, true))
158+
end)
159+
160+
it("lets the resolver name where a widened channel lost its type", function()
161+
hints.set_resolver(function(_, family, names)
162+
assert.are.equal("widened", family)
163+
return { label = "Jev", line = hints.templates.widened(names[1], "fromRegistry", "app.ts", 12), detail = "widened fromRegistry 0.81" }
164+
end)
165+
local box = render.artistic(diag(WIDE_R), { effect = true })
166+
assert.is_truthy(box:find("R Not Inferred", 1, true))
167+
assert.is_truthy(box:find("⚡ Jev: annotate fromRegistry (app.ts:12), where R widened", 1, true))
168+
assert.is_truthy(box:find("↳ widened fromRegistry 0.81", 1, true))
169+
assert.is_falsy(box:find("annotate the effect to find where", 1, true))
170+
box = render.artistic(diag(LS_WIDE_E, "effect"), { effect = true })
171+
assert.is_truthy(box:find("E Not Inferred", 1, true))
172+
assert.is_truthy(box:find("where E widened", 1, true))
173+
hints.set_resolver(function()
174+
return { lean = "jev unsure: widened none 0.30" }
175+
end)
176+
box = render.artistic(diag(WIDE_E), { effect = true })
177+
assert.is_truthy(box:find("⚡ Hint: annotate the effect to find where E widened", 1, true))
178+
assert.is_truthy(box:find("↳ jev unsure: widened none 0.30", 1, true))
179+
end)
180+
98181
it("keeps the generic line and adds a lean when the resolver is unsure", function()
99182
hints.set_resolver(function()
100183
return { lean = "jev unsure: fix declare 0.47" }

0 commit comments

Comments
 (0)