Skip to content

Commit 730d7c7

Browse files
author
oujinsai
committed
refactor(commands): establish single-layer command pipeline
Move command execution into a single handlers layer and keep api.lua as a thin action map. This removes the split handlers/usecases execution path so command parsing, dispatch, and execution boundaries are explicit and reviewable.
1 parent 138299d commit 730d7c7

22 files changed

Lines changed: 3171 additions & 1618 deletions

lua/opencode/api.lua

Lines changed: 101 additions & 1607 deletions
Large diffs are not rendered by default.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
local M = {}
2+
3+
---@type table<string, fun(): string[]>
4+
local providers = {
5+
user_commands = function()
6+
local config_file = require('opencode.config_file')
7+
local user_commands = config_file.get_user_commands():wait()
8+
if not user_commands then
9+
return {}
10+
end
11+
12+
local names = vim.tbl_keys(user_commands)
13+
table.sort(names)
14+
return names
15+
end,
16+
}
17+
18+
---@param provider_id string
19+
---@return (fun(): string[])|nil
20+
function M.get(provider_id)
21+
return providers[provider_id]
22+
end
23+
24+
return M

lua/opencode/commands/dispatch.lua

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
local handlers = require('opencode.commands.handlers')
2+
local config = require('opencode.config')
3+
local registry = require('opencode.registry')
4+
local state = require('opencode.state')
5+
6+
local M = {}
7+
8+
local lifecycle_hook_keys = {
9+
before = 'on_command_before',
10+
after = 'on_command_after',
11+
error = 'on_command_error',
12+
finally = 'on_command_finally',
13+
}
14+
15+
local lifecycle_event_names = {
16+
before = 'custom.command.before',
17+
after = 'custom.command.after',
18+
error = 'custom.command.error',
19+
finally = 'custom.command.finally',
20+
}
21+
22+
---@param event_name string
23+
---@param payload table
24+
local function emit_lifecycle_event(event_name, payload)
25+
local manager = state.event_manager
26+
if manager and type(manager.emit) == 'function' then
27+
pcall(manager.emit, manager, event_name, payload)
28+
end
29+
end
30+
31+
---@param stage OpencodeCommandLifecycleStage
32+
---@param hook_id string
33+
---@param hook_fn OpencodeCommandDispatchHook
34+
---@param ctx OpencodeCommandDispatchContext
35+
---@return OpencodeCommandDispatchContext
36+
local function run_hook(stage, hook_id, hook_fn, ctx)
37+
local ok, next_ctx_or_err = pcall(hook_fn, ctx)
38+
if not ok then
39+
emit_lifecycle_event('custom.command.hook_error', {
40+
stage = stage,
41+
hook_id = hook_id,
42+
error = tostring(next_ctx_or_err),
43+
context = ctx,
44+
})
45+
return ctx
46+
end
47+
48+
if type(next_ctx_or_err) == 'table' then
49+
return next_ctx_or_err
50+
end
51+
52+
return ctx
53+
end
54+
55+
---@param stage OpencodeCommandLifecycleStage
56+
---@return { id: string, fn: OpencodeCommandDispatchHook }[]
57+
local function collect_registry_stage_hooks(stage)
58+
local hooks = registry.get_hooks()
59+
local hook_names = vim.tbl_keys(hooks)
60+
table.sort(hook_names)
61+
62+
local stage_hooks = {}
63+
for _, hook_name in ipairs(hook_names) do
64+
local hook_spec = hooks[hook_name]
65+
local hook_fn
66+
67+
if type(hook_spec) == 'table' then
68+
hook_fn = hook_spec[stage]
69+
if type(hook_fn) ~= 'function' then
70+
hook_fn = hook_spec[lifecycle_hook_keys[stage]]
71+
end
72+
elseif type(hook_spec) == 'function' and (hook_name == stage or hook_name == lifecycle_hook_keys[stage]) then
73+
hook_fn = hook_spec
74+
end
75+
76+
if type(hook_fn) == 'function' then
77+
stage_hooks[#stage_hooks + 1] = {
78+
id = 'registry:' .. hook_name,
79+
fn = hook_fn,
80+
}
81+
end
82+
end
83+
84+
return stage_hooks
85+
end
86+
87+
---@param stage OpencodeCommandLifecycleStage
88+
---@param ctx OpencodeCommandDispatchContext
89+
---@return OpencodeCommandDispatchContext
90+
local function run_hook_pipeline(stage, ctx)
91+
local next_ctx = ctx
92+
93+
for _, hook in ipairs(collect_registry_stage_hooks(stage)) do
94+
next_ctx = run_hook(stage, hook.id, hook.fn, next_ctx)
95+
end
96+
97+
local hooks = config.hooks
98+
if hooks then
99+
local config_hook_name = lifecycle_hook_keys[stage]
100+
local config_hook = hooks[config_hook_name]
101+
if type(config_hook) == 'function' then
102+
next_ctx = run_hook(stage, 'config:' .. config_hook_name, config_hook, next_ctx)
103+
end
104+
end
105+
106+
emit_lifecycle_event(lifecycle_event_names[stage], next_ctx)
107+
108+
return next_ctx
109+
end
110+
111+
---@param parsed OpencodeCommandParseResult
112+
---@param api OpencodeCommandApi
113+
---@return OpencodeCommandDispatchResult
114+
function M.command(parsed, api)
115+
---@type OpencodeCommandDispatchContext
116+
local ctx = {
117+
parsed = parsed,
118+
intent = parsed.intent,
119+
args = parsed.intent and parsed.intent.args or nil,
120+
range = parsed.intent and parsed.intent.range or nil,
121+
}
122+
123+
if not parsed.ok then
124+
ctx.error = parsed.error
125+
ctx = run_hook_pipeline('error', ctx)
126+
ctx = run_hook_pipeline('finally', ctx)
127+
128+
return {
129+
ok = false,
130+
error = ctx.error,
131+
}
132+
end
133+
134+
ctx = run_hook_pipeline('before', ctx)
135+
136+
local intent = ctx.intent or parsed.intent
137+
local args = ctx.args
138+
if args == nil and intent then
139+
args = intent.args
140+
end
141+
142+
local range = ctx.range
143+
if range == nil and intent then
144+
range = intent.range
145+
end
146+
147+
if intent then
148+
intent.args = args or {}
149+
intent.range = range
150+
ctx.intent = intent
151+
end
152+
153+
local ok, result, handler_error = handlers.execute(intent.handler_id, api, intent.args, intent.range)
154+
155+
if not ok then
156+
ctx.error = {
157+
code = 'unknown_handler',
158+
message = 'Unknown command handler: ' .. intent.handler_id,
159+
handler_id = intent.handler_id,
160+
}
161+
ctx = run_hook_pipeline('error', ctx)
162+
ctx = run_hook_pipeline('finally', ctx)
163+
164+
return {
165+
ok = false,
166+
intent = ctx.intent,
167+
error = ctx.error,
168+
}
169+
end
170+
171+
if handler_error then
172+
ctx.error = handler_error
173+
ctx = run_hook_pipeline('error', ctx)
174+
ctx = run_hook_pipeline('finally', ctx)
175+
176+
return {
177+
ok = false,
178+
intent = ctx.intent,
179+
error = ctx.error,
180+
}
181+
end
182+
183+
ctx.result = result
184+
ctx = run_hook_pipeline('after', ctx)
185+
ctx = run_hook_pipeline('finally', ctx)
186+
187+
return {
188+
ok = true,
189+
result = ctx.result,
190+
intent = ctx.intent,
191+
}
192+
end
193+
194+
return M

