diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..d47f983 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,11 @@ +[target.x86_64-apple-darwin] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] + +[target.aarch64-apple-darwin] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..1d953f4 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6e2a592 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +on: + pull_request: + push: + branches: + - master + +jobs: + test: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + + steps: + - uses: actions/checkout@v5 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Run tests + run: cargo test --all --verbose diff --git a/.gitignore b/.gitignore index d5f0966..0777e13 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,8 @@ .lsp .nrepl* -# Created by https://www.toptal.com/developers/gitignore/api/windows,macos,linux,lua -# Edit at https://www.toptal.com/developers/gitignore?templates=windows,macos,linux,lua +# Created by https://www.toptal.com/developers/gitignore/api/windows,macos,linux,lua,rust +# Edit at https://www.toptal.com/developers/gitignore?templates=windows,macos,linux,lua,rust ### Linux ### *~ @@ -96,6 +96,22 @@ Temporary Items # iCloud generated files *.icloud +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + ### Windows ### # Windows thumbnail cache files Thumbs.db @@ -122,4 +138,4 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk -# End of https://www.toptal.com/developers/gitignore/api/windows,macos,linux,lua +# End of https://www.toptal.com/developers/gitignore/api/windows,macos,linux,lua,rust diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f5f4e44 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "defold-nvim" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anyhow = "1.0.100" +netstat2 = "0.11.2" +nvim-oxi = { version = "0.6.0", features = ["neovim-0-11"] } +reqwest = { version = "0.12.24", features = ["blocking", "json"] } +rust-ini = "0.21.3" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +sha3 = "0.10.8" +sysinfo = "0.37.2" diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..1064b81 --- /dev/null +++ b/Justfile @@ -0,0 +1,19 @@ +watch: + watchexec -w src -r 'just build-and-link' + +build: + cargo build + +link: + #!/usr/bin/env bash + set -e + + if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]] || [[ "$OSTYPE" == "cygwin" ]]; then + cp -f "$(pwd)/target/debug/defold_nvim.dll" lua/defold/sidecar.dll + elif [[ "$(uname)" == "Darwin" ]]; then + cp -f "$(pwd)/target/debug/libdefold_nvim.dylib" lua/defold/sidecar.so + else + cp -f "$(pwd)/target/debug/libdefold_nvim.so" lua/defold/sidecar.so + fi + +build-and-link: build link diff --git a/README.md b/README.md index 4c8d460..51bf14d 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ You're in luck, powershell is surprisingly capable so there is nothing else need "mfussenegger/nvim-dap", }, + version = "*", + opts = { -- config options, see below }, @@ -150,11 +152,6 @@ local config = { custom_arguments = nil, }, - babashka = { - -- Use a custom executable for babashka (default: nil) - custom_executable = nil, - }, - -- setup keymaps for Defold actions keymaps = { diff --git a/bb.edn b/bb.edn index 4162311..01659d4 100644 --- a/bb.edn +++ b/bb.edn @@ -4,9 +4,7 @@ setup {:task (apply defold/run-wrapped :setup *command-line-args*)} set-default-editor {:task (apply defold/run-wrapped :set-default-editor *command-line-args*)} install-dependencies {:task (apply defold/run-wrapped :install-dependencies *command-line-args*)} - list-commands {:task (apply defold/run-wrapped :list-commands *command-line-args*)} list-dependency-dirs {:task (apply defold/run-wrapped :list-dependency-dirs *command-line-args*)} - send-command {:task (apply defold/run-wrapped :send-command *command-line-args*)} launch-neovim {:task (apply defold/run-wrapped :launch-neovim *command-line-args*)} focus-neovim {:task (apply defold/run-wrapped :focus-neovim *command-line-args*)} focus-game {:task (apply defold/run-wrapped :focus-game *command-line-args*)} diff --git a/lua/defold/editor.lua b/lua/defold/editor.lua index 40b3675..53e502e 100644 --- a/lua/defold/editor.lua +++ b/lua/defold/editor.lua @@ -1,17 +1,13 @@ local M = {} ---List all available Defold commands +---@param port integer|nil ---@return table|nil -function M.list_commands() - local babashka = require "defold.service.babashka" +function M.list_commands(port) local log = require "defold.service.logger" + local sidecar = require "defold.sidecar" - local res = babashka.run_task_json "list-commands" - - if not res then - log.error "Could not fetch commands from Defold, maybe the editor isn't running?" - return nil - end + local res = sidecar.list_commands(port) if res.error then log.error(string.format("Could not fetch commands from Defold, because: %s", res.error)) @@ -22,19 +18,20 @@ function M.list_commands() end ---Sends a command to the Defold editor +---@param port integer|nil ---@param command string ---@param dont_report_error boolean|nil -function M.send_command(command, dont_report_error) - local babashka = require "defold.service.babashka" +function M.send_command(port, command, dont_report_error) local log = require "defold.service.logger" + local sidecar = require "defold.sidecar" - local res = babashka.run_task_json("send-command", { command }) + local res = sidecar.send_command(port, command) - if res.status == 202 then + if not res.error then return end - if dont_report_error or false then + if dont_report_error then return end diff --git a/lua/defold/init.lua b/lua/defold/init.lua index 5424e3a..e1cbf36 100644 --- a/lua/defold/init.lua +++ b/lua/defold/init.lua @@ -79,6 +79,9 @@ M.loaded = false ---@type DefoldNvimConfig M.config = default_config +---@type integer|nil +M.prev_editor_port = nil + ---Returns true if we are in a defold project ---@return boolean function M.is_defold_project() @@ -104,6 +107,8 @@ function M.setup(opts) M.config = vim.tbl_deep_extend("force", default_config, opts or {}) + -- TODO: check if sidecar is available, if not download it (which shouldnt be necessary with some pkg managers) + -- persist config for babashka local config_path = babashka.config_path() vim.fn.writefile({ @@ -196,6 +201,18 @@ function M.setup(opts) end, 0) end +---@return integer +function M.editor_port() + if M.prev_editor_port then + -- TODO: validate port + return M.prev_editor_port + end + + local sidecar = require "defold.sidecar" + M.prev_editor_port = sidecar.find_editor_port() + return M.prev_editor_port +end + function M.load_plugin() if M.loaded then return @@ -206,6 +223,7 @@ function M.load_plugin() -- register all filetypes vim.filetype.add(require("defold.config.filetype").full) + local sidecar = require "defold.sidecar" local babashka = require "defold.service.babashka" local debugger = require "defold.service.debugger" local editor = require "defold.editor" @@ -213,6 +231,7 @@ function M.load_plugin() local project = require "defold.project" log.debug "============= defold.nvim: Loaded plugin" + log.debug("Sidecar Version:" .. sidecar.version) log.debug("Babashka Path: " .. babashka.bb_path()) log.debug("Mobdap Path: " .. debugger.mobdap_path()) log.debug("Config: " .. vim.inspect(M.config)) @@ -222,7 +241,7 @@ function M.load_plugin() vim.api.nvim_create_autocmd("BufWritePost", { pattern = { "*.lua", "*.script", "*.gui_script" }, callback = function() - editor.send_command("hot-reload", true) + editor.send_command(M.editor_port(), "hot-reload", true) end, }) end @@ -256,26 +275,26 @@ function M.load_plugin() return end - editor.send_command(cmds[idx]) + editor.send_command(M.editor_port(), cmds[idx]) end) end, { nargs = 0, desc = "Select a command to run" }) -- add the ":DefoldSend cmd" command to send commands to the editor vim.api.nvim_create_user_command("DefoldSend", function(opt) - editor.send_command(opt.args) + editor.send_command(M.editor_port(), opt.args) end, { nargs = 1, desc = "Send a command to the Defold editor" }) -- add the ":DefoldFetch" command to fetch dependencies & annoatations vim.api.nvim_create_user_command("DefoldFetch", function(opt) -- when a user runs DefoldFetch I recon they also expect us to update the dependencies - editor.send_command("fetch-libraries", true) + editor.send_command(M.editor_port(), "fetch-libraries", true) project.install_dependencies(opt.bang) end, { bang = true, nargs = 0, desc = "Fetch & create Defold project dependency annotations" }) -- integrate the debugger into dap if M.config.debugger.enable then - debugger.register_nvim_dap() + debugger.register_nvim_dap(M.editor_port) end -- add snippets @@ -289,7 +308,7 @@ function M.load_plugin() log.debug(string.format("Setup action '%s' for keymap '%s'", action, vim.json.encode(keymap))) vim.keymap.set(keymap.mode, keymap.mapping, function() - editor.send_command(action) + editor.send_command(M.editor_port(), action) end) end diff --git a/lua/defold/service/debugger.lua b/lua/defold/service/debugger.lua index 3fac42a..b21a51c 100644 --- a/lua/defold/service/debugger.lua +++ b/lua/defold/service/debugger.lua @@ -42,7 +42,8 @@ function M.setup(custom_executable, custom_arguments) M.mobdap_path() end -function M.register_nvim_dap() +---@param editor_port_fn fun(): integer +function M.register_nvim_dap(editor_port_fn) local log = require "defold.service.logger" local ok, dap = pcall(require, "dap") @@ -91,7 +92,7 @@ function M.register_nvim_dap() dap.listeners.after.event_mobdap_waiting_for_connection.defold_nvim_start_game = function(_, _) log.debug "debugger: connected" - editor.send_command "build" + editor.send_command(editor_port_fn(), "build") end dap.listeners.after.event_stopped.defold_nvim_switch_focus_on_stop = function(_, _) diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..4f32adb --- /dev/null +++ b/shell.nix @@ -0,0 +1,15 @@ +{ + pkgs ? import { }, +}: + +pkgs.mkShell { + nativeBuildInputs = with pkgs; [ + pkg-config + ]; + + buildInputs = with pkgs; [ + openssl.dev + ]; + + RUST_BACKTRACE = "1"; +} diff --git a/src/defold/editor.clj b/src/defold/editor.clj deleted file mode 100644 index cbb50c2..0000000 --- a/src/defold/editor.clj +++ /dev/null @@ -1,97 +0,0 @@ -(ns defold.editor - (:require - [babashka.http-client :as http] - [babashka.process :refer [shell]] - [cheshire.core :as json] - [clojure.string :as string] - [defold.constants :refer [mobdap-port]] - [defold.utils :refer [command-exists?]] - [taoensso.timbre :as log])) - -(defn make-command-url [port cmd] - (str "http://127.0.0.1:" port "/command/" (string/lower-case cmd))) - -(defn- is-defold-port? [port] - (when (not= (Integer/parseInt port) mobdap-port) - (try (let [res (http/head (make-command-url port "") {:timeout 100}) - status (:status res)] - (= status 200)) - (catch Exception _ false)))) - -(defn- extract-port-generic [line] - (->> - (string/split line #" ") - (filter not-empty) - (map #(re-find #".*:(\d+)$" %)) - (filter some?) - (first) - (last))) - -(defn- find-port-generic [& cmd] - (try - (-> (apply shell {:out :string} cmd) - :out - (string/split-lines) - (->> (filter #(string/includes? % "java"))) - (->> (map extract-port-generic)) - (->> (filter is-defold-port?)) - (first)) - (catch Exception t - (do - (log/error "Error: " t) - (throw (ex-info (str "Could not find Defold port via '" (string/join " " cmd) "'.") {})))))) - -(defn- find-port-netstat [] - (some #(when (is-defold-port? %) %) - (-> (shell {:out :string :err :string} "netstat" "-anv") - :out - (string/split-lines) - (->> (filter #(string/includes? % "LISTEN"))) - (->> (map #(string/split % #" "))) - (->> (filter #(= (first %) "tcp"))) - (flatten) - (->> (map #(re-find #".*:(\d+)$" %))) - (->> (map second)) - (->> (filter some?)) - (->> (map Integer/parseInt)) - (->> (sort >))))) - -(defn find-port [] - (cond - (command-exists? "lsof") - (find-port-generic "lsof" "-nP" "-iTCP" "-sTCP:LISTEN") - - (command-exists? "ss") - (find-port-generic "ss" "-tplH4") - - (command-exists? "netstat") - (find-port-netstat) - - :else (throw (ex-info "Couldn't find either 'lsof', 'ss' or 'netstat', which is necessary to interact with Defold" {})))) - -(defn list-commands [] - (try - (let [port (find-port) - url (make-command-url port "")] - (-> - (http/get url) - :body - (json/parse-string))) - (catch Exception e - (let [msg (ex-message e)] - (log/error "Error: " msg e) - {"error" (ex-message e)})))) - -(defn send-command [cmd] - (try - (let [port (find-port) - url (make-command-url port cmd)] - {"status" (:status (http/post url))}) - (catch Exception e - (if (= 403 (get-in (Throwable->map e) [:data :status])) - (do (log/warn (format "Editor responded 403 Forbidden to %s" cmd)) - {"error" (ex-message e)}) - (let [msg (ex-message e)] - (log/error "Error: " msg e) - {"error" (ex-message e)}))))) - diff --git a/src/defold/main.clj b/src/defold/main.clj index ebcf9df..213305f 100644 --- a/src/defold/main.clj +++ b/src/defold/main.clj @@ -3,7 +3,6 @@ [babashka.fs :as fs] [cheshire.core :as json] [defold.debugger :as debugger] - [defold.editor :as editor] [defold.editor-config :as editor-config] [defold.focus :as focus] [defold.launcher :as launcher] @@ -37,7 +36,7 @@ (when-not (get-in conf ["plugin_config" "debugger" "custom_executable"]) (debugger/setup)) (when (and (= "neovide" (get-in conf ["plugin_config" "launcher" "type"])) - (not (get-in conf ["plugin_config" "launcher" "executable"]))) + (not (get-in conf ["plugin_config" "launcher" "executable"]))) (neovide/setup)) (print-json {:status 200})) (catch Throwable t @@ -54,15 +53,9 @@ ([_ _ game-project force-redownload] (print-json (project/install-dependencies game-project force-redownload)))) -(defmethod run :list-commands [_ _] - (print-json (editor/list-commands))) - (defmethod run :list-dependency-dirs [_ _ game-project] (print-json (project/list-dependency-dirs game-project))) -(defmethod run :send-command [_ _ cmd] - (print-json (editor/send-command cmd))) - (defmethod run :launch-neovim ([_ config-file root-dir filename] (let [conf (parse-config config-file)] diff --git a/src/editor.rs b/src/editor.rs new file mode 100644 index 0000000..a0d70e3 --- /dev/null +++ b/src/editor.rs @@ -0,0 +1,107 @@ +use std::collections::HashMap; + +use anyhow::{Context, Result, bail}; +use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo, iterate_sockets_info}; +use sysinfo::System; + +fn command_url(port: u16, command: Option) -> String { + format!( + "http://127.0.0.1:{port}/command/{}", + command.unwrap_or_default() + ) +} + +pub fn find_port() -> Option { + let sys = System::new_all(); + + for (pid, proc) in sys.processes() { + if !proc + .name() + .to_ascii_lowercase() + .to_str() + .unwrap_or_default() + .contains("java") + { + continue; + } + + let ports = ports_for_pid(pid.as_u32()); + + if ports.is_err() { + continue; + } + + for port in ports.unwrap() { + if is_editor(port) { + return Some(port); + } + } + } + + None +} + +fn ports_for_pid(pid: u32) -> Result> { + let mut ports = Vec::new(); + + let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6; + let proto_flags = ProtocolFlags::TCP | ProtocolFlags::UDP; + + for socket in iterate_sockets_info(af_flags, proto_flags)? { + let socket = socket?; + + if socket.associated_pids.contains(&pid) { + match &socket.protocol_socket_info { + ProtocolSocketInfo::Tcp(tcp) => { + ports.push(tcp.local_port); + } + ProtocolSocketInfo::Udp(udp) => { + ports.push(udp.local_port); + } + } + } + } + + Ok(ports) +} + +fn is_editor(port: u16) -> bool { + reqwest::blocking::Client::new() + .head(command_url(port, None)) + .send() + .is_ok_and(|r| r.status().is_success()) +} + +pub fn list_commands(port: Option) -> Result> { + let url = command_url( + port.or_else(|| find_port()) + .context("could not determine editor port")?, + None, + ); + + let res = reqwest::blocking::get(url)?; + + if !res.status().is_success() { + bail!("could not list commands, status: {:?}", res.status()); + } + + let content = res.text()?.to_string(); + + serde_json::from_str(&content).map_err(|err| anyhow::Error::from(err)) +} + +pub fn send_command(port: Option, cmd: String) -> Result<()> { + let url = command_url( + port.or_else(|| find_port()) + .context("could not determine editor port")?, + Some(cmd.clone()), + ); + + let res = reqwest::blocking::Client::new().post(url).send()?; + + if !res.status().is_success() { + bail!("could not send command {cmd}, status: {:?}", res.status()); + } + + Ok(()) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..dab9393 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,27 @@ +use nvim_oxi::{conversion::ToObject, serde::Serializer}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct LuaError { + pub error: String, +} + +impl From for LuaError { + fn from(value: String) -> Self { + LuaError { + error: format!("Err: {value}"), + } + } +} + +impl From for LuaError { + fn from(value: anyhow::Error) -> Self { + LuaError::from(format!("{value:?}")) + } +} + +impl ToObject for LuaError { + fn to_object(self) -> Result { + self.serialize(Serializer::new()).map_err(Into::into) + } +} diff --git a/src/game_project.rs b/src/game_project.rs new file mode 100644 index 0000000..8475134 --- /dev/null +++ b/src/game_project.rs @@ -0,0 +1,56 @@ +use std::{fs, path::PathBuf}; + +use anyhow::{Context, Result, bail}; +use ini::Ini; +use nvim_oxi::{conversion::ToObject, serde::Serializer}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct GameProject { + pub title: String, + pub dependencies: Vec, +} + +impl GameProject { + pub fn load_from_path(path: PathBuf) -> Result { + if !path.exists() { + bail!("game.project file {path:?} could not be found"); + } + + GameProject::try_from(fs::read_to_string(path)?) + } +} + +impl TryFrom for GameProject { + type Error = anyhow::Error; + + fn try_from(value: String) -> std::result::Result { + let proj = Ini::load_from_str(value.as_str())?; + + let project_section = proj + .section(Some("project")) + .context("invalid game.project: no [project] section")?; + + let title = project_section + .get("title") + .context("invalid game.project: no title field in [project] section")? + .to_string(); + + let dependencies = project_section + .iter() + .filter(|(k, _)| k.starts_with("dependencies#")) + .map(|(_, v)| v.to_string()) + .collect(); + + Ok(GameProject { + title, + dependencies, + }) + } +} + +impl ToObject for GameProject { + fn to_object(self) -> std::result::Result { + self.serialize(Serializer::new()).map_err(Into::into) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f8ab95a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,145 @@ +use anyhow::{Result, anyhow}; +use nvim_oxi::conversion::ToObject; +use nvim_oxi::{Dictionary, Function, Object, ObjectKind}; +use sha3::{Digest, Sha3_256}; + +use crate::game_project::GameProject; + +mod editor; +mod error; +mod game_project; + +use crate::error::*; + +#[nvim_oxi::plugin] +fn defold_sidecar() -> Dictionary { + Dictionary::from_iter([ + ("version", Object::from(env!("CARGO_PKG_VERSION"))), + ("sha3", Object::from(Function::from_fn(sha3))), + ( + "read_game_project", + Object::from(Function::from_fn(read_game_project)), + ), + ( + "find_editor_port", + Object::from(Function::from_fn(find_editor_port)), + ), + ( + "list_commands", + Object::from(Function::from_fn(list_commands)), + ), + ( + "send_command", + Object::from(Function::from_fn(send_command)), + ), + ]) +} + +fn sha3(input: Object) -> Object { + if input.kind() != ObjectKind::String { + return LuaError::from("sha3(input), expected input to be of type string".to_string()) + .to_object() + .unwrap(); + }; + + let input = unsafe { input.as_nvim_str_unchecked().to_string() }; + + let mut hasher = Sha3_256::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + + format!("{:x}", result).into() +} + +fn read_game_project(path: Object) -> Object { + if path.kind() != ObjectKind::String { + return LuaError::from( + "read_game_project(path), expected path to be of type string".to_string(), + ) + .to_object() + .unwrap(); + }; + + let path = unsafe { path.as_nvim_str_unchecked().to_string() }; + + match GameProject::load_from_path(path.into()) { + Ok(game_project) => game_project.to_object().unwrap_or_else(|err| { + LuaError::from(anyhow::Error::from(err)) + .to_object() + .unwrap() + }), + Err(err) => LuaError::from(err).to_object().unwrap(), + } +} + +fn find_editor_port(_: ()) -> Object { + match editor::find_port() { + Some(port) => Object::from(port), + None => LuaError::from("Could not find editor port".to_string()) + .to_object() + .unwrap(), + } +} + +fn list_commands(port: Object) -> Object { + let port = match port.kind() { + ObjectKind::Integer => match u16::try_from(unsafe { port.as_integer_unchecked() }) { + Ok(port) => Some(port), + Err(err) => { + return LuaError::from(anyhow::Error::from(err)) + .to_object() + .unwrap(); + } + }, + _ => None, + }; + + let commands = editor::list_commands(port); + + let Ok(commands) = commands else { + return LuaError::from(anyhow::Error::from(commands.unwrap_err())) + .to_object() + .unwrap(); + }; + + Object::from_iter(commands) +} + +fn send_command((port, cmd): (Object, Object)) -> Object { + let Ok(port) = get_int_opt(port) else { + return LuaError::from("send_command(port, cmd): port was not int|nil".to_string()) + .to_object() + .unwrap(); + }; + + let Ok(cmd) = get_string(cmd) else { + return LuaError::from("send_command(port, cmd): cmd was not a string".to_string()) + .to_object() + .unwrap(); + }; + + if let Err(err) = editor::send_command(port, cmd) { + return LuaError::from(err).to_object().unwrap(); + } + + Object::from(Dictionary::new()) +} + +fn get_int_opt>(o: Object) -> Result> { + match o.kind() { + ObjectKind::Integer => match T::try_from(unsafe { o.as_integer_unchecked() }) { + Ok(v) => Ok(Some(v)), + Err(_) => Ok(None), + }, + ObjectKind::Nil => Ok(None), + _ => Err(anyhow!("object was not an integer")), + } +} + +fn get_string(o: Object) -> Result { + match o.kind() { + ObjectKind::String => String::try_from(unsafe { o.as_nvim_str_unchecked() }.to_string()) + .map_err(anyhow::Error::from), + _ => Err(anyhow!("object was not a string")), + } +}