Repository: f-person/auto-dark-mode.nvim
Pull request #35: feat: use new neovim apis, add `:healthcheck`, fix sudo `query_command`
Author: login: nekowinston
Created: 2024-10-10T06:08:41Z | Merged: 2025-03-09T12:14:41Z

Uses `vim.uv` over `vim.loop`, `vim.system` over `vim.fn.jobstart`, adds a `:checkhealth` hook with a query_command benchmark, and fixes the `SUDO_USER` query_command. Still preserves fallbacks for jobstart & vim.loop so it will work on older neovim versions.

Also uses `vim.validate` to validate the plugin options and refactors quite a bit; moving the query/interval logic into its own lua module.

It's still a bit of a mess right now but I figured I'd open the PR sooner rather than later.

Fixes #33.
Fixes #34.

Linked issue #33: Use `vim.uv` instead of `vim.loop` if available
`vim.loop` will be removed with1.0 (https://neovim.io/doc/user/deprecated.html#vim.loop), we should use the new `vim.uv` if it isn't `nil` to futureproof the plugin.

Diff:
diff --git a/.github/workflows/panvimdoc.yml b/.github/workflows/panvimdoc.yml
index 2351560..f15e151 100644
--- a/.github/workflows/panvimdoc.yml
+++ b/.github/workflows/panvimdoc.yml
@@ -12,12 +12,14 @@ jobs:
     runs-on: ubuntu-latest
     name: README to vimdoc
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
-      - uses: kdheepak/panvimdoc@main
+      - uses: kdheepak/panvimdoc@v4.0.1
         with:
-          vimdoc: auto-dark-mode
-          description: Plugin that automatically changes the editor appearance based on system settings.
+          vimdoc: "auto-dark-mode"
+          version: "NVIM v0.7.0 -> v0.10.4"
+          description: "Plugin that automatically changes the editor appearance based on system settings."
+          demojify: true
 
       - uses: stefanzweifel/git-auto-commit-action@v4
         with:
diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml
new file mode 100644
index 0000000..9bf962e
--- /dev/null
+++ b/.github/workflows/unit_tests.yml
@@ -0,0 +1,52 @@
+name: Run Tests
+
+on: 
+  pull_request: {}
+  push:
+    branches: [master]
+
+jobs:
+  unit_tests:
+    name: Run unit tests
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os:
+          - macos-latest
+          - ubuntu-latest
+          - windows-latest
+        neovim_version: 
+          - v0.7.2
+          - v0.8.3
+          - v0.9.5
+          - v0.10.4
+          - nightly
+
+    steps:
+      - uses: actions/checkout@v4
+      - run: date +%F > todays-date
+      - name: Restore cache for today's nightly.
+        uses: actions/cache@v4
+        with:
+          path: ~/nvim-nightly
+          key: ${{ runner.os }}-x64-${{ hashFiles('todays-date') }}
+
+      - name: Prepare plenary
+        uses: actions/checkout@v4
+        with:
+          repository: nvim-lua/plenary.nvim
+          # FIXME: update this once we drop support for neovim 0.7.*
+          ref: 37604d95d8a212478063c40df4b7c48313453d52
+          path: _runtime/plenary.nvim
+
+      - uses: rhysd/action-setup-vim@v1
+        with:
+          neovim: true
+          version: ${{ matrix.neovim_version }}
+
+      - name: Run tests
+        env:
+          PLENARY: _runtime/plenary.nvim
+        run: |
+          nvim --headless --noplugin -u tests/minimal.vim -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/minimal.vim'}"
diff --git a/README.md b/README.md
index 28fe247..c537927 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,6 @@
+<!-- panvimdoc-ignore-start -->
 # auto-dark-mode.nvim
+<!-- panvimdoc-ignore-end -->
 
 A Neovim plugin for macOS, Linux, and Windows that automatically changes the
 editor appearance based on system settings.
@@ -32,15 +34,32 @@ editor appearance based on system settings.
 ## 📋 Requirements
 Your operating system needs to be:
 
-- a Linux desktop environment that implements
-  [`org.freedesktop.appearance.color-scheme`](https://github.com/flatpak/xdg-desktop-portal/issues/629),
-  such as
+- a Linux desktop environment that implements [`org.freedesktop.appearance.color-scheme`](https://github.com/flatpak/xdg-desktop-portal/issues/629), such as
   - [Gnome](https://gnome.org)
   - [KDE](https://kde.org)
   - [darkman](https://gitlab.com/WhyNotHugo/darkman) for window managers
 - macOS Mojave or newer
 - Windows 10 or newer (or WSL)
 
+<details>
+<summary>Notes for WSL</summary>
+
+To query the appearance of the Windows host system, two options **must be**
+enabled in `/etc/wsl.conf`:
+
+```ini
+[automount]
+enabled = true
+
+[interop]
+enabled = true
+```
+
+See [the Microsoft docs](https://learn.microsoft.com/en-us/windows/wsl/wsl-config)
+for more information about WSL settings.
+
+</details>
+
 ## 📦 Installation
 
 Install the plugin with your preferred package manager:
@@ -107,4 +126,4 @@ You can disable `auto-dark-mode.nvim` at runtime via `lua require('auto-dark-mod
 
 
 👩‍🎤 Special thanks to [@nekowinston](https://github.com/nekowinston) for 
-maintaining the plugin 👩‍🎤
\ No newline at end of file
+maintaining the plugin 👩‍🎤
diff --git a/doc/auto-dark-mode.txt b/doc/auto-dark-mode.txt
index 9f287e4..3507c1f 100644
--- a/doc/auto-dark-mode.txt
+++ b/doc/auto-dark-mode.txt
@@ -3,36 +3,46 @@
 ==============================================================================
 Table of Contents                           *auto-dark-mode-table-of-contents*
 
-1. auto-dark-mode.nvim                    |auto-dark-mode-auto-dark-mode.nvim|
-  - 📋 Requirements   |auto-dark-mode-auto-dark-mode.nvim-📋-requirements|
-  - 📦 Installation   |auto-dark-mode-auto-dark-mode.nvim-📦-installation|
-  - ⚙️ Configuration|auto-dark-mode-auto-dark-mode.nvim-⚙️-configuration|
-  - 🚀 Usage                 |auto-dark-mode-auto-dark-mode.nvim-🚀-usage|
-  - 💖 Contributors   |auto-dark-mode-auto-dark-mode.nvim-💖-contributors|
-2. Links                                                |auto-dark-mode-links|
-
-==============================================================================
-1. auto-dark-mode.nvim                    *auto-dark-mode-auto-dark-mode.nvim*
-
+  - Requirements                                 |auto-dark-mode-requirements|
+  - Installation                                 |auto-dark-mode-installation|
+  - Configuration                               |auto-dark-mode-configuration|
+  - Usage                                               |auto-dark-mode-usage|
+  - Contributors                                 |auto-dark-mode-contributors|
+1. Links                                                |auto-dark-mode-links|
 A Neovim plugin for macOS, Linux, and Windows that automatically changes the
 editor appearance based on system settings.
 
 
-📋 REQUIREMENTS       *auto-dark-mode-auto-dark-mode.nvim-📋-requirements*
+REQUIREMENTS                                     *auto-dark-mode-requirements*
 
 Your operating system needs to be:
 
-- a Linux desktop environment that implements
-    `org.freedesktop.appearance.color-scheme` <https://github.com/flatpak/xdg-desktop-portal/issues/629>,
-    such as
+- a Linux desktop environment that implements `org.freedesktop.appearance.color-scheme` <https://github.com/flatpak/xdg-desktop-portal/issues/629>, such as
     - Gnome <https://gnome.org>
     - KDE <https://kde.org>
     - darkman <https://gitlab.com/WhyNotHugo/darkman> for window managers
 - macOS Mojave or newer
 - Windows 10 or newer (or WSL)
 
+Notes for WSL ~
+
+To query the appearance of the Windows host system, two options **must be**
+enabled in `/etc/wsl.conf`
+
+>ini
+    [automount]
+    enabled = true
+    
+    [interop]
+    enabled = true
+<
+
+Seethe Microsoft docs
+<https://learn.microsoft.com/en-us/windows/wsl/wsl-config> for more information
+about WSL settings.
+
 
-📦 INSTALLATION       *auto-dark-mode-auto-dark-mode.nvim-📦-installation*
+INSTALLATION                                     *auto-dark-mode-installation*
 
 Install the plugin with your preferred package manager:
 
@@ -59,7 +69,7 @@ VIM-PLUG ~
 <
 
 
-⚙️ CONFIGURATION *auto-dark-mode-auto-dark-mode.nvim-⚙️-configuration*
+CONFIGURATION                                   *auto-dark-mode-configuration*
 
 **auto-dark-mode** comes with the following defaults:
 
@@ -90,7 +100,7 @@ be particularly useful to specify a default version when remotely connecting
 via SSH, or when using neovim on a tty.
 
 
-🚀 USAGE                     *auto-dark-mode-auto-dark-mode.nvim-🚀-usage*
+USAGE                                                   *auto-dark-mode-usage*
 
 
 DISABLING AT RUNTIME ~
@@ -99,15 +109,15 @@ You can disable `auto-dark-mode.nvim` at runtime via `lua
 require('auto-dark-mode').disable()`.
 
 
-💖 CONTRIBUTORS       *auto-dark-mode-auto-dark-mode.nvim-💖-contributors*
+CONTRIBUTORS                                     *auto-dark-mode-contributors*
 
 <https://github.com/f-person/auto-dark-mode.nvim/graphs/contributors>
 
-👩‍🎤 Special thanks to @nekowinston <https://github.com/nekowinston> for
-maintaining the plugin 👩‍🎤
+‍ Special thanks to @nekowinston <https://github.com/nekowinston> for
+maintaining the plugin ‍
 
 ==============================================================================
-2. Links                                                *auto-dark-mode-links*
+1. Links                                                *auto-dark-mode-links*
 
 1. **: https://contrib.rocks/image?repo=f-person/auto-dark-mode.nvim
 2. *@nekowinston*: 
diff --git a/lua/auto-dark-mode/health.lua b/lua/auto-dark-mode/health.lua
new file mode 100644
index 0000000..66a2318
--- /dev/null
+++ b/lua/auto-dark-mode/health.lua
@@ -0,0 +1,102 @@
+local M = {}
+
+local uv = vim.uv or vim.loop
+
+local adm = require("auto-dark-mode")
+local interval = require("auto-dark-mode.interval")
+
+M.benchmark = function(iterations)
+	local result = {
+		avg = 0,
+		max = 0,
+		min = math.huge,
+		all_results = {},
+		stdout = nil,
+		stderr = nil,
+		parsed = nil,
+	}
+
+	for _ = 1, iterations do
+		local _start = uv.hrtime()
+		-- parsing the response is measured, but actually syncing the vim theme isn't performed
+		local function callback(_stdout, _stderr)
+			result.stdout = _stdout
+			result.stderr = _stderr
+			result.parsed = interval.parse_callback(_stdout, _stderr)
+			vim.schedule_wrap(function()
+				vim.print(result)
+			end)
+		end
+
+		local success, _ = pcall(interval.poll_dark_mode, callback)
+		-- bail early if polling fails
+		if not success then
+			break
+		end
+		local _end = uv.hrtime()
+
+		table.insert(result.all_results, (_end - _start) / 1e6)
+	end
+
+	local sum = 0
+	for _, v in pairs(result.all_results) do
+		result.max = result.max > v and result.max or v
+		result.min = result.min < v and result.min or v
+		sum = sum + v
+	end
+	result.avg = sum / iterations
+
+	return result
+end
+
+-- support for neovim < 0.9.0
+local H = vim.health
+local health = {}
+health.start = H.start or H.report_start
+health.ok = H.ok or H.report_ok
+health.info = H.info or H.report_info
+health.error = H.error or H.report_error
+
+M.check = function()
+	health.start("auto-dark-mode.nvim")
+
+	if adm.state.setup_correct then
+		health.ok("Setup is correct")
+	else
+		health.error("Setup is incorrect")
+	end
+
+	health.info(string.format("Detected operating system: %s", adm.state.system))
+	health.info(string.format("Using query command: `%s`", table.concat(adm.state.query_command, " ")))
+
+	interval.poll_dark_mode(function(stdout, stderr)
+		vim.schedule(function()
+			health.info(string.format("Query response:\nstdout: %s\nstderr: %s\n", stdout, stderr))
+		end)
+	end)
+
+	local benchmark = M.benchmark(30)
+	health.info(
+		string.format("Benchmark: %.2fms avg / %.2fms min / %.2fms max", benchmark.avg, benchmark.min, benchmark.max)
+	)
+
+	local update_interval = adm.options.update_interval
+	local ratio = update_interval / benchmark.avg
+	local msg = {
+		info = string.format("Update interval (%dms) is %.2fx the average query time", update_interval, ratio),
+		error = string.format(
+			"Update interval (%dms) seems too short compared to current benchmarks, consider increasing it",
+			update_interval
+		),
+	}
+
+	if ratio > 30 then
+		health.ok(msg.info)
+	elseif ratio > 5 then
+		health.warn(msg.info)
+	else
+		health.error(msg.error)
+	end
+end
+
+return M
diff --git a/lua/auto-dark-mode/init.lua b/lua/auto-dark-mode/init.lua
index 92e931f..90c8523 100644
--- a/lua/auto-dark-mode/init.lua
+++ b/lua/auto-dark-mode/init.lua
@@ -1,100 +1,70 @@
-local utils = require("auto-dark-mode.utils")
-
----@type number
-local timer_id
----@type boolean
-local is_currently_dark_mode
-
----@type fun(): nil | nil
-local set_dark_mode
----@type fun(): nil | nil
-local set_light_mode
-
----@type number
-local update_interval
-
----@type table
-local query_command
----@type "Linux" | "Darwin" | "Windows_NT" | "WSL"
-local system
-
----@type "light" | "dark"
-local fallback
-
--- Parses the query response for each system
----@param res table
----@return boolean
-local function parse_query_response(res)
-	if system == "Linux" then
-		-- https://github.com/flatpak/xdg-desktop-portal/blob/c0f0eb103effdcf3701a1bf53f12fe953fbf0b75/data/org.freedesktop.impl.portal.Settings.xml#L32-L46
-		-- 0: no preference
-		-- 1: dark
-		-- 2: light
-		if string.match(res[1], "uint32 1") ~= nil then
-			return true
-		elseif string.match(res[1], "uint32 2") ~= nil then
-			return false
-		else
-			return fallback == "dark"
-		end
-	elseif system == "Darwin" then
-		return res[1] == "Dark"
-	elseif system == "Windows_NT" or system == "WSL" then
-		-- AppsUseLightTheme REG_DWORD 0x0 : dark
-		-- AppsUseLightTheme REG_DWORD 0x1 : light
-		return string.match(res[3], "0x1") == nil
-	end
-	return false
-end
+local M = {}
 
----@param callback fun(is_dark_mode: boolean)
-local function check_is_dark_mode(callback)
-	utils.start_job(query_command, {
-		on_stdout = function(data)
-			local is_dark_mode = parse_query_response(data)
-			callback(is_dark_mode)
-		end,
-	})
-end
+local uv = vim.uv or vim.loop
 
----@param is_dark_mode boolean
-local function change_theme_if_needed(is_dark_mode)
-	if is_dark_mode == is_currently_dark_mode then
-		return
-	end
+---@type AutoDarkModeOptions
+local default_options = {
+	fallback = "dark",
 
-	is_currently_dark_mode = is_dark_mode
-	if is_currently_dark_mode then
-		set_dark_mode()
-	else
-		set_light_mode()
-	end
-end
+	set_dark_mode = function()
+		vim.api.nvim_set_option_value("background", "dark", {})
+	end,
+
+	set_light_mode = function()
+		vim.api.nvim_set_option_value("background", "light", {})
+	end,
 
-local function start_check_timer()
-	timer_id = vim.fn.timer_start(update_interval, function()
-		check_is_dark_mode(change_theme_if_needed)
-	end, { ["repeat"] = -1 })
+	update_interval = 3000,
+}
+
+---@param options AutoDarkModeOptions
+local function validate_options(options)
+	vim.validate({
+		fallback = {
+			options.fallback,
+			function(opt)
+				return vim.tbl_contains({ "dark", "light" }, opt)
+			end,
+			"`fallback` must be either 'light' or 'dark'",
+		},
+		set_dark_mode = { options.set_dark_mode, "function" },
+		set_light_mode = { options.set_light_mode, "function" },
+		update_interval = { options.update_interval, "number" },
+	})
+
+	M.state.setup_correct = true
 end
 
-local function init()
-	if string.match(vim.loop.os_uname().release, "WSL") then
-		system = "WSL"
+---@class AutoDarkModeState
+M.state = {
+	---@type boolean
+	setup_correct = false,
+	---@type DetectedOS
+	system = nil,
+	---@type table
+	query_command = {},
+}
+
+---@return nil
+M.init = function()
+	local os_uname = uv.os_uname()
+
+	if string.match(os_uname.release, "WSL") then
+		M.state.system = "WSL"
 	else
-		system = vim.loop.os_uname().sysname
+		M.state.system = os_uname.sysname
 	end
 
-	if system == "Darwin" then
-		query_command = { "defaults", "read", "-g", "AppleInterfaceStyle" }
-	elseif system == "Linux" then
-		if not vim.fn.executable("dbus-send") then
-			error([[
-		`dbus-send` is not available. The Linux implementation of
-		auto-dark-mode.nvim relies on `dbus-send` being on the `$PATH`.
-		]])
+	if M.state.system == "Darwin" then
+		M.state.query_command = { "defaults", "read", "-g", "AppleInterfaceStyle" }
+	elseif M.state.system == "Linux" then
+		if vim.fn.executable("dbus-send") == 0 then
+			error(
+				"auto-dark-mode.nvim: `dbus-send` is not available. The Linux implementation of auto-dark-mode.nvim relies on `dbus-send` being on the `$PATH`."
+			)
 		end
 
-		query_command = {
+		M.state.query_command = {
 			"dbus-send",
 			"--session",
 			"--print-reply=literal",
@@ -105,89 +75,88 @@ local function init()
 			"string:org.freedesktop.appearance",
 			"string:color-scheme",
 		}
-	elseif system == "WSL" then
-		-- Don't swap the quotes; it breaks the code
-		query_command = {
-			"/mnt/c/Windows/system32/reg.exe",
-			"Query",
-			"HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
-			"/v",
-			"AppsUseLightTheme",
-		}
-  elseif system == "Windows_NT" then
-		-- Don't swap the quotes; it breaks the code
-		query_command = {
-			"reg.exe",
+	elseif M.state.system == "Windows_NT" or M.state.system == "WSL" then
+		local reg = "reg.exe"
+
+		-- gracefully handle a bunch of WSL specific errors
+		if M.state.system == "WSL" then
+			-- automount not being enabled
+			if not uv.fs_stat("/mnt/c/Windows") then
+				error(
+					"auto-dark-mode.nvim: Your WSL configuration doesn't enable `automount`. Please see https://learn.microsoft.com/en-us/windows/wsl/wsl-config#automount-settings."
+				)
+			end
+
+			-- binfmt not being provided for windows executables
+			if
+				not (
+					uv.fs_stat("/proc/sys/fs/binfmt_misc/WSLInterop")
+					or uv.fs_stat("/proc/sys/fs/binfmt_misc/WSLInterop-late")
+				)
+			then
+				error(
+					"auto-dark-mode.nvim: Your WSL configuration doesn't enable `interop`. Please see https://learn.microsoft.com/en-us/windows/wsl/wsl-config#interop-settings."
+				)
+			end
+
+			-- `appendWindowsPath` being set to false
+			if vim.fn.executable("reg.exe") == 0 then
+				local hardcoded_path = "/mnt/c/Windows/system32/reg.exe"
+				if uv.fs_stat(hardcoded_path) then
+					reg = hardcoded_path
+				else
+					error(
+						"auto-dark-mode.nvim: `reg.exe` cannot be found. To support syncing with the host system, this plugin relies on `reg.exe` being on the `$PATH`."
+					)
+				end
+			end
+		end
+
+		M.state.query_command = {
+			reg,
 			"Query",
 			"HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
 			"/v",
 			"AppsUseLightTheme",
 		}
-
 	else
 		return
 	end
 
-	if vim.fn.has("unix") ~= 0 then
-		if vim.loop.getuid() == 0 then
-			local sudo_user = vim.env.SUDO_USER
-
-			if sudo_user ~= nil then
-				query_command = vim.tbl_extend("keep", { "su", "-", sudo_user, "-c" }, query_command)
-			else
-				error([[
-				auto-dark-mode.nvim:
-				Running as `root`, but `$SUDO_USER` is not set.
-				Please open an issue to add support for your system.
-				]])
-			end
-		end
-	end
+	-- when on a supported unix system, and the userid is root
+	if (M.state.system == "Darwin" or M.state.system == "Linux") and uv.getuid() == 0 then
+		local sudo_user = vim.env.SUDO_USER
 
-	if type(set_dark_mode) ~= "function" or type(set_light_mode) ~= "function" then
-		error([[
+		if not sudo_user then
+			error(
+				"auto-dark-mode.nvim: Running as `root`, but `$SUDO_USER` is not set. Please open an issue to add support for your setup."
+			)
+		end
 
-		Call `setup` first:
+		local prefixed_cmd = { "sudo", "--user", sudo_user }
+		vim.list_extend(prefixed_cmd, M.state.query_command)
 
-		require('auto-dark-mode').setup({
-			set_dark_mode=function()
-				vim.api.nvim_set_option_value('background', 'dark')
-				vim.cmd('colorscheme gruvbox')
-			end,
-			set_light_mode=function()
-				vim.api.nvim_set_option_value('background', 'light')
-			end,
-		})
-		]])
+		M.state.query_command = prefixed_cmd
 	end
 
-	check_is_dark_mode(change_theme_if_needed)
-	start_check_timer()
-end
-
-local function disable()
-	vim.fn.timer_stop(timer_id)
-end
+	local interval = require("auto-dark-mode.interval")
 
----@param options AutoDarkModeOptions
-local function setup(options)
-	options = options or {}
+	interval.start(M.options, M.state)
 
-	---@param background string
-	local function set_background(background)
-		vim.api.nvim_set_option_value("background", background, {})
-	end
+	-- expose the previous `require("auto-dark-mode").disable()` function
+	M.disable = interval.stop_timer
+end
 
-	set_dark_mode = options.set_dark_mode or function()
-		set_background("dark")
-	end
-	set_light_mode = options.set_light_mode or function()
-		set_background("light")
+---@param options? AutoDarkModeOptions
+---@return nil
+M.setup = function(options)
+	if not options then
+		options = {}
 	end
-	update_interval = options.update_interval or 3000
-	fallback = options.fallback or "dark"
+	M.options = vim.tbl_deep_extend("keep", options or {}, default_options)
+	validate_options(M.options)
 
-	init()
+	M.init()
 end
 
-return { setup = setup, init = init, disable = disable }
+return M
diff --git a/lua/auto-dark-mode/interval.lua b/lua/auto-dark-mode/interval.lua
new file mode 100644
index 0000000..e8628d5
--- /dev/null
+++ b/lua/auto-dark-mode/interval.lua
@@ -0,0 +1,153 @@
+local M = {
+	---@type uv_timer_t
+	timer = nil,
+	---@type number
+	timer_id = nil,
+	---@type Appearance
+	current_appearance = "dark",
+}
+
+local uv = vim.uv or vim.loop
+
+-- Parses the query response for each system, returning the current appearance,
+-- or `nil` if it can't be resolved.
+---@param stdout string
+---@param stderr string
+---@return Appearance?
+local function parse_query_response(stdout, stderr)
+	if M.state.system == "Linux" then
+		-- https://github.com/flatpak/xdg-desktop-portal/blob/c0f0eb103effdcf3701a1bf53f12fe953fbf0b75/data/org.freedesktop.impl.portal.Settings.xml#L32-L46
+		-- 0: no preference
+		-- 1: dark
+		-- 2: light
+		if string.match(stdout, "uint32 1") ~= nil then
+			return "dark"
+		elseif string.match(stdout, "uint32 2") ~= nil then
+			return "light"
+		else
+			return M.options.fallback
+		end
+	elseif M.state.system == "Darwin" then
+		return stdout == "Dark\n" and "dark" or "light"
+	elseif M.state.system == "Windows_NT" or M.state.system == "WSL" then
+		-- AppsUseLightTheme REG_DWORD 0x0 : dark
+		-- AppsUseLightTheme REG_DWORD 0x1 : light
+		return string.match(stdout, "0x1") and "light" or "dark"
+	end
+
+	return nil
+end
+
+-- Executes the `set_dark_mode` and `set_light_mode` hooks when needed,
+-- otherwise it's a no-op.
+---@param appearance Appearance
+---@return nil
+local function sync_theme(appearance)
+	if appearance == M.current_appearance then
+		return
+	end
+
+	M.current_appearance = appearance
+	if M.current_appearance == "dark" then
+		if vim.system then
+			vim.schedule(M.options.set_dark_mode)
+		else
+			M.options.set_dark_mode()
+		end
+	elseif M.current_appearance == "light" then
+		if vim.system then
+			vim.schedule(M.options.set_light_mode)
+		else
+			M.options.set_light_mode()
+		end
+	end
+end
+
+-- Uses a subprocess to query the system for the current dark mode setting.
+-- The callback is called with the plaintext stdout response of the query.
+---@param callback? fun(stdout: string, stderr: string): nil
+---@return nil
+M.poll_dark_mode = function(callback)
+	-- if no callback is provided, use a no-op
+	if callback == nil then
+		callback = function() end
+	end
+
+	if vim.system then
+		vim.system(M.state.query_command, { text = true }, function(data)
+			callback(data.stdout, data.stderr)
+		end)
+	else
+		-- Legacy implementation using `vim.fn.jobstart` instead of `vim.system`,
+		-- for use in neovim <0.10.0
+		local stdout = ""
+		local stderr = ""
+
+		vim.fn.jobstart(M.state.query_command, {
+			stderr_buffered = true,
+			stdout_buffered = true,
+			on_stderr = function(_, data, _)
+				stderr = table.concat(data, " ")
+			end,
+			on_stdout = function(_, data, _)
+				stdout = table.concat(data, " ")
+			end,
+			on_exit = function(_, _, _)
+				callback(stdout, stderr)
+			end,
+		})
+	end
+end
+
+---@param stdout string
+---@param stderr string
+---@return nil
+M.parse_callback = function(stdout, stderr)
+	local appearance = parse_query_response(stdout, stderr)
+
+	if appearance ~= nil then
+		sync_theme(appearance)
+	end
+end
+
+local timer_callback = function()
+	M.poll_dark_mode(M.parse_callback)
+end
+
+---@return nil
+M.start_timer = function()
+	---@type number
+	local interval = M.options.update_interval
+
+	-- needs to check for `vim.system` because the poll function depends on it
+	if uv and vim.system then
+		M.timer = uv.new_timer()
+		M.timer:start(interval, interval, timer_callback)
+	else
+		M.timer_id = vim.fn.timer_start(interval, timer_callback, { ["repeat"] = -1 })
+	end
+end
+
+---@return nil
+M.stop_timer = function()
+	if uv.timer_stop then
+		uv.timer_stop(M.timer)
+	else
+		vim.fn.timer_stop(M.timer_id)
+	end
+end
+
+---@param options AutoDarkModeOptions
+---@param state AutoDarkModeState
+---@return nil
+M.start = function(options, state)
+	M.options = options
+	M.state = state
+
+	-- act as if the timer has finished once to instantly sync on startup
+	timer_callback()
+
+	M.start_timer()
+end
+
+return M
diff --git a/lua/auto-dark-mode/types.lua b/lua/auto-dark-mode/types.lua
index 65a4e46..575cd33 100644
--- a/lua/auto-dark-mode/types.lua
+++ b/lua/auto-dark-mode/types.lua
@@ -1,10 +1,21 @@
+---@meta
+
+---@alias Appearance "light" | "dark"
+---@alias DetectedOS "Linux" | "Darwin" | "Windows_NT" | "WSL"
+
 ---@class AutoDarkModeOptions
--- Optional. If not provided, `vim.api.nvim_set_option_value('background', 'dark')` will be used.
----@field set_dark_mode nil | fun(): nil
--- Optional. If not provided, `vim.api.nvim_set_option_value('background', 'light')` will be used.
----@field set_light_mode nil | fun(): nil
--- Every `update_interval` milliseconds a theme check will be performed.
----@field update_interval number?
 -- Optional. Fallback theme to use if the system theme can't be detected.
 -- Useful for linux and environments without a desktop manager.
----@field fallback "light" | "dark" | nil
+---@field fallback? Appearance
+-- Optional. The default runs:
+-- ```lua
+-- vim.api.nvim_set_option_value('background', 'dark', {})
+-- ```
+---@field set_dark_mode? fun(): nil
+-- Optional. The default runs:
+-- ```lua
+-- vim.api.nvim_set_option_value('background', 'light', {})
+-- ```
+---@field set_light_mode? fun(): nil
+-- Optional. Specifies the `update_interval` milliseconds a theme check will be performed.
+---@field update_interval? number
diff --git a/lua/auto-dark-mode/utils.lua b/lua/auto-dark-mode/utils.lua
deleted file mode 100644
index b9df7b9..0000000
--- a/lua/auto-dark-mode/utils.lua
+++ /dev/null
@@ -1,30 +0,0 @@
-local M = {}
-
----@param cmd table
----@param opts {input?: string, on_stdout?: function, on_exit?: function}
----@return number | 'the job id'
-function M.start_job(cmd, opts)
-	opts = opts or {}
-	local id = vim.fn.jobstart(cmd, {
-		stdout_buffered = true,
-		on_stdout = function(_, data, _)
-			if data and opts.on_stdout then
-				opts.on_stdout(data)
-			end
-		end,
-		on_exit = function(_, data, _)
-			if opts.on_exit then
-				opts.on_exit(data)
-			end
-		end,
-	})
-
-	if opts.input then
-		vim.fn.chansend(id, opts.input)
-		vim.fn.chanclose(id, "stdin")
-	end
-
-	return id
-end
-
-return M
diff --git a/tests/minimal.vim b/tests/minimal.vim
new file mode 100644
index 0000000..699703c
--- /dev/null
+++ b/tests/minimal.vim
@@ -0,0 +1,7 @@
+set rtp+=.
+if $PLENARY != "" && isdirectory($PLENARY)
+  set rtp+=$PLENARY
+else
+  set rtp+=../plenary.nvim
+endif
+runtime! plugin/plenary.vim
diff --git a/tests/plenary/healthcheck_spec.lua b/tests/plenary/healthcheck_spec.lua
new file mode 100644
index 0000000..d835cb2
--- /dev/null
+++ b/tests/plenary/healthcheck_spec.lua
@@ -0,0 +1,6 @@
+describe("healthchecks", function()
+	it("running `:checkhealth auto-dark-mode`", function()
+		require("auto-dark-mode").setup()
+		vim.cmd(":checkhealth auto-dark-mode")
+	end)
+end)
diff --git a/tests/plenary/query_spec.lua b/tests/plenary/query_spec.lua
new file mode 100644
index 0000000..4287822
--- /dev/null
+++ b/tests/plenary/query_spec.lua
@@ -0,0 +1,15 @@
+describe("querying", function()
+	it("polling dark mode", function()
+		-- needed to initialize the plugin state
+		require("auto-dark-mode").setup()
+
+		require("auto-dark-mode.interval").poll_dark_mode()
+	end)
+
+	it("parsing the response", function()
+		-- needed to initialize the plugin state
+		require("auto-dark-mode").setup()
+
+		require("auto-dark-mode.interval").poll_dark_mode(require("auto-dark-mode.interval").parse_callback)
+	end)
+end)
diff --git a/tests/plenary/setup_spec.lua b/tests/plenary/setup_spec.lua
new file mode 100644
index 0000000..78a7626
--- /dev/null
+++ b/tests/plenary/setup_spec.lua
@@ -0,0 +1,18 @@
+describe("calling setup()", function()
+	it("with default config", function()
+		require("auto-dark-mode").setup()
+	end)
+
+	it("with customized config", function()
+		require("auto-dark-mode").setup({
+			fallback = "light",
+			set_dark_mode = function()
+				vim.notify("would set dark mode")
+			end,
+			set_light_mode = function()
+				vim.notify("would set light mode")
+			end,
+			update_interval = 10000,
+		})
+	end)
+end)


Reviews:
user: login: nekowinston
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-08T03:24:55Z
user: login: nekowinston
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-08T10:20:06Z

Review comments:
diff_hunk: @@ -0,0 +1,102 @@
+local M = {}
+
+local uv = vim.uv or vim.loop
+
+local adm = require("auto-dark-mode")
+local interval = require("auto-dark-mode.interval")
+
+M.benchmark = function(iterations)
+	local result = {
+		avg = 0,
+		max = 0,
+		min = math.huge,
+		all_results = {},
+		stdout = nil,
+		stderr = nil,
+		parsed = nil,
+	}
+
+	for _ = 1, iterations do
+		local _start = uv.hrtime()
+		-- parsing the response is measured, but actually syncing the vim theme isn't performed
+		local function callback(_stdout, _stderr)
+			result.stdout = _stdout
+			result.stderr = _stderr
+			result.parsed = interval.parse_callback(_stdout, _stderr)
+			vim.schedule_wrap(function()
+				vim.print(result)
+			end)
+		end
+
+		local success, _ = pcall(interval.poll_dark_mode, callback)
+		-- bail early if polling fails
+		if not success then
+			break
+		end
+		local _end = uv.hrtime()
+
+		table.insert(result.all_results, (_end - _start) / 1e6)
+	end
+
+	local sum = 0
+	for _, v in pairs(result.all_results) do
+		result.max = result.max > v and result.max or v
+		result.min = result.min < v and result.min or v
+		sum = sum + v
+	end
+	result.avg = sum / iterations
+
+	return result
+end
+
+-- support for neovim < 0.9.0
path: lua/auto-dark-mode/health.lua
user: login: nekowinston
body: We can't actually test neovim 0.8.x with the busted test runner.
It looks like Ubuntu 24.04 and onward now have 0.9 [in their repositories](https://repology.org/project/neovim/versions), so  I'm not sure if supporting 0.8 is even worth it.
created_at: 2025-03-08T03:24:55Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,102 @@
+local M = {}
+
+local uv = vim.uv or vim.loop
+
+local adm = require("auto-dark-mode")
+local interval = require("auto-dark-mode.interval")
+
+M.benchmark = function(iterations)
+	local result = {
+		avg = 0,
+		max = 0,
+		min = math.huge,
+		all_results = {},
+		stdout = nil,
+		stderr = nil,
+		parsed = nil,
+	}
+
+	for _ = 1, iterations do
+		local _start = uv.hrtime()
+		-- parsing the response is measured, but actually syncing the vim theme isn't performed
+		local function callback(_stdout, _stderr)
+			result.stdout = _stdout
+			result.stderr = _stderr
+			result.parsed = interval.parse_callback(_stdout, _stderr)
+			vim.schedule_wrap(function()
+				vim.print(result)
+			end)
+		end
+
+		local success, _ = pcall(interval.poll_dark_mode, callback)
+		-- bail early if polling fails
+		if not success then
+			break
+		end
+		local _end = uv.hrtime()
+
+		table.insert(result.all_results, (_end - _start) / 1e6)
+	end
+
+	local sum = 0
+	for _, v in pairs(result.all_results) do
+		result.max = result.max > v and result.max or v
+		result.min = result.min < v and result.min or v
+		sum = sum + v
+	end
+	result.avg = sum / iterations
+
+	return result
+end
+
+-- support for neovim < 0.9.0
path: lua/auto-dark-mode/health.lua
user: login: nekowinston
body: I migrated the tests to use [Plenary's busted](https://github.com/nvim-lua/plenary.nvim/blob/master/TESTS_README.md) instead, things should work for earlier versions now.
created_at: 2025-03-08T10:20:05Z
author_association: COLLABORATOR

PR comments:
user: login: nekowinston
created_at: 2024-10-10T22:37:49Z
body: I still need to check if this works on neovim 0.8 and neovim 0.9, but support for 0.10 should work fine already.
author_association: COLLABORATOR
user: login: sedlund
created_at: 2025-03-02T10:58:59Z
body: this still seems to broken on master for me on wsl
author_association: NONE
user: login: nekowinston
created_at: 2025-03-08T11:17:46Z
body: I just need a Windows/WSL system to test this on, but should be able to do that this weekend. Aiming to merge it after that.

Sorry for letting this PR sit for a while.
author_association: COLLABORATOR
user: login: nekowinston
created_at: 2025-03-09T12:13:42Z
body: Managed to fix some more issues with the `sudo` query function and the WSL implementation (thanks to @s1deprojects for helping me troubleshoot the code on their system 🙂).
author_association: COLLABORATOR