diff --git a/CHANGELOG.md b/CHANGELOG.md index 1174b9123..58a89bf21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `overwrite_mappings` option, which sets the mappings in the config even if they already exist - Added support for multiple vaults (#128) - Added command to switch between vaults (#60) +- Added configuration option `yaml_parser` (a string value of either "native" or "yq") to change the YAML parser. ### Fixed diff --git a/README.md b/README.md index bc2cd6c32..ec9be2602 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ Built for people who love the concept of Obsidian -- a simple, markdown-based no Search functionality (e.g. via the `:ObsidianSearch` and `:ObsidianQuickSwitch` commands) also requires [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) or one of the `fzf` alternatives (see [plugin dependencies](#plugin-dependencies) below). +You may also want to install [`yq`](https://github.com/mikefarah/yq) for more robust frontmatter YAML parsing. + ### Install and configure To configure obsidian.nvim you just need to call `require("obsidian").setup({ ... })` with the desired options. @@ -304,6 +306,12 @@ This is a complete list of all of the options that can be passed to `require("ob -- or replacing the current buffer (default) -- Accepted values are "current", "hsplit" and "vsplit" open_notes_in = "current" + + -- Optional, set the YAML parser to use. The valid options are: + -- * "native" - uses a pure Lua parser that's fast but not very robust. + -- * "yq" - uses the command-line tool yq (https://github.com/mikefarah/yq), which is more robust + -- but slower and needs to be installed separately. + yaml_parser = "native", } ``` diff --git a/lua/obsidian/config.lua b/lua/obsidian/config.lua index 5ee94f2b8..7eea8f2ee 100644 --- a/lua/obsidian/config.lua +++ b/lua/obsidian/config.lua @@ -27,6 +27,7 @@ local config = {} ---@field sort_by string|? ---@field sort_reversed boolean|? ---@field open_notes_in "current"|"vsplit"|"hsplit" +---@field yaml_parser string|? config.ClientOpts = {} ---Get defaults. @@ -54,6 +55,7 @@ config.ClientOpts.default = function() sort_by = "modified", sort_reversed = true, open_notes_in = "current", + yaml_parser = "native", } end diff --git a/lua/obsidian/init.lua b/lua/obsidian/init.lua index 848a1cb5b..0bc1114f3 100644 --- a/lua/obsidian/init.lua +++ b/lua/obsidian/init.lua @@ -9,6 +9,7 @@ obsidian.VERSION = "1.14.2" obsidian.completion = require "obsidian.completion" obsidian.note = require "obsidian.note" obsidian.util = require "obsidian.util" +obsidian.yaml = require "obsidian.yaml" obsidian.mapping = require "obsidian.mapping" obsidian.workspace = require "obsidian.workspace" @@ -32,6 +33,9 @@ obsidian.new = function(opts) self.dir = Path:new(self.current_workspace.path) self.opts = opts self.backlinks_namespace = vim.api.nvim_create_namespace "ObsidianBacklinks" + if self.opts.yaml_parser ~= nil then + obsidian.yaml.set_parser(self.opts.yaml_parser) + end return self end diff --git a/lua/obsidian/yaml/init.lua b/lua/obsidian/yaml/init.lua new file mode 100644 index 000000000..0a3fa3384 --- /dev/null +++ b/lua/obsidian/yaml/init.lua @@ -0,0 +1,117 @@ +local util = require "obsidian.util" + +local yaml = {} + +yaml.parsers = { + ["native"] = require "obsidian.yaml.native", + ["yq"] = require "obsidian.yaml.yq", +} + +---@return string +local detect_parser = function() + if vim.fn.executable "yq" then + return "yq" + else + return "native" + end +end + +yaml.parser = detect_parser() + +---Set the YAML parser to use. +---@param parser string +yaml.set_parser = function(parser) + yaml.parser = parser +end + +---Reset to the default parser. +yaml.reset_parser = function() + yaml.parser = detect_parser() +end + +---Deserialize a YAML string. +---@param str string +---@return any +yaml.loads = function(str) + return yaml.parsers[yaml.parser].loads(str) +end + +---@return string[] +local dumps +dumps = function(x, indent, order) + local indent_str = string.rep(" ", indent) + + if type(x) == "string" then + -- TODO: make this more robust + if string.match(x, "%w") then + return { indent_str .. x } + else + return { indent_str .. [["]] .. x .. [["]] } + end + end + + if type(x) == "boolean" then + return { indent_str .. tostring(x) } + end + + if type(x) == "number" then + return { indent_str .. tostring(x) } + end + + if type(x) == "table" then + local out = {} + + if util.is_array(x) then + for _, v in ipairs(x) do + local item_lines = dumps(v, indent + 2) + table.insert(out, indent_str .. "- " .. util.strip(item_lines[1])) + for i = 2, #item_lines do + table.insert(out, item_lines[i]) + end + end + else + -- Gather and sort keys so we can keep the order deterministic. + local keys = {} + for k, _ in pairs(x) do + table.insert(keys, k) + end + table.sort(keys, order) + for _, k in ipairs(keys) do + local v = x[k] + if type(v) == "string" or type(v) == "boolean" or type(v) == "number" then + table.insert(out, indent_str .. tostring(k) .. ": " .. dumps(v, 0)[1]) + elseif type(v) == "table" and util.table_length(v) == 0 then + table.insert(out, indent_str .. tostring(k) .. ": []") + else + local item_lines = dumps(v, indent + 2) + table.insert(out, indent_str .. tostring(k) .. ":") + for _, line in ipairs(item_lines) do + table.insert(out, line) + end + end + end + end + + return out + end + + error("Can't convert object with type " .. type(x) .. " to YAML") +end + +---Dump an object to YAML lines. +---@param x any +---@param order function +---@return string[] +yaml.dumps_lines = function(x, order) + return dumps(x, 0, order) +end + +---Dump an object to a YAML string. +---@param x any +---@param order function|? +---@return string +yaml.dumps = function(x, order) + return table.concat(dumps(x, 0, order), "\n") +end + +return yaml diff --git a/lua/obsidian/yaml.lua b/lua/obsidian/yaml/native.lua similarity index 87% rename from lua/obsidian/yaml.lua rename to lua/obsidian/yaml/native.lua index bf0390869..faf3e30aa 100644 --- a/lua/obsidian/yaml.lua +++ b/lua/obsidian/yaml/native.lua @@ -1,7 +1,5 @@ ---Adapted from https://github.com/exosite/lua-yaml.git -local util = require "obsidian.util" - local Parser = {} function Parser.new(self, tokens) @@ -590,78 +588,4 @@ yaml.loads = function(str) return parser:parse() end ----@return string[] -local dumps -dumps = function(x, indent, order) - local indent_str = string.rep(" ", indent) - - if type(x) == "string" then - -- TODO: handle double quotes in x - return { indent_str .. [["]] .. x .. [["]] } - end - - if type(x) == "boolean" then - return { indent_str .. tostring(x) } - end - - if type(x) == "number" then - return { indent_str .. tostring(x) } - end - - if type(x) == "table" then - local out = {} - - if util.is_array(x) then - for _, v in ipairs(x) do - local item_lines = dumps(v, indent + 2) - table.insert(out, indent_str .. "- " .. util.strip(item_lines[1])) - for i = 2, #item_lines do - table.insert(out, item_lines[i]) - end - end - else - -- Gather and sort keys so we can keep the order deterministic. - local keys = {} - for k, _ in pairs(x) do - table.insert(keys, k) - end - table.sort(keys, order) - for _, k in ipairs(keys) do - local v = x[k] - if type(v) == "string" or type(v) == "boolean" or type(v) == "number" then - table.insert(out, indent_str .. tostring(k) .. ": " .. dumps(v, 0)[1]) - elseif type(v) == "table" and util.table_length(v) == 0 then - table.insert(out, indent_str .. tostring(k) .. ": []") - else - local item_lines = dumps(v, indent + 2) - table.insert(out, indent_str .. tostring(k) .. ":") - for _, line in ipairs(item_lines) do - table.insert(out, line) - end - end - end - end - - return out - end - - error("Can't convert object with type " .. type(x) .. " to YAML") -end - ----Dump an object to YAML lines. ----@param x any ----@param order function ----@return string[] -yaml.dumps_lines = function(x, order) - return dumps(x, 0, order) -end - ----Dump an object to a YAML string. ----@param x any ----@param order function|? ----@return string -yaml.dumps = function(x, order) - return table.concat(dumps(x, 0, order), "\n") -end - return yaml diff --git a/lua/obsidian/yaml/yq.lua b/lua/obsidian/yaml/yq.lua new file mode 100644 index 000000000..7a3e0603e --- /dev/null +++ b/lua/obsidian/yaml/yq.lua @@ -0,0 +1,11 @@ +local m = {} + +---@param str string +---@return any +m.loads = function(str) + local as_json = vim.fn.system("yq -o=json", str) + local data = vim.json.decode(as_json, { luanil = { object = true, array = true } }) + return data +end + +return m diff --git a/test/obsidian/note_spec.lua b/test/obsidian/note_spec.lua index 6dc932471..3971b5f08 100644 --- a/test/obsidian/note_spec.lua +++ b/test/obsidian/note_spec.lua @@ -70,11 +70,11 @@ describe("Note", function() table.concat(note:frontmatter_lines(), "\n"), table.concat({ "---", - 'id: "note_with_additional_metadata"', + "id: note_with_additional_metadata", "aliases:", - ' - "Note with additional metadata"', + " - Note with additional metadata", "tags: []", - 'foo: "bar"', + "foo: bar", "---", }, "\n") ) diff --git a/test/obsidian/yaml_spec.lua b/test/obsidian/yaml_spec.lua index 22bb42053..ab91043bd 100644 --- a/test/obsidian/yaml_spec.lua +++ b/test/obsidian/yaml_spec.lua @@ -1,22 +1,23 @@ local yaml = require "obsidian.yaml" -describe("obsidian.yaml", function() +describe("obsidian.yaml.dumps", function() + yaml.set_parser "native" it("should dump numbers", function() assert.equals(yaml.dumps(1), "1") end) it("should dump strings", function() - assert.equals(yaml.dumps "hi there", '"hi there"') - assert.equals(yaml.dumps "hi it's me", [["hi it's me"]]) - assert.equals(yaml.dumps { foo = "bar" }, [[foo: "bar"]]) + assert.equals(yaml.dumps "hi there", "hi there") + assert.equals(yaml.dumps "hi it's me", [[hi it's me]]) + assert.equals(yaml.dumps { foo = "bar" }, [[foo: bar]]) end) it("should dump strings with single quote", function() - assert.equals(yaml.dumps "hi it's me", [["hi it's me"]]) + assert.equals(yaml.dumps "hi it's me", [[hi it's me]]) end) it("should dump table with string values", function() - assert.equals(yaml.dumps { foo = "bar" }, [[foo: "bar"]]) + assert.equals(yaml.dumps { foo = "bar" }, [[foo: bar]]) end) it("should dump arrays with string values", function() - assert.equals(yaml.dumps { "foo", "bar" }, '- "foo"\n- "bar"') + assert.equals(yaml.dumps { "foo", "bar" }, "- foo\n- bar") end) it("should dump arrays with number values", function() assert.equals(yaml.dumps { 1, 2 }, "- 1\n- 2") @@ -25,17 +26,88 @@ describe("obsidian.yaml", function() assert.equals(yaml.dumps { { a = 1 }, { b = 2 } }, "- a: 1\n- b: 2") end) it("should dump tables with string values", function() - assert.equals(yaml.dumps { a = "foo", b = "bar" }, 'a: "foo"\nb: "bar"') + assert.equals(yaml.dumps { a = "foo", b = "bar" }, "a: foo\nb: bar") end) it("should dump tables with number values", function() assert.equals(yaml.dumps { a = 1, b = 2 }, "a: 1\nb: 2") end) it("should dump tables with array values", function() - assert.equals(yaml.dumps { a = { "foo" }, b = { "bar" } }, 'a:\n - "foo"\nb:\n - "bar"') + assert.equals(yaml.dumps { a = { "foo" }, b = { "bar" } }, "a:\n - foo\nb:\n - bar") end) it("should dump tables with empty array", function() assert.equals(yaml.dumps { a = {} }, "a: []") end) +end) + +describe("obsidian.yaml.native", function() + it("should parse inline lists with quotes on items", function() + local data = yaml.loads 'aliases: ["Foo", "Bar", "Foo Baz"]' + assert.equals(type(data), "table") + assert.equals(type(data.aliases), "table") + assert.equals(#data.aliases, 3) + assert.equals(data.aliases[3], "Foo Baz") + + data = yaml.loads 'aliases: ["Foo"]' + assert.equals(type(data), "table") + assert.equals(type(data.aliases), "table") + assert.equals(#data.aliases, 1) + assert.equals(data.aliases[1], "Foo") + + data = yaml.loads 'aliases: ["Foo Baz"]' + assert.equals(type(data), "table") + assert.equals(type(data.aliases), "table") + assert.equals(#data.aliases, 1) + assert.equals(data.aliases[1], "Foo Baz") + end) + it("should parse inline lists without quotes on items", function() + local data = yaml.loads "aliases: [Foo, Bar, Foo Baz]" + assert.equals(type(data), "table") + assert.equals(type(data.aliases), "table") + assert.equals(#data.aliases, 3) + assert.equals(data.aliases[3], "Foo Baz") + + data = yaml.loads "aliases: [Foo]" + assert.equals(type(data), "table") + assert.equals(type(data.aliases), "table") + assert.equals(#data.aliases, 1) + assert.equals(data.aliases[1], "Foo") + + data = yaml.loads "aliases: [Foo Baz]" + assert.equals(type(data), "table") + assert.equals(type(data.aliases), "table") + assert.equals(#data.aliases, 1) + assert.equals(data.aliases[1], "Foo Baz") + end) + it("should parse boolean field values", function() + local data = yaml.loads "complete: false" + assert.equals(type(data), "table") + assert.equals(type(data.complete), "boolean") + end) + it("should parse implicit null values", function() + local data = yaml.loads "tags: \ncomplete: false" + assert.equals(type(data), "table") + assert.equals(data.tags, nil) + assert.equals(data.complete, false) + end) +end) + +describe("obsidian.yaml.yq", function() + yaml.set_parser "yq" + for key, data in pairs { + ["numbers"] = 1, + ["strings"] = "hi there", + ["strings with single quotes"] = "hi it's me", + ["tables with string values"] = { foo = "bar" }, + ["arrays with string values"] = { "foo", "bar" }, + ["arrays with number values"] = { 1, 2 }, + ["arrays with table values"] = { { a = 1 }, { b = 2 } }, + ["tables with number values"] = { a = 1 }, + ["tables with an empty array"] = { a = {} }, + } do + it("should dump/parse " .. key, function() + assert.are.same(yaml.loads(yaml.dumps(data)), data) + end) + end it("should parse inline lists with quotes on items", function() local data = yaml.loads 'aliases: ["Foo", "Bar", "Foo Baz"]' assert.equals(type(data), "table") diff --git a/test_fixtures/notes/foo_bar.md b/test_fixtures/notes/foo_bar.md index a69f75421..d99fada2e 100644 --- a/test_fixtures/notes/foo_bar.md +++ b/test_fixtures/notes/foo_bar.md @@ -1,9 +1,9 @@ --- -id: "foo" +id: foo aliases: - - "foo" - - "Foo" - - "Foo Bar" + - foo + - Foo + - Foo Bar tags: [] --- diff --git a/test_fixtures/notes/note_with_additional_metadata_saved.md b/test_fixtures/notes/note_with_additional_metadata_saved.md index f6ef83c85..189b16963 100644 --- a/test_fixtures/notes/note_with_additional_metadata_saved.md +++ b/test_fixtures/notes/note_with_additional_metadata_saved.md @@ -1,9 +1,9 @@ --- -id: "note_with_additional_metadata" +id: note_with_additional_metadata aliases: - - "Note with additional metadata" + - Note with additional metadata tags: [] -foo: "bar" +foo: bar --- # Note with additional metadata diff --git a/test_fixtures/notes/note_without_frontmatter_saved.md b/test_fixtures/notes/note_without_frontmatter_saved.md index 755a70262..6f7c2a5c0 100644 --- a/test_fixtures/notes/note_without_frontmatter_saved.md +++ b/test_fixtures/notes/note_without_frontmatter_saved.md @@ -1,7 +1,7 @@ --- -id: "note_without_frontmatter" +id: note_without_frontmatter aliases: - - "Hey there" + - Hey there tags: [] ---