lua/opencode/commands/handlers.lua

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
local M = {}
2+
local registry = require('opencode.registry')
3+
4+
local handler_group_modules = {
5+
'opencode.commands.handlers.window',
6+
'opencode.commands.handlers.agent',
7+
'opencode.commands.handlers.workflow',
8+
'opencode.commands.handlers.session',
9+
'opencode.commands.handlers.diff',
10+
'opencode.commands.handlers.permission',
11+
}
12+
13+
---@return OpencodeCommandHandlerMap
14+
local function get_handlers()
15+
local all_handlers = {}
16+
local handler_sources = {}
17+
18+
for _, module_name in ipairs(handler_group_modules) do
19+
local module_exports = require(module_name)
20+
local module_handlers = module_exports.handlers or module_exports
21+
for handler_id, handler in pairs(module_handlers) do
22+
if handler_sources[handler_id] then
23+
error(
24+
string.format(
25+
"Duplicate handler_id '%s' in modules '%s' and '%s'",
26+
handler_id,
27+
handler_sources[handler_id],
28+
module_name
29+
)
30+
)
31+
end
32+
33+
handler_sources[handler_id] = module_name
34+
all_handlers[handler_id] = handler
35+
end
36+
end
37+
38+
for handler_id, handler in pairs(registry.get_handlers()) do
39+
if handler_sources[handler_id] then
40+
error(
41+
string.format(
42+
"Duplicate handler_id '%s' in modules '%s' and extension registry",
43+
handler_id,
44+
handler_sources[handler_id]
45+
)
46+
)
47+
end
48+
49+
handler_sources[handler_id] = 'extension registry'
50+
all_handlers[handler_id] = handler
51+
end
52+
53+
return all_handlers
54+
end
55+
56+
-- Validate handler graph during module load for fast-fail behavior.
57+
get_handlers()
58+
59+
---@param handler_id string
60+
---@return OpencodeCommandHandler|nil
61+
function M.get(handler_id)
62+
return get_handlers()[handler_id]
63+
end
64+
65+
---@return string[]
66+
function M.ids()
67+
local ids = vim.tbl_keys(get_handlers())
68+
table.sort(ids)
69+
return ids
70+
end
71+
72+
---@param handler_id string
73+
---@param api OpencodeCommandApi
74+
---@param args string[]
75+
---@param range? OpencodeSelectionRange
76+
---@return boolean, any, OpencodeCommandDispatchError|nil
77+
function M.execute(handler_id, api, args, range)
78+
local handler = M.get(handler_id)
79+
if not handler then
80+
return false, nil, nil
81+
end
82+
83+
local ok, result = pcall(handler, api, args or {}, range)
84+
if ok then
85+
return true, result, nil
86+
end
87+
88+
if type(result) == 'table' and type(result.code) == 'string' and type(result.message) == 'string' then
89+
if not result.handler_id then
90+
result.handler_id = handler_id
91+
end
92+
return true, nil, result
93+
end
94+
95+
return true,
96+
nil,
97+
{
98+
code = 'handler_exception',
99+
message = 'Command handler failed: ' .. handler_id,
100+
handler_id = handler_id,
101+
}
102+
end
103+
104+
return M

0 commit comments

Comments
 (0)