diff --git a/README.md b/README.md index f21bef0..fc3ad95 100644 --- a/README.md +++ b/README.md @@ -500,7 +500,7 @@ return { 'mfussenegger/nvim-dap' } This plugin integrates with [nvim-dap](https://github.com/mfussenegger/nvim-dap) to provide debug capabilities for Flutter and Dart applications. -The plugin will automatically set up `nvim-dap` for Flutter/Dart debugging. +The plugin will automatically set up `nvim-dap` for Flutter/Dart debugging. The `dart` adapter and launch configurations are registered when the plugin starts, so `require("dap").continue()` works without running `:FlutterRun` or `:FlutterDebug` first. An adapter or configurations you define yourself are kept. Also see: diff --git a/lua/flutter-tools/commands.lua b/lua/flutter-tools/commands.lua index 54a2b4f..2fa060f 100644 --- a/lua/flutter-tools/commands.lua +++ b/lua/flutter-tools/commands.lua @@ -12,7 +12,6 @@ local debugger_runner = lazy.require("flutter-tools.runners.debugger_runner") -- local path = lazy.require("flutter-tools.utils.path") ---@module "flutter-tools.utils.path" local dev_log = lazy.require("flutter-tools.log") ---@module "flutter-tools.log" local banner = lazy.require("flutter-tools.banner") ---@module "flutter-tools.banner" -local parser = lazy.require("flutter-tools.utils.yaml_parser") local config_utils = lazy.require("flutter-tools.utils.config_utils") ---@module "flutter-tools.utils.config_utils" local M = {} @@ -150,6 +149,19 @@ local function on_run_exit(result, cli_args, opts, project_config, launch_config end end +---Route dart debug sessions started outside flutter-tools, e.g. via `dap.continue()`, through +---the debugger runner so logs and commands like reload work for them +function M.track_debug_sessions() + debugger_runner.on_untracked_session(function() + if runner and runner ~= debugger_runner and runner:is_running() then return end + runner = debugger_runner + return { + on_run_data = on_run_data, + on_run_exit = function(before_start_logs) on_run_exit(before_start_logs, {}) end, + } + end) +end + --- Take arguments from the commandline and pass --- them to the run command ---@param args string @@ -230,45 +242,6 @@ local function get_device_from_args(args) end end ---@return table? -local function parse_yaml(str) - local ok, yaml = pcall(parser.parse, str) - if not ok then return nil end - return yaml -end - ----@param cwd string -local function has_flutter_dependency_in_pubspec(cwd) - -- As this plugin is tailored for flutter projects, - -- we assume that the project is a flutter project. - local default_has_flutter_dependency = true - local pubspec_path = vim.fn.glob(path.join(cwd, "pubspec.yaml")) - if pubspec_path == "" then return default_has_flutter_dependency end - local pubspec_content = vim.fn.readfile(pubspec_path) - local joined_content = table.concat(pubspec_content, "\n") - local pubspec = parse_yaml(joined_content) - if not pubspec then return default_has_flutter_dependency end - --https://github.com/Dart-Code/Dart-Code/blob/43914cd2709d77668e19a4edf3500f996d5c307b/src/shared/utils/fs.ts#L183 - return ( - pubspec.dependencies - and ( - pubspec.dependencies.flutter - or pubspec.dependencies.flutter_test - or pubspec.dependencies.sky_engine - or pubspec.dependencies.flutter_goldens - ) - ) - or ( - pubspec.devDependencies - and ( - pubspec.devDependencies.flutter - or pubspec.devDependencies.flutter_test - or pubspec.devDependencies.sky_engine - or pubspec.devDependencies.flutter_goldens - ) - ) -end - ---@param opts RunOpts ---@param project_conf flutter.ProjectConfig? ---@param launch_config dap.Configuration? @@ -295,7 +268,7 @@ local function run(opts, project_conf, launch_config) -- To determinate if the project is a flutter project we need to check if -- the pubspec.yaml file has a flutter dependency in it. We need to get -- cwd first to pick correct pubspec.yaml file. - local is_flutter_project = has_flutter_dependency_in_pubspec(cwd) + local is_flutter_project = config_utils.has_flutter_dependency_in_pubspec(cwd) local default_run_args = config.default_run_args local run_args diff --git a/lua/flutter-tools/dap.lua b/lua/flutter-tools/dap.lua index 5284fa6..5dbbafe 100644 --- a/lua/flutter-tools/dap.lua +++ b/lua/flutter-tools/dap.lua @@ -4,10 +4,21 @@ function M.setup(config) local success, dap = pcall(require, "dap") if success then local opts = config.debugger - require("flutter-tools.executable").get(function(_) + require("flutter-tools.commands").track_debug_sessions() + require("flutter-tools.executable").get(function(paths) if opts.exception_breakpoints and type(opts.exception_breakpoints) == "table" then dap.defaults.dart.exception_breakpoints = opts.exception_breakpoints end + local config_utils = require("flutter-tools.utils.config_utils") + local projects = config.project + local project_config = #projects == 1 and projects[1] or nil + local cwd = config_utils.get_cwd(project_config) or vim.fn.getcwd() + require("flutter-tools.runners.debugger_runner").register_defaults( + paths, + config_utils.has_flutter_dependency_in_pubspec(cwd), + project_config, + cwd + ) end) end end diff --git a/lua/flutter-tools/runners/debugger_runner.lua b/lua/flutter-tools/runners/debugger_runner.lua index 8524b2a..e29faae 100644 --- a/lua/flutter-tools/runners/debugger_runner.lua +++ b/lua/flutter-tools/runners/debugger_runner.lua @@ -62,7 +62,8 @@ end ---@param paths table ---@param is_flutter_project boolean ---@param project_config flutter.ProjectConfig? -local function register_default_configurations(paths, is_flutter_project, project_config) +---@param cwd string? +local function register_default_configurations(paths, is_flutter_project, project_config, cwd) local program if is_flutter_project then if project_config and project_config.target then @@ -78,6 +79,7 @@ local function register_default_configurations(paths, is_flutter_project, projec dartSdkPath = paths.dart_sdk, flutterSdkPath = paths.flutter_sdk, program = program, + cwd = cwd, }, { type = "dart", @@ -86,6 +88,7 @@ local function register_default_configurations(paths, is_flutter_project, projec dartSdkPath = paths.dart_sdk, flutterSdkPath = paths.flutter_sdk, program = program, + cwd = cwd, }, } else @@ -102,6 +105,7 @@ local function register_default_configurations(paths, is_flutter_project, projec name = "Launch dart", dartSdkPath = paths.dart_sdk, program = program, + cwd = cwd, }, } end @@ -163,11 +167,34 @@ local function handle_inspect_event(isolate_id) end) end +local listened_events = { + { "after", "event_output" }, + { "before", "event_exited" }, + { "before", "event_terminated" }, + { "before", "event_app.started" }, + { "before", "event_dart.debuggerUris" }, + { "before", "event_dart.serviceExtensionAdded" }, + { "before", "event_flutter.serviceExtensionStateChanged" }, +} + +local function unregister_dap_listeners() + for _, event in ipairs(listened_events) do + dap.listeners[event[1]][event[2]][plugin_identifier] = nil + end +end + +---@param on_run_data fun(is_err: boolean, line: string) +---@param on_run_exit fun(before_start_logs: string[]) local function register_dap_listeners(on_run_data, on_run_exit) + vm_service_extensions.reset() local started = false local before_start_logs = {} dap.listeners.after["event_output"][plugin_identifier] = function(_, body) - on_run_data(started, before_start_logs, body) + if not body or not body.output then return end + for line in body.output:gmatch("[^\r\n]+") do + if not started then table.insert(before_start_logs, line) end + on_run_data(body.category == "stderr", line) + end end local handle_termination = function() @@ -219,6 +246,60 @@ local function register_dap_listeners(on_run_data, on_run_exit) end end +---@class flutter.DebuggerListeners +---@field on_run_data fun(is_err: boolean, line: string) +---@field on_run_exit fun(before_start_logs: string[]) + +---Listeners for the session the next `dap.run` call starts +---@type flutter.DebuggerListeners? +local pending_listeners = nil + +---@type fun(): flutter.DebuggerListeners? +local get_untracked_session_listeners = function() end + +local tracked_session_id = nil + +---@param launch_config dap.Configuration +---@param listeners flutter.DebuggerListeners +local function start_session(launch_config, listeners) + pending_listeners = listeners + dap.run(launch_config) +end + +dap.listeners.on_session[plugin_identifier] = function(_, session) + if not session or session.config.type ~= "dart" or session.id == tracked_session_id then + return + end + tracked_session_id = session.id + local listeners = pending_listeners or get_untracked_session_listeners() + pending_listeners = nil + if listeners then + register_dap_listeners(listeners.on_run_data, listeners.on_run_exit) + else + unregister_dap_listeners() + end +end + +---Set the listeners for dart sessions started outside flutter-tools, e.g. via `dap.continue()` +---@param get_listeners fun(): flutter.DebuggerListeners? +function DebuggerRunner.on_untracked_session(get_listeners) + get_untracked_session_listeners = get_listeners +end + +---Register the adapter and launch configurations up front so `dap.continue()` works before any +---flutter-tools command has run. Adapters and configurations the user defined are left in place. +---@param paths table +---@param is_flutter_project boolean +---@param project_config flutter.ProjectConfig? +---@param cwd string? +function DebuggerRunner.register_defaults(paths, is_flutter_project, project_config, cwd) + if not dap.adapters.dart then register_debug_adapter(paths, is_flutter_project) end + if not dap.configurations.dart then + register_default_configurations(paths, is_flutter_project, project_config, cwd) + end + if config.debugger.register_configurations then config.debugger.register_configurations(paths) end +end + function DebuggerRunner:run( opts, paths, @@ -230,32 +311,25 @@ function DebuggerRunner:run( project_config, last_launch_config ) - vm_service_extensions.reset() ---@type dap.Configuration local selected_launch_config = nil - register_dap_listeners( - function(started, before_start_logs, body) - if body and body.output then - for line in body.output:gmatch("[^\r\n]+") do - if not started then table.insert(before_start_logs, line) end - on_run_data(body.category == "stderr", line) - end - end - end, - function(before_start_logs) + ---@type flutter.DebuggerListeners + local listeners = { + on_run_data = on_run_data, + on_run_exit = function(before_start_logs) on_run_exit(before_start_logs, args, opts, project_config, selected_launch_config) - end - ) + end, + } register_debug_adapter(paths, is_flutter_project) local launch_configurations = {} local launch_configuration_count = 0 if last_launch_config then - dap.run(last_launch_config) + start_session(last_launch_config, listeners) return else - register_default_configurations(paths, is_flutter_project, project_config) + register_default_configurations(paths, is_flutter_project, project_config, cwd) if config.debugger.register_configurations then config.debugger.register_configurations(paths) end @@ -293,27 +367,23 @@ function DebuggerRunner:run( launch_config.evaluateToStringInDebugViews = true end selected_launch_config = launch_config - dap.run(launch_config) + start_session(launch_config, listeners) end ) end end function DebuggerRunner:attach(paths, args, cwd, on_run_data, on_run_exit) - vm_service_extensions.reset() - register_dap_listeners(function(started, before_start_logs, body) - if body and body.output then - for line in body.output:gmatch("[^\r\n]+") do - if not started then table.insert(before_start_logs, line) end - on_run_data(body.category == "stderr", line) - end - end - end, function(before_start_logs) on_run_exit(before_start_logs, args) end) + ---@type flutter.DebuggerListeners + local listeners = { + on_run_data = on_run_data, + on_run_exit = function(before_start_logs) on_run_exit(before_start_logs, args) end, + } register_debug_adapter(paths, true) local launch_configurations = {} local launch_configuration_count = 0 - register_default_configurations(paths, true) + register_default_configurations(paths, true, nil, cwd) if config.debugger.register_configurations then config.debugger.register_configurations(paths) end local all_configurations = require("dap").configurations.dart if not all_configurations then @@ -347,7 +417,7 @@ function DebuggerRunner:attach(paths, args, cwd, on_run_data, on_run_exit) if config.debugger.evaluate_to_string_in_debug_views then launch_config.evaluateToStringInDebugViews = true end - dap.run(launch_config) + start_session(launch_config, listeners) end ) end diff --git a/lua/flutter-tools/utils/config_utils.lua b/lua/flutter-tools/utils/config_utils.lua index 401fcf1..762ca7f 100644 --- a/lua/flutter-tools/utils/config_utils.lua +++ b/lua/flutter-tools/utils/config_utils.lua @@ -4,6 +4,7 @@ local lazy = require("flutter-tools.lazy") local path = lazy.require("flutter-tools.utils.path") ---@module "flutter-tools.utils.path" local ui = lazy.require("flutter-tools.ui") ---@module "flutter-tools.ui" local lsp = lazy.require("flutter-tools.lsp") ---@module "flutter-tools.utils" +local parser = lazy.require("flutter-tools.utils.yaml_parser") --- Gets the appropriate cwd ---@param project_conf flutter.ProjectConfig? @@ -19,4 +20,43 @@ function M.get_cwd(project_conf) return lsp.get_project_root_dir() end +--@return table? +local function parse_yaml(str) + local ok, yaml = pcall(parser.parse, str) + if not ok then return nil end + return yaml +end + +---@param cwd string +function M.has_flutter_dependency_in_pubspec(cwd) + -- As this plugin is tailored for flutter projects, + -- we assume that the project is a flutter project. + local default_has_flutter_dependency = true + local pubspec_path = vim.fn.glob(path.join(cwd, "pubspec.yaml")) + if pubspec_path == "" then return default_has_flutter_dependency end + local pubspec_content = vim.fn.readfile(pubspec_path) + local joined_content = table.concat(pubspec_content, "\n") + local pubspec = parse_yaml(joined_content) + if not pubspec then return default_has_flutter_dependency end + --https://github.com/Dart-Code/Dart-Code/blob/43914cd2709d77668e19a4edf3500f996d5c307b/src/shared/utils/fs.ts#L183 + return ( + pubspec.dependencies + and ( + pubspec.dependencies.flutter + or pubspec.dependencies.flutter_test + or pubspec.dependencies.sky_engine + or pubspec.dependencies.flutter_goldens + ) + ) + or ( + pubspec.devDependencies + and ( + pubspec.devDependencies.flutter + or pubspec.devDependencies.flutter_test + or pubspec.devDependencies.sky_engine + or pubspec.devDependencies.flutter_goldens + ) + ) +end + return M diff --git a/tests/debugger_runner_spec.lua b/tests/debugger_runner_spec.lua new file mode 100644 index 0000000..c500b10 --- /dev/null +++ b/tests/debugger_runner_spec.lua @@ -0,0 +1,77 @@ +local paths = { + flutter_bin = "/sdk/flutter/bin/flutter", + flutter_sdk = "/sdk/flutter", + dart_bin = "/sdk/flutter/bin/dart", + dart_sdk = "/sdk/flutter/bin/cache/dart-sdk", +} + +describe("debugger runner", function() + local dap, runner + + before_each(function() + dap = require("dap") + runner = require("flutter-tools.runners.debugger_runner") + end) + + after_each(function() + dap.adapters.dart = nil + dap.configurations.dart = nil + dap.listeners.after["event_output"]["flutter-tools"] = nil + runner.on_untracked_session(function() end) + package.loaded["flutter-tools.runners.debugger_runner"] = nil + end) + + it("registers the adapter and default configurations", function() + runner.register_defaults(paths, true, nil, "/project") + + assert.are.same({ + type = "executable", + command = paths.flutter_bin, + args = { "debug-adapter" }, + }, dap.adapters.dart) + assert.are.same( + { "Launch flutter", "Connect flutter" }, + vim.tbl_map(function(c) return c.name end, dap.configurations.dart) + ) + assert.are.equal("/project", dap.configurations.dart[1].cwd) + end) + + it("keeps an adapter and configurations the user defined", function() + local adapter = { type = "executable", command = "custom" } + local configurations = { { type = "dart", request = "launch", name = "Mine" } } + dap.adapters.dart = adapter + dap.configurations.dart = configurations + + runner.register_defaults(paths, true, nil, "/project") + + assert.are.equal(adapter, dap.adapters.dart) + assert.are.equal(configurations, dap.configurations.dart) + end) + + it("routes output of sessions started outside flutter-tools", function() + local lines = {} + runner.on_untracked_session(function() + return { + on_run_data = function(is_err, line) table.insert(lines, { is_err, line }) end, + on_run_exit = function() end, + } + end) + + dap.listeners.on_session["flutter-tools"](nil, { id = 1, config = { type = "dart" } }) + dap.listeners.after["event_output"]["flutter-tools"](nil, { + category = "stderr", + output = "first\nsecond", + }) + + assert.are.same({ { true, "first" }, { true, "second" } }, lines) + end) + + it("ignores sessions of other adapters", function() + local called = false + runner.on_untracked_session(function() called = true end) + + dap.listeners.on_session["flutter-tools"](nil, { id = 2, config = { type = "python" } }) + + assert.is_false(called) + end) +end) diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua index fd7fffd..7f17aa7 100644 --- a/tests/minimal_init.lua +++ b/tests/minimal_init.lua @@ -31,6 +31,7 @@ function M.setup() vim.opt.packpath = { M.root(".tests/site") } M.load("nvim-lua/plenary.nvim") M.load("MunifTanjim/nui.nvim") + M.load("mfussenegger/nvim-dap") vim.env.XDG_CONFIG_HOME = M.root(".tests/config") vim.env.XDG_DATA_HOME = M.root(".tests/data") vim.env.XDG_STATE_HOME = M.root(".tests/state")