From 6252da37a50c40375485b21a851c0cc985ba551c Mon Sep 17 00:00:00 2001 From: obemu Date: Fri, 7 Nov 2025 15:26:30 +0100 Subject: [PATCH 1/2] Improve type definitions for module executable This commit is basically a small code-cleanup for the `executable.lua` module, but it mainly adds Lua Doc comments to some public functions. --- lua/flutter-tools/executable.lua | 139 ++++++++++++++++++++----------- 1 file changed, 91 insertions(+), 48 deletions(-) diff --git a/lua/flutter-tools/executable.lua b/lua/flutter-tools/executable.lua index 8700c075..64dec39c 100644 --- a/lua/flutter-tools/executable.lua +++ b/lua/flutter-tools/executable.lua @@ -5,6 +5,29 @@ local ui = lazy.require("flutter-tools.ui") ---@module "flutter-tools.ui" local config = lazy.require("flutter-tools.config") ---@module "flutter-tools.config" local Job = require("plenary.job") +---@class flutter.Paths +--- +--- The path to the Flutter CLI. +---@field flutter_bin string +--- +--- The path to the root directory of the Flutter SDK. +---@field flutter_sdk string +--- +--- The path to the Dart CLI. +---@field dart_bin string +--- +--- The path to the root directory of the Dart SDK used by the Flutter SDK. +---@field dart_sdk string +--- +--- True if fvm provides the Flutter SDK, otherwise nil or false. +---@field fvm boolean? + +---@private +---@class flutter.internal.Paths +---@field flutter_bin string +---@field flutter_sdk string +---@field dart_bin string + local fn = vim.fn local fs = vim.fs local luv = vim.loop @@ -13,12 +36,15 @@ local M = {} local dart_sdk = path.join("cache", "dart-sdk") -local function _flutter_sdk_root(bin_path) +---@type flutter.Paths? +local cached_paths = nil + +local function flutter_sdk_root(bin_path) -- convert path/to/flutter/bin/flutter into path/to/flutter return fn.fnamemodify(bin_path, ":h:h") end -local function _dart_sdk_root(paths) +local function dart_sdk_root(paths) if paths.flutter_sdk then -- On Linux installations with snap the dart SDK can be further nested inside a bin directory -- so it's /bin/cache/dart-sdk whereas else where it is /cache/dart-sdk @@ -42,33 +68,27 @@ local function _dart_sdk_root(paths) return "" end -local function _flutter_sdk_dart_bin(flutter_sdk) +local function flutter_sdk_dart_bin(flutter_sdk) -- retrieve the Dart binary from the Flutter SDK local binary_name = path.is_windows and "dart.bat" or "dart" return path.join(flutter_sdk, "bin", binary_name) end ----Get paths for flutter and dart based on the binary locations ----@return table? +--- Get paths for flutter and dart based on the binary locations. +---@return flutter.internal.Paths? local function get_default_binaries() local flutter_bin = fn.resolve(fn.exepath("flutter")) if #flutter_bin <= 0 then return nil end return { flutter_bin = flutter_bin, dart_bin = fn.resolve(fn.exepath("dart")), - flutter_sdk = _flutter_sdk_root(flutter_bin), + flutter_sdk = flutter_sdk_root(flutter_bin), } end ----@type table -local _paths = nil - -function M.reset_paths() _paths = nil end - ----Execute user's lookup command and pass it to the job callback +--- Execute user's lookup command and pass it to the job callback. ---@param lookup_cmd string ----@param callback fun(t?: table?) ----@return table? +---@param callback fun(paths:flutter.internal.Paths):nil local function path_from_lookup_cmd(lookup_cmd, callback) local paths = {} local parts = vim.split(lookup_cmd, " ") @@ -76,6 +96,7 @@ local function path_from_lookup_cmd(lookup_cmd, callback) local args = vim.list_slice(parts, 2, #parts) local job = Job:new({ command = cmd, args = args }) + job:after_failure( vim.schedule_wrap( function() @@ -83,11 +104,12 @@ local function path_from_lookup_cmd(lookup_cmd, callback) end ) ) + job:after_success(vim.schedule_wrap(function(j, _) local result = j:result() local flutter_sdk_path = result[1] if flutter_sdk_path then - paths.dart_bin = _flutter_sdk_dart_bin(flutter_sdk_path) + paths.dart_bin = flutter_sdk_dart_bin(flutter_sdk_path) paths.flutter_bin = path.join(flutter_sdk_path, "bin", "flutter") paths.flutter_sdk = flutter_sdk_path callback(paths) @@ -95,78 +117,99 @@ local function path_from_lookup_cmd(lookup_cmd, callback) paths = get_default_binaries() callback(paths) end - return paths end)) + job:start() end -local function _flutter_bin_from_fvm() +local function flutter_bin_from_fvm() local fvm_root = fs.dirname(fs.find(".fvm", { path = luv.cwd(), upward = true, type = "directory" })[1]) + local binary_name = path.is_windows and "flutter.bat" or "flutter" local flutter_bin_symlink = path.join(fvm_root, ".fvm", "flutter_sdk", "bin", binary_name) flutter_bin_symlink = fn.exepath(flutter_bin_symlink) + local flutter_bin = luv.fs_realpath(flutter_bin_symlink) if path.exists(flutter_bin_symlink) and path.exists(flutter_bin) then return flutter_bin end end ----Fetch the paths to the users binaries. ----@param callback fun(paths?: table) ----@return nil +--- Reset the internally cached SDK paths. +function M.reset_paths() cached_paths = nil end + +--- Fetch the paths to the users binaries. +---@param callback fun(paths: flutter.Paths):nil function M.get(callback) - if _paths then return callback(_paths) end + if cached_paths then return callback(cached_paths) end if config.fvm then - local flutter_bin = _flutter_bin_from_fvm() + local flutter_bin = flutter_bin_from_fvm() if flutter_bin then - _paths = { + cached_paths = { flutter_bin = flutter_bin, - flutter_sdk = _flutter_sdk_root(flutter_bin), + flutter_sdk = flutter_sdk_root(flutter_bin), fvm = true, + -- Provide default values to make the linter happy. + dart_sdk = "", + dart_bin = "", } - _paths.dart_sdk = _dart_sdk_root(_paths) - _paths.dart_bin = _flutter_sdk_dart_bin(_paths.flutter_sdk) - return callback(_paths) + cached_paths.dart_sdk = dart_sdk_root(cached_paths) + cached_paths.dart_bin = flutter_sdk_dart_bin(cached_paths.flutter_sdk) + return callback(cached_paths) end end if config.flutter_path then local flutter_path = fn.resolve(config.flutter_path) - _paths = { flutter_bin = flutter_path, flutter_sdk = _flutter_sdk_root(flutter_path) } - _paths.dart_sdk = _dart_sdk_root(_paths) - _paths.dart_bin = _flutter_sdk_dart_bin(_paths.flutter_sdk) - return callback(_paths) + cached_paths = { + flutter_bin = flutter_path, + flutter_sdk = flutter_sdk_root(flutter_path), + -- Provide default values to make the linter happy. + dart_sdk = "", + dart_bin = "", + } + cached_paths.dart_sdk = dart_sdk_root(cached_paths) + cached_paths.dart_bin = flutter_sdk_dart_bin(cached_paths.flutter_sdk) + return callback(cached_paths) end if config.flutter_lookup_cmd then return path_from_lookup_cmd(config.flutter_lookup_cmd, function(paths) - if not paths then return end - _paths = paths - _paths.dart_sdk = _dart_sdk_root(_paths) - callback(_paths) + paths = { + flutter_bin = paths.flutter_bin, + flutter_sdk = paths.flutter_sdk, + dart_bin = paths.dart_bin, + dart_sdk = dart_sdk_root(paths), + } + callback(paths) end) end - local default_paths = get_default_binaries() - - if not _paths and default_paths then - _paths = default_paths - _paths.dart_sdk = _dart_sdk_root(_paths) - if _paths.flutter_sdk then _paths.dart_bin = _flutter_sdk_dart_bin(_paths.flutter_sdk) end + if not cached_paths then + local internal_paths = get_default_binaries() + if internal_paths then + cached_paths = { + flutter_bin = internal_paths.flutter_bin, + flutter_sdk = internal_paths.flutter_sdk, + dart_bin = internal_paths.dart_bin, + dart_sdk = dart_sdk_root(internal_paths), + } + if cached_paths.flutter_sdk then + cached_paths.dart_bin = flutter_sdk_dart_bin(cached_paths.flutter_sdk) + end + end end - return callback(_paths) + return callback(cached_paths) end ----Fetch the path to the users flutter installation. ----@param callback fun(paths: string) ----@return nil +--- Fetch the path to the users flutter installation. +---@param callback fun(flutter_bin: string):nil function M.flutter(callback) M.get(function(paths) callback(paths.flutter_bin) end) end ----Fetch the path to the users dart installation. ----@param callback fun(paths: table) ----@return nil +--- Fetch the path to the users dart installation. +---@param callback fun(dart_bin: string):nil function M.dart(callback) M.get(function(paths) callback(paths.dart_bin) end) end From 6cc0cbbb8caec6269a83f2d32af02a4b35587e0c Mon Sep 17 00:00:00 2001 From: obemu Date: Fri, 7 Nov 2025 16:18:00 +0100 Subject: [PATCH 2/2] Fix flutter version/welcome banner lua error Executing any `flutter` command (including `flutter debug_adapter` and `flutter debug-adapter`) may cause the `New Flutter Version` or `Welcome to Flutter` banner to be printed to STDOUT. This is problematic, because it interferes with `nvim-dap`s ability to parse RPC calls (obviously, because the banners are not formatted to look like a valid RPC call). `nvim-dap` is unable to properly detect the `Content-Length` header of the first RPC call (because of the banner/s) -> `error(...)` gets called -> user receives an error when trying to run their project. This commit intends to fix that, by detecting and clearing any banners, before the debug adapter is started. --- lua/flutter-tools/banner.lua | 147 +++++++++++++++++++++++++++++++++ lua/flutter-tools/commands.lua | 44 ++++++---- 2 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 lua/flutter-tools/banner.lua diff --git a/lua/flutter-tools/banner.lua b/lua/flutter-tools/banner.lua new file mode 100644 index 00000000..0f5236de --- /dev/null +++ b/lua/flutter-tools/banner.lua @@ -0,0 +1,147 @@ +local lazy = require("flutter-tools.lazy") +local executable = lazy.require("flutter-tools.executable") ---@module "flutter-tools.executable" +local Job = require("plenary.job") ---@module "plenary.job" + +---@class flutter.DetectedBanners +--- +--- True, if the banner that matches `PATTERNS.FLUTTER_NEW_VERSION` has been +--- detected. +--- +--- This banner will be detected if the file +--- `$FLUTTER_SDK/bin/cache/flutter_version_check.stamp` does not exist, or +--- reached a certain age. (Assuming `$FLUTTER_SDK` is the path to the directory +--- that contains your Flutter SDK). +--- +--- See the [version.dart from the Flutter SDK](https://github.com/flutter/flutter/blob/3.35.7/packages/flutter_tools/lib/src/version.dart#L1303). +---@field has_flutter_new_version boolean +--- +--- True, if the banner that matches `PATTERNS.FLUTTER_WELCOME` has been +--- detected. +--- +--- This banner will be detected if the file `$HOME/.config/flutter/tool_state` +--- does not exist, or you changed your Flutter SDK to one with a different +--- welcome banner. +--- +--- See the [first_run.dart from the Flutter SDK](https://github.com/flutter/flutter/blob/3.35.7/packages/flutter_tools/lib/src/reporting/first_run.dart#L13). +---@field has_flutter_welcome boolean + +---@private +---@alias flutter.internal.OnBannersClearedListener fun(detect_banners: flutter.DetectedBanners):nil + +---@type flutter.DetectedBanners? +local cached_banners = nil + +---@type flutter.internal.OnBannersClearedListener[] +local on_cleared_listeners = {} + +local has_started_cleansing = false + +local M = { + PATTERNS = { + FLUTTER_NEW_VERSION = "A new version of Flutter is available!", + FLUTTER_WELCOME = "Welcome to Flutter!", + }, +} + +---@param lines string[] +---@return flutter.DetectedBanners +local function detect_banners(lines) + ---@type flutter.DetectedBanners + local banners = { + has_flutter_new_version = false, + has_flutter_welcome = false, + } + + for _, line in ipairs(lines) do + if nil ~= line:match(M.PATTERNS.FLUTTER_NEW_VERSION) then + banners.has_flutter_new_version = true + end + + if nil ~= line:match(M.PATTERNS.FLUTTER_WELCOME) then banners.has_flutter_welcome = true end + end + + return banners +end + +--- Calls every listener from `on_cleared_listeners`, once `do_clear_banners` is +--- done. Internally caches all listeners and then resets `on_cleared_listeners` +--- to an empty table. +--- +---@param detected_banners flutter.DetectedBanners +local function on_cleared_banners(detected_banners) + local listeners = vim.deepcopy(on_cleared_listeners) + on_cleared_listeners = {} + vim.schedule(function() + for _, cb in ipairs(listeners) do + cb(detected_banners) + end + end) +end + +local function do_clear_banners(is_flutter_project) + assert(nil == cached_banners) + assert(not has_started_cleansing) + + has_started_cleansing = true + + executable.get(function(paths) + if is_flutter_project then + Job:new({ + command = paths.flutter_bin, + args = { "--version" }, + enable_recording = true, + on_exit = function(self, code, _) + -- Exit code should always be 0. + assert(0 == code) + + -- 'flutter --version' writes everything to STDOUT including the + -- "Welcome" and "New Flutter Version" banner. + ---@type string[] + local lines = self:result() + + local banners = detect_banners(lines) + cached_banners = banners + + on_cleared_banners(cached_banners) + end, + }):start() + else + -- Only flutter CLI shows startup banners that interfer with the + -- Debug-/Jobrunner. + -- + -- dart CLI does currently not show anything, maybe there will + -- be a banner in the future. + cached_banners = { + has_flutter_welcome = false, + has_flutter_new_version = false, + } + + on_cleared_banners(cached_banners) + end + end) +end + +--- Clear and detect any startup banners from the Flutter or Dart CLI tool. +--- `on_cleared` is called, after all banners have been cleared. +--- +---@param is_flutter_project boolean +---@param on_cleared fun(detected_banners: flutter.DetectedBanners) +function M.clear_startup_banners(is_flutter_project, on_cleared) + if nil ~= cached_banners then + vim.schedule(function() on_cleared(cached_banners) end) + return + end + + table.insert(on_cleared_listeners, on_cleared) + + if not has_started_cleansing then do_clear_banners(is_flutter_project) end +end + +--- Reset the internally cached banners. +function M.reset_cache() + cached_banners = nil + on_cleared_listeners = {} + has_started_cleansing = false +end + +return M diff --git a/lua/flutter-tools/commands.lua b/lua/flutter-tools/commands.lua index a723e0a7..876c2867 100644 --- a/lua/flutter-tools/commands.lua +++ b/lua/flutter-tools/commands.lua @@ -11,6 +11,7 @@ local job_runner = lazy.require("flutter-tools.runners.job_runner") ---@module " local debugger_runner = lazy.require("flutter-tools.runners.debugger_runner") ---@module "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" @@ -34,6 +35,8 @@ local runner = nil local fvm_name = path.is_windows and "fvm.bat" or "fvm" +local has_notified_new_flutter_version = false + local function use_debugger_runner(force_debug) if force_debug or config.debugger.enabled then local dap_ok, _ = pcall(require, "dap") @@ -249,10 +252,14 @@ local function run(opts, project_conf, launch_config) project_conf.pre_run_callback(callback_args) end end + local cwd = config_utils.get_cwd(project_conf) - -- 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. + + -- 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 default_run_args = config.default_run_args local run_args if is_flutter_project then @@ -262,6 +269,7 @@ local function run(opts, project_conf, launch_config) ui.notify("Starting dart project...") if default_run_args then run_args = default_run_args.dart end end + if run_args then if type(run_args) == "string" then vim.list_extend(args, vim.split(run_args, " ")) @@ -269,18 +277,26 @@ local function run(opts, project_conf, launch_config) vim.list_extend(args, run_args) end end - runner = use_debugger_runner(opts.force_debug) and debugger_runner or job_runner - runner:run( - opts, - paths, - args, - cwd, - on_run_data, - on_run_exit, - is_flutter_project, - project_conf, - launch_config - ) + + banner.clear_startup_banners(is_flutter_project, function(detected_banners) + if detected_banners.has_flutter_new_version and not has_notified_new_flutter_version then + has_notified_new_flutter_version = true + ui.notify(banner.PATTERNS.FLUTTER_NEW_VERSION, ui.INFO) + end + + runner = use_debugger_runner(opts.force_debug) and debugger_runner or job_runner + runner:run( + opts, + paths, + args, + cwd, + on_run_data, + on_run_exit, + is_flutter_project, + project_conf, + launch_config + ) + end) end) end