From 2298d80d44d8c2c978ba5fcadc9c8bf2cbed7d11 Mon Sep 17 00:00:00 2001 From: Jason Paris Date: Sun, 6 Jul 2025 21:38:31 -0400 Subject: [PATCH 1/3] feat: add luacheck linting with configuration - Add .luacheckrc with Neovim-specific linting rules - Add Makefile with lint, test, and utility targets - Configure appropriate warning ignores for plugin development - Set 100 character line limit for consistent formatting --- .luacheckrc | 45 +++++++++++++++++++++++++++++++++++++++++++++ Makefile | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 .luacheckrc create mode 100644 Makefile diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..5ce58f4 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,45 @@ +-- luacheck configuration for markdown-notes.nvim + +-- Use LuaJIT + Neovim standard library +std = "luajit" + +-- Add Neovim globals +globals = { + "vim", +} + +-- Additional read-only globals for testing +read_globals = { + -- Busted testing framework + "describe", "it", "before_each", "after_each", "setup", "teardown", + "assert", "spy", "stub", "mock", + -- Plenary testing + "require", +} + +-- Files/directories to exclude +exclude_files = { + ".luarocks/", + "doc/tags", +} + +-- Ignore specific warnings +ignore = { + "212", -- Unused argument (common in Neovim plugins for callback functions) + "213", -- Unused loop variable (common in ipairs/pairs loops) +} + +-- Maximum line length +max_line_length = 100 + +-- Files and their specific configurations +files = { + ["tests/"] = { + -- Allow longer lines in tests for readability + max_line_length = 120, + -- Additional testing globals + globals = { + "vim", -- vim is mocked in tests + } + } +} \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bf331df --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +# Makefile for markdown-notes.nvim + +.PHONY: test lint lint-fix clean help + +# Default target +help: + @echo "Available targets:" + @echo " test - Run all tests" + @echo " lint - Run luacheck linter" + @echo " lint-fix - Run luacheck and show detailed issues for fixing" + @echo " clean - Clean up temporary files" + @echo " help - Show this help message" + +# Run tests +test: + nvim --headless -u tests/minimal_init.vim -c "lua require('plenary.test_harness').test_directory('tests')" + +# Run linter +lint: + luacheck lua/ tests/ + +# Run linter with detailed output for fixing +lint-fix: + luacheck --formatter plain --codes lua/ tests/ + +# Clean up +clean: + find . -name "*.tmp" -delete + find . -name "luacov.*" -delete + +# Check dependencies +check-deps: + @command -v luacheck >/dev/null 2>&1 || { echo "luacheck not found. Install with: luarocks install luacheck"; exit 1; } + @echo "✓ All dependencies available" \ No newline at end of file From 85b6485bde514d14b3addbdf3d36330e48bdb8d9 Mon Sep 17 00:00:00 2001 From: Jason Paris Date: Sun, 6 Jul 2025 21:47:18 -0400 Subject: [PATCH 2/3] fix: resolve all luacheck linting warnings and improve code quality - Fix logic issues by replacing inefficient loops with next() function calls - Break long lines to comply with 100 character limit - Remove trailing whitespace throughout codebase - Fix read-only field warnings in tests using proper metatable approach - Add test-setup.sh script for isolated testing environment - Achieve zero linting warnings across all Lua files --- lua/markdown-notes/config.lua | 266 ++++---- lua/markdown-notes/daily.lua | 56 +- lua/markdown-notes/init.lua | 241 +++---- lua/markdown-notes/links.lua | 568 ++++++++--------- lua/markdown-notes/notes.lua | 449 ++++++------- lua/markdown-notes/templates.lua | 178 +++--- lua/markdown-notes/workspace.lua | 149 ++--- plugin/markdown-notes.lua | 4 +- test-setup.sh | 281 +++++++++ tests/markdown-notes/config_spec.lua | 485 ++++++++------- tests/markdown-notes/links_spec.lua | 796 ++++++++++++------------ tests/markdown-notes/notes_spec.lua | 286 +++++---- tests/markdown-notes/templates_spec.lua | 194 +++--- tests/markdown-notes/workspace_spec.lua | 118 ++-- tests/minimal_init.vim | 2 +- 15 files changed, 2207 insertions(+), 1866 deletions(-) create mode 100755 test-setup.sh diff --git a/lua/markdown-notes/config.lua b/lua/markdown-notes/config.lua index 1e9e8cf..817122f 100644 --- a/lua/markdown-notes/config.lua +++ b/lua/markdown-notes/config.lua @@ -1,43 +1,55 @@ local M = {} M.defaults = { - vault_path = "~/repos/notes", - templates_path = "~/repos/notes/sys/templates", - dailies_path = "~/repos/notes/personal/dailies/2025", - weekly_path = "~/repos/notes/personal/weekly", - notes_subdir = "notes", - default_template = nil, -- Optional default template for new notes - - -- Template substitution variables - template_vars = { - date = function() return os.date("%Y-%m-%d") end, - time = function() return os.date("%H:%M") end, - datetime = function() return os.date("%Y-%m-%d %H:%M") end, - title = function() return vim.fn.expand("%:t:r") end, - yesterday = function() return os.date("%Y-%m-%d", os.time() - 86400) end, - tomorrow = function() return os.date("%Y-%m-%d", os.time() + 86400) end, - }, - - -- Default workspace (optional) - default_workspace = nil, - - -- Key mappings - mappings = { - daily_note_today = "nd", - daily_note_yesterday = "ny", - daily_note_tomorrow = "nt", - new_note = "nn", - new_note_from_template = "nc", - find_notes = "nf", - search_notes = "ns", - insert_link = "nl", - insert_template = "np", - search_tags = "ng", - show_backlinks = "nb", - follow_link = "gf", - rename_note = "nr", - pick_workspace = "nw", - }, + vault_path = "~/repos/notes", + templates_path = "~/repos/notes/sys/templates", + dailies_path = "~/repos/notes/personal/dailies/2025", + weekly_path = "~/repos/notes/personal/weekly", + notes_subdir = "notes", + default_template = nil, -- Optional default template for new notes + + -- Template substitution variables + template_vars = { + date = function() + return os.date("%Y-%m-%d") + end, + time = function() + return os.date("%H:%M") + end, + datetime = function() + return os.date("%Y-%m-%d %H:%M") + end, + title = function() + return vim.fn.expand("%:t:r") + end, + yesterday = function() + return os.date("%Y-%m-%d", os.time() - 86400) + end, + tomorrow = function() + return os.date("%Y-%m-%d", os.time() + 86400) + end, + }, + + -- Default workspace (optional) + default_workspace = nil, + + -- Key mappings + mappings = { + daily_note_today = "nd", + daily_note_yesterday = "ny", + daily_note_tomorrow = "nt", + new_note = "nn", + new_note_from_template = "nc", + find_notes = "nf", + search_notes = "ns", + insert_link = "nl", + insert_template = "np", + search_tags = "ng", + show_backlinks = "nb", + follow_link = "gf", + rename_note = "nr", + pick_workspace = "nw", + }, } M.workspaces = {} @@ -46,122 +58,122 @@ M.current_active_workspace = nil M.options = {} local function deep_extend(target, source) - for k, v in pairs(source) do - if type(v) == "table" and type(target[k]) == "table" then - deep_extend(target[k], v) - else - target[k] = v - end - end - return target + for k, v in pairs(source) do + if type(v) == "table" and type(target[k]) == "table" then + deep_extend(target[k], v) + else + target[k] = v + end + end + return target end function M.setup(opts) - if vim and vim.tbl_deep_extend then - M.options = vim.tbl_deep_extend("force", M.defaults, opts or {}) - else - -- Fallback for test environment - M.options = {} - for k, v in pairs(M.defaults) do - M.options[k] = v - end - if opts then - deep_extend(M.options, opts) - end - end - - -- Set default workspace if specified in config - if M.options.default_workspace then - M.default_workspace = M.options.default_workspace - end + if vim and vim.tbl_deep_extend then + M.options = vim.tbl_deep_extend("force", M.defaults, opts or {}) + else + -- Fallback for test environment + M.options = {} + for k, v in pairs(M.defaults) do + M.options[k] = v + end + if opts then + deep_extend(M.options, opts) + end + end + + -- Set default workspace if specified in config + if M.options.default_workspace then + M.default_workspace = M.options.default_workspace + end end function M.setup_workspace(name, opts) - local workspace_config - if vim and vim.tbl_deep_extend then - workspace_config = vim.tbl_deep_extend("force", M.defaults, opts or {}) - else - workspace_config = {} - for k, v in pairs(M.defaults) do - workspace_config[k] = v - end - if opts then - deep_extend(workspace_config, opts) - end - end - M.workspaces[name] = workspace_config + local workspace_config + if vim and vim.tbl_deep_extend then + workspace_config = vim.tbl_deep_extend("force", M.defaults, opts or {}) + else + workspace_config = {} + for k, v in pairs(M.defaults) do + workspace_config[k] = v + end + if opts then + deep_extend(workspace_config, opts) + end + end + M.workspaces[name] = workspace_config end local function ensure_active_workspace() - -- If no active workspace set, use default workspace - if M.current_active_workspace == nil then - if M.default_workspace and M.workspaces[M.default_workspace] then - M.current_active_workspace = M.default_workspace - else - -- Fall back to first configured workspace - for name, _ in pairs(M.workspaces) do - M.current_active_workspace = name - break - end - - -- If no workspaces configured, create a "default" workspace from base config - if M.current_active_workspace == nil then - M.workspaces["default"] = M.options - M.current_active_workspace = "default" - end - end - end - - -- Validate current active workspace still exists - if not M.workspaces[M.current_active_workspace] then - if M.default_workspace and M.workspaces[M.default_workspace] then - M.current_active_workspace = M.default_workspace - else - -- Fall back to first workspace - for name, _ in pairs(M.workspaces) do - M.current_active_workspace = name - break - end - end - end + -- If no active workspace set, use default workspace + if M.current_active_workspace == nil then + if M.default_workspace and M.workspaces[M.default_workspace] then + M.current_active_workspace = M.default_workspace + else + -- Fall back to first configured workspace + local first_workspace_name = next(M.workspaces) + if first_workspace_name then + M.current_active_workspace = first_workspace_name + end + + -- If no workspaces configured, create a "default" workspace from base config + if M.current_active_workspace == nil then + M.workspaces["default"] = M.options + M.current_active_workspace = "default" + end + end + end + + -- Validate current active workspace still exists + if not M.workspaces[M.current_active_workspace] then + if M.default_workspace and M.workspaces[M.default_workspace] then + M.current_active_workspace = M.default_workspace + else + -- Fall back to first workspace + local first_workspace_name = next(M.workspaces) + if first_workspace_name then + M.current_active_workspace = first_workspace_name + end + end + end end function M.get_current_config(bufnr) - ensure_active_workspace() - -- Always return a workspace config (guaranteed to exist) - return M.workspaces[M.current_active_workspace], M.current_active_workspace + ensure_active_workspace() + -- Always return a workspace config (guaranteed to exist) + return M.workspaces[M.current_active_workspace], M.current_active_workspace end function M.get_workspaces() - return M.workspaces + return M.workspaces end function M.set_active_workspace(name) - if M.workspaces[name] then - M.current_active_workspace = name - return true - else - vim.notify("Workspace '" .. name .. "' not found", vim.log.levels.ERROR) - return false - end + if M.workspaces[name] then + M.current_active_workspace = name + return true + else + vim.notify("Workspace '" .. name .. "' not found", vim.log.levels.ERROR) + return false + end end function M.get_active_workspace() - return M.current_active_workspace + return M.current_active_workspace end function M.set_default_workspace(name) - if M.workspaces[name] then - M.default_workspace = name - return true - else - vim.notify("Workspace '" .. name .. "' not found", vim.log.levels.ERROR) - return false - end + if M.workspaces[name] then + M.default_workspace = name + return true + else + vim.notify("Workspace '" .. name .. "' not found", vim.log.levels.ERROR) + return false + end end function M.get_default_workspace() - return M.default_workspace + return M.default_workspace end -return M \ No newline at end of file +return M diff --git a/lua/markdown-notes/daily.lua b/lua/markdown-notes/daily.lua index 52f1bd0..df5defe 100644 --- a/lua/markdown-notes/daily.lua +++ b/lua/markdown-notes/daily.lua @@ -4,33 +4,33 @@ local templates = require("markdown-notes.templates") local M = {} function M.open_daily_note(offset) - offset = offset or 0 - local date = os.date("%Y-%m-%d", os.time() + (offset * 86400)) - local options = config.get_current_config() - local file_path = vim.fn.expand(options.dailies_path .. "/" .. date .. ".md") - - -- Create directory if it doesn't exist - local dir = vim.fn.fnamemodify(file_path, ":h") - if vim.fn.isdirectory(dir) == 0 then - vim.fn.mkdir(dir, "p") - end - - -- Create file with template if it doesn't exist - if vim.fn.filereadable(file_path) == 0 then - local template_path = vim.fn.expand(options.templates_path .. "/Daily.md") - if vim.fn.filereadable(template_path) == 1 then - local template_content = vim.fn.readfile(template_path) - local custom_vars = { - date = date, - title = date, - datetime = date .. " " .. os.date("%H:%M"), - } - template_content = templates.substitute_template_vars(template_content, custom_vars) - vim.fn.writefile(template_content, file_path) - end - end - - vim.cmd("edit " .. file_path) + offset = offset or 0 + local date = os.date("%Y-%m-%d", os.time() + (offset * 86400)) + local options = config.get_current_config() + local file_path = vim.fn.expand(options.dailies_path .. "/" .. date .. ".md") + + -- Create directory if it doesn't exist + local dir = vim.fn.fnamemodify(file_path, ":h") + if vim.fn.isdirectory(dir) == 0 then + vim.fn.mkdir(dir, "p") + end + + -- Create file with template if it doesn't exist + if vim.fn.filereadable(file_path) == 0 then + local template_path = vim.fn.expand(options.templates_path .. "/Daily.md") + if vim.fn.filereadable(template_path) == 1 then + local template_content = vim.fn.readfile(template_path) + local custom_vars = { + date = date, + title = date, + datetime = date .. " " .. os.date("%H:%M"), + } + template_content = templates.substitute_template_vars(template_content, custom_vars) + vim.fn.writefile(template_content, file_path) + end + end + + vim.cmd("edit " .. file_path) end -return M \ No newline at end of file +return M diff --git a/lua/markdown-notes/init.lua b/lua/markdown-notes/init.lua index cab5bcd..a134005 100644 --- a/lua/markdown-notes/init.lua +++ b/lua/markdown-notes/init.lua @@ -8,126 +8,143 @@ local workspace = require("markdown-notes.workspace") local M = {} function M.setup(opts) - config.setup(opts) - M.setup_keymaps() + config.setup(opts) + M.setup_keymaps() end function M.setup_workspace(name, opts) - config.setup_workspace(name, opts) - - -- If this is the first workspace and no default is set, make it the default - if not config.get_default_workspace() then - config.set_default_workspace(name) - end + config.setup_workspace(name, opts) + + -- If this is the first workspace and no default is set, make it the default + if not config.get_default_workspace() then + config.set_default_workspace(name) + end end function M.set_default_workspace(name) - return config.set_default_workspace(name) + return config.set_default_workspace(name) end function M.setup_keymaps() - - -- Set up key mappings - local function map(mode, key, cmd, desc) - -- Use a wrapper that gets current workspace config at runtime - local function wrapped_cmd() - if type(cmd) == "function" then - cmd() - else - return cmd - end - end - - if config.options.mappings[key] then - vim.keymap.set(mode, config.options.mappings[key], wrapped_cmd, { - noremap = true, - silent = true, - desc = desc - }) - end - end - - -- Daily notes - map("n", "daily_note_today", function() daily.open_daily_note(0) end, "Open today's note") - map("n", "daily_note_yesterday", function() daily.open_daily_note(-1) end, "Open yesterday's note") - map("n", "daily_note_tomorrow", function() daily.open_daily_note(1) end, "Open tomorrow's note") - - -- Note management - map("n", "new_note", notes.create_new_note, "Create new note") - map("n", "new_note_from_template", notes.create_from_template, "Create new note from template") - map("n", "find_notes", notes.find_notes, "Find notes") - map("n", "search_notes", notes.search_notes, "Search notes") - - -- Links - map("n", "insert_link", links.search_and_link, "Insert link to note") - map("n", "follow_link", links.follow_link, "Follow link") - map("n", "show_backlinks", links.show_backlinks, "Show backlinks") - map("n", "rename_note", function() - vim.ui.input({prompt = "New note name: "}, function(input) - if input then - links.rename_note(input) - end - end) - end, "Rename note and update links") - - -- Templates - map("n", "insert_template", templates.pick_template, "Insert template") - - -- Tags - map("n", "search_tags", notes.search_tags, "Search tags") - - -- Workspaces - map("n", "pick_workspace", workspace.pick_workspace, "Pick workspace") - - -- Note management commands - vim.api.nvim_create_user_command("MarkdownNotesRename", function(opts) - if opts.args and opts.args ~= "" then - links.rename_note(opts.args) - else - vim.ui.input({prompt = "New note name: "}, function(input) - if input then - links.rename_note(input) - end - end) - end - end, { nargs = "?", desc = "Rename current note and update links" }) - - -- Workspace management commands - vim.api.nvim_create_user_command("MarkdownNotesWorkspaceStatus", workspace.show_current_workspace, { desc = "Show current workspace" }) - vim.api.nvim_create_user_command("MarkdownNotesWorkspacePick", workspace.pick_workspace, { desc = "Pick workspace with fzf" }) - vim.api.nvim_create_user_command("MarkdownNotesWorkspaceSwitch", function(opts) - workspace.switch_to_workspace(opts.args) - end, { - nargs = 1, - desc = "Switch to workspace", - complete = function() - local workspaces = config.get_workspaces() - local names = {} - for name, _ in pairs(workspaces) do - table.insert(names, name) - end - return names - end - }) - vim.api.nvim_create_user_command("MarkdownNotesWorkspaceSetDefault", function(opts) - workspace.set_default_workspace(opts.args) - end, { - nargs = 1, - desc = "Set default workspace", - complete = function() - local workspaces = config.get_workspaces() - local names = {} - for name, _ in pairs(workspaces) do - table.insert(names, name) - end - return names - end - }) - vim.api.nvim_create_user_command("MarkdownNotesWorkspaceShowDefault", workspace.show_default_workspace, { desc = "Show default workspace" }) - vim.api.nvim_create_user_command("MarkdownNotesWorkspaceActive", function() - local _, active = config.get_current_config() - vim.notify("Active workspace: " .. active, vim.log.levels.INFO) - end, { desc = "Show active workspace" }) + -- Set up key mappings + local function map(mode, key, cmd, desc) + -- Use a wrapper that gets current workspace config at runtime + local function wrapped_cmd() + if type(cmd) == "function" then + cmd() + else + return cmd + end + end + + if config.options.mappings[key] then + vim.keymap.set(mode, config.options.mappings[key], wrapped_cmd, { + noremap = true, + silent = true, + desc = desc, + }) + end + end + + -- Daily notes + map("n", "daily_note_today", function() + daily.open_daily_note(0) + end, "Open today's note") + map("n", "daily_note_yesterday", function() + daily.open_daily_note(-1) + end, "Open yesterday's note") + map("n", "daily_note_tomorrow", function() + daily.open_daily_note(1) + end, "Open tomorrow's note") + + -- Note management + map("n", "new_note", notes.create_new_note, "Create new note") + map("n", "new_note_from_template", notes.create_from_template, "Create new note from template") + map("n", "find_notes", notes.find_notes, "Find notes") + map("n", "search_notes", notes.search_notes, "Search notes") + + -- Links + map("n", "insert_link", links.search_and_link, "Insert link to note") + map("n", "follow_link", links.follow_link, "Follow link") + map("n", "show_backlinks", links.show_backlinks, "Show backlinks") + map("n", "rename_note", function() + vim.ui.input({ prompt = "New note name: " }, function(input) + if input then + links.rename_note(input) + end + end) + end, "Rename note and update links") + + -- Templates + map("n", "insert_template", templates.pick_template, "Insert template") + + -- Tags + map("n", "search_tags", notes.search_tags, "Search tags") + + -- Workspaces + map("n", "pick_workspace", workspace.pick_workspace, "Pick workspace") + + -- Note management commands + vim.api.nvim_create_user_command("MarkdownNotesRename", function(opts) + if opts.args and opts.args ~= "" then + links.rename_note(opts.args) + else + vim.ui.input({ prompt = "New note name: " }, function(input) + if input then + links.rename_note(input) + end + end) + end + end, { nargs = "?", desc = "Rename current note and update links" }) + + -- Workspace management commands + vim.api.nvim_create_user_command( + "MarkdownNotesWorkspaceStatus", + workspace.show_current_workspace, + { desc = "Show current workspace" } + ) + vim.api.nvim_create_user_command( + "MarkdownNotesWorkspacePick", + workspace.pick_workspace, + { desc = "Pick workspace with fzf" } + ) + vim.api.nvim_create_user_command("MarkdownNotesWorkspaceSwitch", function(opts) + workspace.switch_to_workspace(opts.args) + end, { + nargs = 1, + desc = "Switch to workspace", + complete = function() + local workspaces = config.get_workspaces() + local names = {} + for name, _ in pairs(workspaces) do + table.insert(names, name) + end + return names + end, + }) + vim.api.nvim_create_user_command("MarkdownNotesWorkspaceSetDefault", function(opts) + workspace.set_default_workspace(opts.args) + end, { + nargs = 1, + desc = "Set default workspace", + complete = function() + local workspaces = config.get_workspaces() + local names = {} + for name, _ in pairs(workspaces) do + table.insert(names, name) + end + return names + end, + }) + vim.api.nvim_create_user_command( + "MarkdownNotesWorkspaceShowDefault", + workspace.show_default_workspace, + { desc = "Show default workspace" } + ) + vim.api.nvim_create_user_command("MarkdownNotesWorkspaceActive", function() + local _, active = config.get_current_config() + vim.notify("Active workspace: " .. active, vim.log.levels.INFO) + end, { desc = "Show active workspace" }) end -return M \ No newline at end of file +return M diff --git a/lua/markdown-notes/links.lua b/lua/markdown-notes/links.lua index 16cb889..458f2e9 100644 --- a/lua/markdown-notes/links.lua +++ b/lua/markdown-notes/links.lua @@ -4,298 +4,304 @@ local M = {} -- Helper function to insert a link at cursor position local function insert_link_at_cursor(file_path) - -- Remove .md extension but keep the full path - local link_path = file_path:gsub("%.md$", "") - local link = "[[" .. link_path .. "]]" - - -- Insert link at cursor - local cursor_pos = vim.api.nvim_win_get_cursor(0) - local line = vim.api.nvim_get_current_line() - local new_line = line:sub(1, cursor_pos[2]) .. link .. line:sub(cursor_pos[2] + 1) - vim.api.nvim_set_current_line(new_line) - vim.api.nvim_win_set_cursor(0, {cursor_pos[1], cursor_pos[2] + #link}) + -- Remove .md extension but keep the full path + local link_path = file_path:gsub("%.md$", "") + local link = "[[" .. link_path .. "]]" + + -- Insert link at cursor + local cursor_pos = vim.api.nvim_win_get_cursor(0) + local line = vim.api.nvim_get_current_line() + local new_line = line:sub(1, cursor_pos[2]) .. link .. line:sub(cursor_pos[2] + 1) + vim.api.nvim_set_current_line(new_line) + vim.api.nvim_win_set_cursor(0, { cursor_pos[1], cursor_pos[2] + #link }) end function M.search_and_link() - local ok, fzf = pcall(require, "fzf-lua") - if not ok then - vim.notify("fzf-lua not available", vim.log.levels.ERROR) - return - end - local options = config.get_current_config() - - fzf.files({ - prompt = "Link to Note> ", - cwd = vim.fn.expand(options.vault_path), - cmd = "find . -name '*.md' -type f -not -path '*/.*'", - file_icons = false, - path_shorten = false, - formatter = nil, - previewer = "builtin", - actions = { - ["default"] = function(selected) - if selected and #selected > 0 then - local file_path = vim.fn.expand(options.vault_path .. "/" .. selected[1]) - vim.cmd("edit " .. vim.fn.fnameescape(file_path)) - end - end, - ["ctrl-l"] = function(selected) - if selected and #selected > 0 then - insert_link_at_cursor(selected[1]) - end - end, - }, - }) + local ok, fzf = pcall(require, "fzf-lua") + if not ok then + vim.notify("fzf-lua not available", vim.log.levels.ERROR) + return + end + local options = config.get_current_config() + + fzf.files({ + prompt = "Link to Note> ", + cwd = vim.fn.expand(options.vault_path), + cmd = "find . -name '*.md' -type f -not -path '*/.*'", + file_icons = false, + path_shorten = false, + formatter = nil, + previewer = "builtin", + actions = { + ["default"] = function(selected) + if selected and #selected > 0 then + local file_path = vim.fn.expand(options.vault_path .. "/" .. selected[1]) + vim.cmd("edit " .. vim.fn.fnameescape(file_path)) + end + end, + ["ctrl-l"] = function(selected) + if selected and #selected > 0 then + insert_link_at_cursor(selected[1]) + end + end, + }, + }) end function M.follow_link() - local line = vim.api.nvim_get_current_line() - local col = vim.api.nvim_win_get_cursor(0)[2] - local options = config.get_current_config() - - -- Look for [[link]] pattern around cursor - local link_start = line:sub(1, col + 1):find("%[%[[^%]]*$") - if link_start then - local link_end = line:find("%]%]", col + 1) - if link_end then - local link_text = line:sub(link_start + 2, link_end - 1) - local file_path = vim.fn.expand(options.vault_path .. "/" .. link_text .. ".md") - - -- Try to find the file if exact match doesn't exist - if vim.fn.filereadable(file_path) == 0 then - local find_cmd = "find " .. vim.fn.expand(options.vault_path) .. " -name '*" .. link_text .. "*.md' -type f -not -path '*/.*'" - local found_files = vim.fn.systemlist(find_cmd) - if #found_files > 0 then - file_path = found_files[1] - end - end - - if vim.fn.filereadable(file_path) == 1 then - vim.cmd("edit " .. file_path) - else - vim.notify("File not found: " .. link_text, vim.log.levels.WARN) - end - return - end - end - - -- Fallback to default gf behavior - vim.cmd("normal! gf") + local line = vim.api.nvim_get_current_line() + local col = vim.api.nvim_win_get_cursor(0)[2] + local options = config.get_current_config() + + -- Look for [[link]] pattern around cursor + local link_start = line:sub(1, col + 1):find("%[%[[^%]]*$") + if link_start then + local link_end = line:find("%]%]", col + 1) + if link_end then + local link_text = line:sub(link_start + 2, link_end - 1) + local file_path = vim.fn.expand(options.vault_path .. "/" .. link_text .. ".md") + + -- Try to find the file if exact match doesn't exist + if vim.fn.filereadable(file_path) == 0 then + local find_cmd = "find " + .. vim.fn.expand(options.vault_path) + .. " -name '*" + .. link_text + .. "*.md' -type f -not -path '*/.*'" + local found_files = vim.fn.systemlist(find_cmd) + if #found_files > 0 then + file_path = found_files[1] + end + end + + if vim.fn.filereadable(file_path) == 1 then + vim.cmd("edit " .. file_path) + else + vim.notify("File not found: " .. link_text, vim.log.levels.WARN) + end + return + end + end + + -- Fallback to default gf behavior + vim.cmd("normal! gf") end function M.show_backlinks() - local current_path = vim.fn.expand("%:p") - local options = config.get_current_config() - local vault_path = vim.fn.expand(options.vault_path) - - -- Get relative path from vault root and remove .md extension - local relative_path = current_path:gsub("^" .. vim.pesc(vault_path) .. "/", ""):gsub("%.md$", "") - - if relative_path == "" then - vim.notify("No current file", vim.log.levels.WARN) - return - end - - local ok, fzf = pcall(require, "fzf-lua") - if not ok then - vim.notify("fzf-lua not available", vim.log.levels.ERROR) - return - end - - -- Find files that contain links to this note - local search_text = "[[" .. relative_path .. "]]" - - -- Get all markdown files first, then check each one - local all_files_cmd = "cd " .. vim.fn.shellescape(vault_path) .. " && find . -name '*.md' -type f -not -path '*/.*'" - local all_files = vim.fn.systemlist(all_files_cmd) - - -- Remove ./ prefix from paths - for i, file in ipairs(all_files) do - all_files[i] = file:gsub("^%./", "") - end - - local linked_files = {} - for _, file in ipairs(all_files) do - local full_path = vault_path .. "/" .. file - local file_content = vim.fn.readfile(full_path) - local content_str = table.concat(file_content, "\n") - - -- Simple string search for the exact link - if content_str:find(search_text, 1, true) then - table.insert(linked_files, file) - end - - -- Also check for links with display text (|) - local link_with_display = "%[%[" .. vim.pesc(relative_path) .. "|" - if content_str:find(link_with_display) then - table.insert(linked_files, file) - end - end - - if #linked_files == 0 then - vim.notify("No backlinks found for: " .. relative_path, vim.log.levels.INFO) - return - end - - fzf.fzf_exec(linked_files, { - prompt = "Backlinks> ", - cwd = vault_path, - previewer = "builtin", - actions = { - ["default"] = function(selected) - if selected and #selected > 0 then - local file_path = vim.fn.expand(options.vault_path .. "/" .. selected[1]) - vim.cmd("edit " .. vim.fn.fnameescape(file_path)) - end - end, - ["ctrl-l"] = function(selected) - if selected and #selected > 0 then - insert_link_at_cursor(selected[1]) - end - end, - }, - }) + local current_path = vim.fn.expand("%:p") + local options = config.get_current_config() + local vault_path = vim.fn.expand(options.vault_path) + + -- Get relative path from vault root and remove .md extension + local relative_path = current_path:gsub("^" .. vim.pesc(vault_path) .. "/", ""):gsub("%.md$", "") + + if relative_path == "" then + vim.notify("No current file", vim.log.levels.WARN) + return + end + + local ok, fzf = pcall(require, "fzf-lua") + if not ok then + vim.notify("fzf-lua not available", vim.log.levels.ERROR) + return + end + + -- Find files that contain links to this note + local search_text = "[[" .. relative_path .. "]]" + + -- Get all markdown files first, then check each one + local all_files_cmd = "cd " .. vim.fn.shellescape(vault_path) .. + " && find . -name '*.md' -type f -not -path '*/.*'" + local all_files = vim.fn.systemlist(all_files_cmd) + + -- Remove ./ prefix from paths + for i, file in ipairs(all_files) do + all_files[i] = file:gsub("^%./", "") + end + + local linked_files = {} + for _, file in ipairs(all_files) do + local full_path = vault_path .. "/" .. file + local file_content = vim.fn.readfile(full_path) + local content_str = table.concat(file_content, "\n") + + -- Simple string search for the exact link + if content_str:find(search_text, 1, true) then + table.insert(linked_files, file) + end + + -- Also check for links with display text (|) + local link_with_display = "%[%[" .. vim.pesc(relative_path) .. "|" + if content_str:find(link_with_display) then + table.insert(linked_files, file) + end + end + + if #linked_files == 0 then + vim.notify("No backlinks found for: " .. relative_path, vim.log.levels.INFO) + return + end + + fzf.fzf_exec(linked_files, { + prompt = "Backlinks> ", + cwd = vault_path, + previewer = "builtin", + actions = { + ["default"] = function(selected) + if selected and #selected > 0 then + local file_path = vim.fn.expand(options.vault_path .. "/" .. selected[1]) + vim.cmd("edit " .. vim.fn.fnameescape(file_path)) + end + end, + ["ctrl-l"] = function(selected) + if selected and #selected > 0 then + insert_link_at_cursor(selected[1]) + end + end, + }, + }) end function M.rename_note(new_name) - local current_path = vim.fn.expand("%:p") - local options = config.get_current_config() - local vault_path = vim.fn.expand(options.vault_path) - - -- Get current note info - local relative_path = current_path:gsub("^" .. vim.pesc(vault_path) .. "/", ""):gsub("%.md$", "") - local current_dir = vim.fn.expand("%:p:h") - - if relative_path == "" then - vim.notify("No current file", vim.log.levels.WARN) - return - end - - -- Validate new name - if not new_name or new_name == "" then - vim.notify("New name cannot be empty", vim.log.levels.ERROR) - return - end - - -- Ensure new_name doesn't have .md extension - new_name = new_name:gsub("%.md$", "") - - -- Check for whitespace-only names - if new_name:match("^%s*$") then - vim.notify("New name cannot be only whitespace", vim.log.levels.ERROR) - return - end - - -- Check for invalid filename characters (basic check) - if new_name:match("[/\\:*?\"<>|]") then - vim.notify("New name contains invalid characters", vim.log.levels.ERROR) - return - end - - -- Create new file path - local new_file_path = current_dir .. "/" .. new_name .. ".md" - - -- Check if target file already exists - if vim.fn.filereadable(new_file_path) == 1 then - vim.notify("File already exists: " .. new_name .. ".md", vim.log.levels.ERROR) - return - end - - -- Find all files that link to this note - local search_text = "[[" .. relative_path .. "]]" - local link_with_display = "%[%[" .. vim.pesc(relative_path) .. "|" - - -- Get all markdown files - local all_files_cmd = "cd " .. vim.fn.shellescape(vault_path) .. " && find . -name '*.md' -type f -not -path '*/.*'" - local all_files = vim.fn.systemlist(all_files_cmd) - - -- Remove ./ prefix from paths - for i, file in ipairs(all_files) do - all_files[i] = file:gsub("^%./", "") - end - - local files_to_update = {} - local update_count = 0 - - -- Find files with links to update - for _, file in ipairs(all_files) do - local full_path = vault_path .. "/" .. file - local file_content = vim.fn.readfile(full_path) - local content_str = table.concat(file_content, "\n") - local updated = false - - -- Check for exact link matches - if content_str:find(search_text, 1, true) then - updated = true - end - - -- Check for display text links - if content_str:find(link_with_display) then - updated = true - end - - if updated then - table.insert(files_to_update, {file = file, path = full_path}) - end - end - - -- Get new relative path for links - local current_dir_relative = current_dir:gsub("^" .. vim.pesc(vault_path) .. "/?", "") - local new_relative_path - if current_dir_relative == "" then - -- File is in vault root - new_relative_path = new_name - else - -- File is in subdirectory - new_relative_path = current_dir_relative .. "/" .. new_name - end - - -- Ask for confirmation - local message = "Rename '" .. relative_path .. "' to '" .. new_name .. "'" - if #files_to_update > 0 then - message = message .. " and update " .. #files_to_update .. " files with links?" - else - message = message .. "?" - end - - local confirm = vim.fn.confirm(message, "&Yes\n&No", 2) - if confirm ~= 1 then - return - end - - -- Update all linking files - for _, file_info in ipairs(files_to_update) do - local file_content = vim.fn.readfile(file_info.path) - local content_str = table.concat(file_content, "\n") - - -- Replace exact links using simple string replacement - local old_link = "[[" .. relative_path .. "]]" - local new_link = "[[" .. new_relative_path .. "]]" - content_str = content_str:gsub(vim.pesc(old_link), new_link) - - -- Replace display text links - local old_display_pattern = "(%[%[)" .. vim.pesc(relative_path) .. "(%|[^%]]*%]%])" - local new_display_replacement = "%1" .. new_relative_path .. "%2" - content_str = content_str:gsub(old_display_pattern, new_display_replacement) - - -- Write updated content - local updated_lines = vim.split(content_str, "\n") - vim.fn.writefile(updated_lines, file_info.path) - update_count = update_count + 1 - end - - -- Rename the actual file - local success = vim.fn.rename(current_path, new_file_path) - if success == 0 then - vim.cmd("edit " .. vim.fn.fnameescape(new_file_path)) - if update_count > 0 then - vim.notify("Renamed note and updated " .. update_count .. " files", vim.log.levels.INFO) - else - vim.notify("Renamed note (no links to update)", vim.log.levels.INFO) - end - else - vim.notify("Failed to rename file", vim.log.levels.ERROR) - end + local current_path = vim.fn.expand("%:p") + local options = config.get_current_config() + local vault_path = vim.fn.expand(options.vault_path) + + -- Get current note info + local relative_path = current_path:gsub("^" .. vim.pesc(vault_path) .. "/", ""):gsub("%.md$", "") + local current_dir = vim.fn.expand("%:p:h") + + if relative_path == "" then + vim.notify("No current file", vim.log.levels.WARN) + return + end + + -- Validate new name + if not new_name or new_name == "" then + vim.notify("New name cannot be empty", vim.log.levels.ERROR) + return + end + + -- Ensure new_name doesn't have .md extension + new_name = new_name:gsub("%.md$", "") + + -- Check for whitespace-only names + if new_name:match("^%s*$") then + vim.notify("New name cannot be only whitespace", vim.log.levels.ERROR) + return + end + + -- Check for invalid filename characters (basic check) + if new_name:match('[/\\:*?"<>|]') then + vim.notify("New name contains invalid characters", vim.log.levels.ERROR) + return + end + + -- Create new file path + local new_file_path = current_dir .. "/" .. new_name .. ".md" + + -- Check if target file already exists + if vim.fn.filereadable(new_file_path) == 1 then + vim.notify("File already exists: " .. new_name .. ".md", vim.log.levels.ERROR) + return + end + + -- Find all files that link to this note + local search_text = "[[" .. relative_path .. "]]" + local link_with_display = "%[%[" .. vim.pesc(relative_path) .. "|" + + -- Get all markdown files + local all_files_cmd = "cd " .. vim.fn.shellescape(vault_path) .. + " && find . -name '*.md' -type f -not -path '*/.*'" + local all_files = vim.fn.systemlist(all_files_cmd) + + -- Remove ./ prefix from paths + for i, file in ipairs(all_files) do + all_files[i] = file:gsub("^%./", "") + end + + local files_to_update = {} + local update_count = 0 + + -- Find files with links to update + for _, file in ipairs(all_files) do + local full_path = vault_path .. "/" .. file + local file_content = vim.fn.readfile(full_path) + local content_str = table.concat(file_content, "\n") + local updated = false + + -- Check for exact link matches + if content_str:find(search_text, 1, true) then + updated = true + end + + -- Check for display text links + if content_str:find(link_with_display) then + updated = true + end + + if updated then + table.insert(files_to_update, { file = file, path = full_path }) + end + end + + -- Get new relative path for links + local current_dir_relative = current_dir:gsub("^" .. vim.pesc(vault_path) .. "/?", "") + local new_relative_path + if current_dir_relative == "" then + -- File is in vault root + new_relative_path = new_name + else + -- File is in subdirectory + new_relative_path = current_dir_relative .. "/" .. new_name + end + + -- Ask for confirmation + local message = "Rename '" .. relative_path .. "' to '" .. new_name .. "'" + if #files_to_update > 0 then + message = message .. " and update " .. #files_to_update .. " files with links?" + else + message = message .. "?" + end + + local confirm = vim.fn.confirm(message, "&Yes\n&No", 2) + if confirm ~= 1 then + return + end + + -- Update all linking files + for _, file_info in ipairs(files_to_update) do + local file_content = vim.fn.readfile(file_info.path) + local content_str = table.concat(file_content, "\n") + + -- Replace exact links using simple string replacement + local old_link = "[[" .. relative_path .. "]]" + local new_link = "[[" .. new_relative_path .. "]]" + content_str = content_str:gsub(vim.pesc(old_link), new_link) + + -- Replace display text links + local old_display_pattern = "(%[%[)" .. vim.pesc(relative_path) .. "(%|[^%]]*%]%])" + local new_display_replacement = "%1" .. new_relative_path .. "%2" + content_str = content_str:gsub(old_display_pattern, new_display_replacement) + + -- Write updated content + local updated_lines = vim.split(content_str, "\n") + vim.fn.writefile(updated_lines, file_info.path) + update_count = update_count + 1 + end + + -- Rename the actual file + local success = vim.fn.rename(current_path, new_file_path) + if success == 0 then + vim.cmd("edit " .. vim.fn.fnameescape(new_file_path)) + if update_count > 0 then + vim.notify("Renamed note and updated " .. update_count .. " files", vim.log.levels.INFO) + else + vim.notify("Renamed note (no links to update)", vim.log.levels.INFO) + end + else + vim.notify("Failed to rename file", vim.log.levels.ERROR) + end end -return M \ No newline at end of file +return M diff --git a/lua/markdown-notes/notes.lua b/lua/markdown-notes/notes.lua index a159668..6380ef7 100644 --- a/lua/markdown-notes/notes.lua +++ b/lua/markdown-notes/notes.lua @@ -4,240 +4,243 @@ local templates = require("markdown-notes.templates") local M = {} function M.create_new_note() - local title = vim.fn.input("Note title (optional): ") - local options = config.get_current_config() - - -- Generate timestamp-based filename - local timestamp = tostring(os.time()) - local filename = timestamp - if title ~= "" then - local clean_title = title:gsub(" ", "-"):gsub("[^A-Za-z0-9-]", ""):lower() - filename = timestamp .. "-" .. clean_title - end - - local file_path = vim.fn.expand(options.vault_path .. "/" .. options.notes_subdir .. "/" .. filename .. ".md") - - -- Create directory if needed - local dir = vim.fn.fnamemodify(file_path, ":h") - if vim.fn.isdirectory(dir) == 0 then - vim.fn.mkdir(dir, "p") - end - - vim.cmd("edit " .. file_path) - - local display_title = title ~= "" and title or "Untitled" - - -- Use default template if configured, otherwise use basic frontmatter - if options.default_template then - local custom_vars = { - title = display_title, - note_title = display_title, - } - if not templates.apply_template_to_file(options.default_template, custom_vars) then - -- Fall back to basic frontmatter if template fails - local frontmatter = { - "---", - "title: " .. display_title, - "date: " .. os.date("%Y-%m-%d"), - "tags: []", - "---", - "", - "# " .. display_title, - "", - } - vim.api.nvim_buf_set_lines(0, 0, 0, false, frontmatter) - end - else - -- Insert basic frontmatter - local frontmatter = { - "---", - "title: " .. display_title, - "date: " .. os.date("%Y-%m-%d"), - "tags: []", - "---", - "", - "# " .. display_title, - "", - } - vim.api.nvim_buf_set_lines(0, 0, 0, false, frontmatter) - end + local title = vim.fn.input("Note title (optional): ") + local options = config.get_current_config() + + -- Generate timestamp-based filename + local timestamp = tostring(os.time()) + local filename = timestamp + if title ~= "" then + local clean_title = title:gsub(" ", "-"):gsub("[^A-Za-z0-9-]", ""):lower() + filename = timestamp .. "-" .. clean_title + end + + local file_path = vim.fn.expand(options.vault_path .. "/" .. + options.notes_subdir .. "/" .. filename .. ".md") + + -- Create directory if needed + local dir = vim.fn.fnamemodify(file_path, ":h") + if vim.fn.isdirectory(dir) == 0 then + vim.fn.mkdir(dir, "p") + end + + vim.cmd("edit " .. file_path) + + local display_title = title ~= "" and title or "Untitled" + + -- Use default template if configured, otherwise use basic frontmatter + if options.default_template then + local custom_vars = { + title = display_title, + note_title = display_title, + } + if not templates.apply_template_to_file(options.default_template, custom_vars) then + -- Fall back to basic frontmatter if template fails + local frontmatter = { + "---", + "title: " .. display_title, + "date: " .. os.date("%Y-%m-%d"), + "tags: []", + "---", + "", + "# " .. display_title, + "", + } + vim.api.nvim_buf_set_lines(0, 0, 0, false, frontmatter) + end + else + -- Insert basic frontmatter + local frontmatter = { + "---", + "title: " .. display_title, + "date: " .. os.date("%Y-%m-%d"), + "tags: []", + "---", + "", + "# " .. display_title, + "", + } + vim.api.nvim_buf_set_lines(0, 0, 0, false, frontmatter) + end end function M.create_from_template() - local title = vim.fn.input("Note title (optional): ") - local options = config.get_current_config() - - -- Generate timestamp-based filename - local timestamp = tostring(os.time()) - local filename = timestamp - if title ~= "" then - local clean_title = title:gsub(" ", "-"):gsub("[^A-Za-z0-9-]", ""):lower() - filename = timestamp .. "-" .. clean_title - end - - local file_path = vim.fn.expand(options.vault_path .. "/" .. options.notes_subdir .. "/" .. filename .. ".md") - - -- Create directory if needed - local dir = vim.fn.fnamemodify(file_path, ":h") - if vim.fn.isdirectory(dir) == 0 then - vim.fn.mkdir(dir, "p") - end - - vim.cmd("edit " .. file_path) - - local display_title = title ~= "" and title or "Untitled" - - -- Let user pick a template - local ok, fzf = pcall(require, "fzf-lua") - if not ok then - vim.notify("fzf-lua not available", vim.log.levels.ERROR) - return - end - - fzf.files({ - prompt = "Select Template> ", - cwd = vim.fn.expand(options.templates_path), - cmd = "find . -name '*.md' -type f -not -path '*/.*' -printf '%P\\n'", - file_icons = false, - path_shorten = false, - formatter = nil, - previewer = "builtin", - actions = { - ["default"] = function(selected) - if selected and #selected > 0 then - local template_name = vim.fn.fnamemodify(selected[1], ":t:r") - local custom_vars = { - title = display_title, - note_title = display_title, - } - templates.apply_template_to_file(template_name, custom_vars) - end - end, - }, - }) + local title = vim.fn.input("Note title (optional): ") + local options = config.get_current_config() + + -- Generate timestamp-based filename + local timestamp = tostring(os.time()) + local filename = timestamp + if title ~= "" then + local clean_title = title:gsub(" ", "-"):gsub("[^A-Za-z0-9-]", ""):lower() + filename = timestamp .. "-" .. clean_title + end + + local file_path = vim.fn.expand(options.vault_path .. "/" .. + options.notes_subdir .. "/" .. filename .. ".md") + + -- Create directory if needed + local dir = vim.fn.fnamemodify(file_path, ":h") + if vim.fn.isdirectory(dir) == 0 then + vim.fn.mkdir(dir, "p") + end + + vim.cmd("edit " .. file_path) + + local display_title = title ~= "" and title or "Untitled" + + -- Let user pick a template + local ok, fzf = pcall(require, "fzf-lua") + if not ok then + vim.notify("fzf-lua not available", vim.log.levels.ERROR) + return + end + + fzf.files({ + prompt = "Select Template> ", + cwd = vim.fn.expand(options.templates_path), + cmd = "find . -name '*.md' -type f -not -path '*/.*' -printf '%P\\n'", + file_icons = false, + path_shorten = false, + formatter = nil, + previewer = "builtin", + actions = { + ["default"] = function(selected) + if selected and #selected > 0 then + local template_name = vim.fn.fnamemodify(selected[1], ":t:r") + local custom_vars = { + title = display_title, + note_title = display_title, + } + templates.apply_template_to_file(template_name, custom_vars) + end + end, + }, + }) end function M.find_notes() - local ok, fzf = pcall(require, "fzf-lua") - if not ok then - vim.notify("fzf-lua not available", vim.log.levels.ERROR) - return - end - local options = config.get_current_config() - - fzf.files({ - prompt = "Find Notes> ", - cwd = vim.fn.expand(options.vault_path), - cmd = "find . -name '*.md' -type f -not -path '*/.*' -printf '%P\\n'", - previewer = "builtin", - }) + local ok, fzf = pcall(require, "fzf-lua") + if not ok then + vim.notify("fzf-lua not available", vim.log.levels.ERROR) + return + end + local options = config.get_current_config() + + fzf.files({ + prompt = "Find Notes> ", + cwd = vim.fn.expand(options.vault_path), + cmd = "find . -name '*.md' -type f -not -path '*/.*' -printf '%P\\n'", + previewer = "builtin", + }) end function M.search_notes() - local ok, fzf = pcall(require, "fzf-lua") - if not ok then - vim.notify("fzf-lua not available", vim.log.levels.ERROR) - return - end - local options = config.get_current_config() - - fzf.grep({ - prompt = "Search Notes> ", - cwd = vim.fn.expand(options.vault_path), - previewer = "builtin", - }) + local ok, fzf = pcall(require, "fzf-lua") + if not ok then + vim.notify("fzf-lua not available", vim.log.levels.ERROR) + return + end + local options = config.get_current_config() + + fzf.grep({ + prompt = "Search Notes> ", + cwd = vim.fn.expand(options.vault_path), + previewer = "builtin", + }) end function M.search_tags() - local ok, fzf = pcall(require, "fzf-lua") - if not ok then - vim.notify("fzf-lua not available", vim.log.levels.ERROR) - return - end - local options = config.get_current_config() - - local vault_path = vim.fn.expand(options.vault_path) - - -- Get all markdown files - local find_cmd = "find " .. vim.fn.shellescape(vault_path) .. " -name '*.md' -type f -not -path '*/.*'" - local all_files = vim.fn.systemlist(find_cmd) - - -- Extract all tags from frontmatter - local tags = {} - for _, file in ipairs(all_files) do - local content = vim.fn.readfile(file) - local in_frontmatter = false - - for _, line in ipairs(content) do - if line == "---" then - in_frontmatter = not in_frontmatter - elseif in_frontmatter and line:match("^tags:") then - -- Extract tags from YAML array format: tags: [tag1, tag2, tag3] - local tags_line = line:gsub("^tags:%s*", "") - if tags_line:match("^%[.*%]$") then - -- Remove brackets and split by comma - local tags_content = tags_line:gsub("^%[", ""):gsub("%]$", "") - for tag in tags_content:gmatch("[^,]+") do - local clean_tag = tag:gsub("%s", ""):gsub('"', ''):gsub("'", "") - if clean_tag ~= "" then - if not tags[clean_tag] then - tags[clean_tag] = {} - end - table.insert(tags[clean_tag], file) - end - end - end - break -- Only process first tags line in frontmatter - end - end - end - - -- Convert tags table to list for fzf - local tag_list = {} - for tag, files in pairs(tags) do - table.insert(tag_list, tag .. " (" .. #files .. " files)") - end - - if #tag_list == 0 then - vim.notify("No tags found in frontmatter", vim.log.levels.INFO) - return - end - - fzf.fzf_exec(tag_list, { - prompt = "Search Tags> ", - actions = { - ["default"] = function(selected) - if selected and #selected > 0 then - local tag = selected[1]:match("^([^%(]+)") - tag = tag:gsub("%s+$", "") -- trim trailing whitespace - local files_with_tag = tags[tag] - - if files_with_tag and #files_with_tag > 0 then - -- Show files with this tag - local relative_files = {} - for _, file in ipairs(files_with_tag) do - local relative = file:gsub("^" .. vim.pesc(vault_path) .. "/", "") - table.insert(relative_files, relative) - end - - fzf.fzf_exec(relative_files, { - prompt = "Files with tag '" .. tag .. "'> ", - cwd = vault_path, - previewer = "builtin", - actions = { - ["default"] = function(file_selected) - if file_selected and #file_selected > 0 then - local file_path = vim.fn.expand(vault_path .. "/" .. file_selected[1]) - vim.cmd("edit " .. vim.fn.fnameescape(file_path)) - end - end, - }, - }) - end - end - end, - }, - }) + local ok, fzf = pcall(require, "fzf-lua") + if not ok then + vim.notify("fzf-lua not available", vim.log.levels.ERROR) + return + end + local options = config.get_current_config() + + local vault_path = vim.fn.expand(options.vault_path) + + -- Get all markdown files + local find_cmd = "find " .. vim.fn.shellescape(vault_path) .. + " -name '*.md' -type f -not -path '*/.*'" + local all_files = vim.fn.systemlist(find_cmd) + + -- Extract all tags from frontmatter + local tags = {} + for _, file in ipairs(all_files) do + local content = vim.fn.readfile(file) + local in_frontmatter = false + + for _, line in ipairs(content) do + if line == "---" then + in_frontmatter = not in_frontmatter + elseif in_frontmatter and line:match("^tags:") then + -- Extract tags from YAML array format: tags: [tag1, tag2, tag3] + local tags_line = line:gsub("^tags:%s*", "") + if tags_line:match("^%[.*%]$") then + -- Remove brackets and split by comma + local tags_content = tags_line:gsub("^%[", ""):gsub("%]$", "") + for tag in tags_content:gmatch("[^,]+") do + local clean_tag = tag:gsub("%s", ""):gsub('"', ""):gsub("'", "") + if clean_tag ~= "" then + if not tags[clean_tag] then + tags[clean_tag] = {} + end + table.insert(tags[clean_tag], file) + end + end + end + break -- Only process first tags line in frontmatter + end + end + end + + -- Convert tags table to list for fzf + local tag_list = {} + for tag, files in pairs(tags) do + table.insert(tag_list, tag .. " (" .. #files .. " files)") + end + + if #tag_list == 0 then + vim.notify("No tags found in frontmatter", vim.log.levels.INFO) + return + end + + fzf.fzf_exec(tag_list, { + prompt = "Search Tags> ", + actions = { + ["default"] = function(selected) + if selected and #selected > 0 then + local tag = selected[1]:match("^([^%(]+)") + tag = tag:gsub("%s+$", "") -- trim trailing whitespace + local files_with_tag = tags[tag] + + if files_with_tag and #files_with_tag > 0 then + -- Show files with this tag + local relative_files = {} + for _, file in ipairs(files_with_tag) do + local relative = file:gsub("^" .. vim.pesc(vault_path) .. "/", "") + table.insert(relative_files, relative) + end + + fzf.fzf_exec(relative_files, { + prompt = "Files with tag '" .. tag .. "'> ", + cwd = vault_path, + previewer = "builtin", + actions = { + ["default"] = function(file_selected) + if file_selected and #file_selected > 0 then + local file_path = vim.fn.expand(vault_path .. "/" .. file_selected[1]) + vim.cmd("edit " .. vim.fn.fnameescape(file_path)) + end + end, + }, + }) + end + end + end, + }, + }) end -return M \ No newline at end of file +return M diff --git a/lua/markdown-notes/templates.lua b/lua/markdown-notes/templates.lua index f2bce74..7fdd704 100644 --- a/lua/markdown-notes/templates.lua +++ b/lua/markdown-notes/templates.lua @@ -3,103 +3,103 @@ local config = require("markdown-notes.config") local M = {} function M.substitute_template_vars(content, custom_vars) - local options = config.get_current_config() - -- Fallback to config.options if no workspace config available - if not options then - options = config.options - end - local template_vars = options.template_vars or config.defaults.template_vars - local vars = {} - - -- Manual merge since vim.tbl_extend might not be available in tests - for k, v in pairs(template_vars) do - vars[k] = v - end - - if custom_vars then - for k, v in pairs(custom_vars) do - vars[k] = v - end - end - - for i, line in ipairs(content) do - for var_name, var_func in pairs(vars) do - local pattern = "{{" .. var_name .. "}}" - local replacement = type(var_func) == "function" and var_func() or var_func - content[i] = string.gsub(content[i], pattern, replacement) - end - end - - return content + local options = config.get_current_config() + -- Fallback to config.options if no workspace config available + if not options then + options = config.options + end + local template_vars = options.template_vars or config.defaults.template_vars + local vars = {} + + -- Manual merge since vim.tbl_extend might not be available in tests + for k, v in pairs(template_vars) do + vars[k] = v + end + + if custom_vars then + for k, v in pairs(custom_vars) do + vars[k] = v + end + end + + for i, line in ipairs(content) do + for var_name, var_func in pairs(vars) do + local pattern = "{{" .. var_name .. "}}" + local replacement = type(var_func) == "function" and var_func() or var_func + content[i] = string.gsub(content[i], pattern, replacement) + end + end + + return content end function M.insert_template(template_name, custom_vars) - local options = config.get_current_config() - if not options then - options = config.options - end - local template_path = vim.fn.expand(options.templates_path .. "/" .. template_name .. ".md") - - if vim.fn.filereadable(template_path) == 0 then - vim.notify("Template not found: " .. template_path, vim.log.levels.ERROR) - return - end - - local template_content = vim.fn.readfile(template_path) - template_content = M.substitute_template_vars(template_content, custom_vars) - - local cursor_pos = vim.api.nvim_win_get_cursor(0) - vim.api.nvim_buf_set_lines(0, cursor_pos[1] - 1, cursor_pos[1] - 1, false, template_content) + local options = config.get_current_config() + if not options then + options = config.options + end + local template_path = vim.fn.expand(options.templates_path .. "/" .. template_name .. ".md") + + if vim.fn.filereadable(template_path) == 0 then + vim.notify("Template not found: " .. template_path, vim.log.levels.ERROR) + return + end + + local template_content = vim.fn.readfile(template_path) + template_content = M.substitute_template_vars(template_content, custom_vars) + + local cursor_pos = vim.api.nvim_win_get_cursor(0) + vim.api.nvim_buf_set_lines(0, cursor_pos[1] - 1, cursor_pos[1] - 1, false, template_content) end function M.apply_template_to_file(template_name, custom_vars) - local options = config.get_current_config() - if not options then - options = config.options - end - local template_path = vim.fn.expand(options.templates_path .. "/" .. template_name .. ".md") - - if vim.fn.filereadable(template_path) == 0 then - vim.notify("Template not found: " .. template_path, vim.log.levels.ERROR) - return false - end - - local template_content = vim.fn.readfile(template_path) - template_content = M.substitute_template_vars(template_content, custom_vars) - - -- Replace entire buffer content - vim.api.nvim_buf_set_lines(0, 0, -1, false, template_content) - return true + local options = config.get_current_config() + if not options then + options = config.options + end + local template_path = vim.fn.expand(options.templates_path .. "/" .. template_name .. ".md") + + if vim.fn.filereadable(template_path) == 0 then + vim.notify("Template not found: " .. template_path, vim.log.levels.ERROR) + return false + end + + local template_content = vim.fn.readfile(template_path) + template_content = M.substitute_template_vars(template_content, custom_vars) + + -- Replace entire buffer content + vim.api.nvim_buf_set_lines(0, 0, -1, false, template_content) + return true end function M.pick_template() - local ok, fzf = pcall(require, "fzf-lua") - if not ok then - vim.notify("fzf-lua not available", vim.log.levels.ERROR) - return - end - local options = config.get_current_config() - if not options then - options = config.options - end - - fzf.files({ - prompt = "Select Template> ", - cwd = vim.fn.expand(options.templates_path), - cmd = "find . -name '*.md' -type f -not -path '*/.*'", - file_icons = false, - path_shorten = false, - formatter = nil, - previewer = "builtin", - actions = { - ["default"] = function(selected) - if selected and #selected > 0 then - local template_name = vim.fn.fnamemodify(selected[1], ":t:r") - M.insert_template(template_name) - end - end, - }, - }) + local ok, fzf = pcall(require, "fzf-lua") + if not ok then + vim.notify("fzf-lua not available", vim.log.levels.ERROR) + return + end + local options = config.get_current_config() + if not options then + options = config.options + end + + fzf.files({ + prompt = "Select Template> ", + cwd = vim.fn.expand(options.templates_path), + cmd = "find . -name '*.md' -type f -not -path '*/.*'", + file_icons = false, + path_shorten = false, + formatter = nil, + previewer = "builtin", + actions = { + ["default"] = function(selected) + if selected and #selected > 0 then + local template_name = vim.fn.fnamemodify(selected[1], ":t:r") + M.insert_template(template_name) + end + end, + }, + }) end -return M \ No newline at end of file +return M diff --git a/lua/markdown-notes/workspace.lua b/lua/markdown-notes/workspace.lua index fe91428..ebfec60 100644 --- a/lua/markdown-notes/workspace.lua +++ b/lua/markdown-notes/workspace.lua @@ -2,94 +2,97 @@ local config = require("markdown-notes.config") local M = {} - function M.show_current_workspace() - local options, current_workspace = config.get_current_config() - local default_workspace = config.get_default_workspace() - - if current_workspace then - local default_indicator = (current_workspace == default_workspace) and " (default)" or "" - vim.notify("Current workspace: " .. current_workspace .. default_indicator .. " (" .. options.vault_path .. ")", vim.log.levels.INFO) - else - vim.notify("Using fallback configuration (no workspace detected)", vim.log.levels.INFO) - end + local options, current_workspace = config.get_current_config() + local default_workspace = config.get_default_workspace() + + if current_workspace then + local default_indicator = (current_workspace == default_workspace) and " (default)" or "" + vim.notify( + "Current workspace: " .. current_workspace .. default_indicator .. + " (" .. options.vault_path .. ")", + vim.log.levels.INFO + ) + else + vim.notify("Using fallback configuration (no workspace detected)", vim.log.levels.INFO) + end end function M.set_default_workspace(workspace_name) - if config.set_default_workspace(workspace_name) then - vim.notify("Set default workspace to: " .. workspace_name, vim.log.levels.INFO) - end + if config.set_default_workspace(workspace_name) then + vim.notify("Set default workspace to: " .. workspace_name, vim.log.levels.INFO) + end end function M.set_active_workspace(workspace_name) - if config.set_active_workspace(workspace_name) then - local workspace = config.get_workspaces()[workspace_name] - local vault_path = vim.fn.expand(workspace.vault_path) - vim.cmd("cd " .. vim.fn.fnameescape(vault_path)) - vim.notify("Switched to workspace: " .. workspace_name, vim.log.levels.INFO) - end + if config.set_active_workspace(workspace_name) then + local workspace = config.get_workspaces()[workspace_name] + local vault_path = vim.fn.expand(workspace.vault_path) + vim.cmd("cd " .. vim.fn.fnameescape(vault_path)) + vim.notify("Switched to workspace: " .. workspace_name, vim.log.levels.INFO) + end end function M.show_default_workspace() - local default_workspace = config.get_default_workspace() - if default_workspace then - vim.notify("Default workspace: " .. default_workspace, vim.log.levels.INFO) - else - vim.notify("No default workspace set", vim.log.levels.INFO) - end + local default_workspace = config.get_default_workspace() + if default_workspace then + vim.notify("Default workspace: " .. default_workspace, vim.log.levels.INFO) + else + vim.notify("No default workspace set", vim.log.levels.INFO) + end end function M.switch_to_workspace(workspace_name) - local workspaces = config.get_workspaces() - - if not workspaces[workspace_name] then - vim.notify("Workspace '" .. workspace_name .. "' not found", vim.log.levels.ERROR) - return - end - - local workspace = workspaces[workspace_name] - local vault_path = vim.fn.expand(workspace.vault_path) - - if vim.fn.isdirectory(vault_path) == 0 then - vim.notify("Workspace directory does not exist: " .. vault_path, vim.log.levels.ERROR) - return - end - - -- Set as active workspace and change directory - M.set_active_workspace(workspace_name) + local workspaces = config.get_workspaces() + + if not workspaces[workspace_name] then + vim.notify("Workspace '" .. workspace_name .. "' not found", vim.log.levels.ERROR) + return + end + + local workspace = workspaces[workspace_name] + local vault_path = vim.fn.expand(workspace.vault_path) + + if vim.fn.isdirectory(vault_path) == 0 then + vim.notify("Workspace directory does not exist: " .. vault_path, vim.log.levels.ERROR) + return + end + + -- Set as active workspace and change directory + M.set_active_workspace(workspace_name) end function M.pick_workspace() - local workspaces = config.get_workspaces() - - if vim.tbl_isempty(workspaces) then - vim.notify("No workspaces configured", vim.log.levels.INFO) - return - end - - local ok, fzf = pcall(require, "fzf-lua") - if not ok then - vim.notify("fzf-lua not available", vim.log.levels.ERROR) - return - end - - local workspace_list = {} - for name, workspace in pairs(workspaces) do - table.insert(workspace_list, name .. " - " .. workspace.vault_path) - end - - fzf.fzf_exec(workspace_list, { - prompt = "Select Workspace> ", - actions = { - ["default"] = function(selected) - if selected and #selected > 0 then - local workspace_name = selected[1]:match("^([^%-]+)") - workspace_name = workspace_name:gsub("%s+$", "") -- trim trailing whitespace - M.switch_to_workspace(workspace_name) - end - end, - }, - }) + local workspaces = config.get_workspaces() + + if vim.tbl_isempty(workspaces) then + vim.notify("No workspaces configured", vim.log.levels.INFO) + return + end + + local ok, fzf = pcall(require, "fzf-lua") + if not ok then + vim.notify("fzf-lua not available", vim.log.levels.ERROR) + return + end + + local workspace_list = {} + for name, workspace in pairs(workspaces) do + table.insert(workspace_list, name .. " - " .. workspace.vault_path) + end + + fzf.fzf_exec(workspace_list, { + prompt = "Select Workspace> ", + actions = { + ["default"] = function(selected) + if selected and #selected > 0 then + local workspace_name = selected[1]:match("^([^%-]+)") + workspace_name = workspace_name:gsub("%s+$", "") -- trim trailing whitespace + M.switch_to_workspace(workspace_name) + end + end, + }, + }) end -return M \ No newline at end of file +return M diff --git a/plugin/markdown-notes.lua b/plugin/markdown-notes.lua index 874bf82..e25c89e 100644 --- a/plugin/markdown-notes.lua +++ b/plugin/markdown-notes.lua @@ -1,6 +1,6 @@ if vim.g.loaded_markdown_notes == 1 then - return + return end vim.g.loaded_markdown_notes = 1 --- Plugin will be set up via require('markdown-notes').setup() in user config \ No newline at end of file +-- Plugin will be set up via require('markdown-notes').setup() in user config diff --git a/test-setup.sh b/test-setup.sh new file mode 100755 index 0000000..ba4cfaf --- /dev/null +++ b/test-setup.sh @@ -0,0 +1,281 @@ +#!/bin/bash + +# markdown-notes.nvim Test Setup Script +# Creates a clean testing environment for the plugin + +set -e + +echo "🧪 Setting up markdown-notes.nvim test environment..." + +# Configuration +TEST_DIR="$HOME/test-markdown-notes" +CONFIG_FILE="$TEST_DIR/test-config.lua" + +# Clean up existing test directory +if [ -d "$TEST_DIR" ]; then + echo "🧹 Cleaning up existing test directory..." + rm -rf "$TEST_DIR" +fi + +# Create test directory structure +echo "📁 Creating test directory structure..." +mkdir -p "$TEST_DIR"/{vault/{templates,daily,notes},config} + +# Create test templates +echo "📝 Creating test templates..." + +# Basic template +cat > "$TEST_DIR/vault/templates/basic.md" << 'EOF' +--- +title: {{title}} +date: {{date}} +tags: [] +--- + +# {{title}} + +Created: {{datetime}} + +## Notes + +EOF + +# Daily template +cat > "$TEST_DIR/vault/templates/Daily.md" << 'EOF' +--- +title: Daily Note - {{date}} +date: {{date}} +tags: [daily] +--- + +# {{date}} - Daily Notes + +## 🎯 Today's Goals +- [ ] + +## 📝 Notes + +## 🔄 Tomorrow's Prep +- [ ] + +EOF + +# Meeting template +cat > "$TEST_DIR/vault/templates/meeting.md" << 'EOF' +--- +title: {{title}} +date: {{date}} +tags: [meeting] +attendees: [] +--- + +# {{title}} + +**Date:** {{datetime}} +**Attendees:** + +## Agenda + +## Notes + +## Action Items +- [ ] + +EOF + +# Project template +cat > "$TEST_DIR/vault/templates/project.md" << 'EOF' +--- +title: {{title}} +date: {{date}} +tags: [project] +status: planning +--- + +# {{title}} + +**Created:** {{datetime}} + +## Overview + +## Goals + +## Tasks +- [ ] + +## Resources + +EOF + +# Create minimal test configuration +echo "⚙️ Creating test configuration..." +cat > "$CONFIG_FILE" << EOF +-- Test configuration for markdown-notes.nvim +-- Run with: nvim -u $CONFIG_FILE + +-- Minimal vim setup +vim.cmd('set runtimepath^=~/.vim runtimepath+=~/.vim/after') +vim.cmd('let &packpath = &runtimepath') + +-- Add current plugin directory to runtime path +vim.opt.rtp:prepend('$(pwd)') + +-- Try to add fzf-lua from common locations +local fzf_paths = { + vim.fn.expand('~/.local/share/nvim/lazy/fzf-lua'), + vim.fn.expand('~/.local/share/nvim/site/pack/*/start/fzf-lua'), + vim.fn.expand('~/.config/nvim/pack/*/start/fzf-lua'), +} + +for _, path in ipairs(fzf_paths) do + if vim.fn.isdirectory(path) == 1 then + vim.opt.rtp:prepend(path) + print('📦 Found fzf-lua at: ' .. path) + break + end +end + +-- Set leader key +vim.g.mapleader = ' ' + +-- Basic settings for testing +vim.opt.number = true +vim.opt.relativenumber = true +vim.opt.wrap = false + +-- Setup markdown-notes +local ok, markdown_notes = pcall(require, 'markdown-notes') +if not ok then + print('❌ Failed to load markdown-notes: ' .. tostring(markdown_notes)) + return +end + +markdown_notes.setup({ + vault_path = '$TEST_DIR/vault', + templates_path = '$TEST_DIR/vault/templates', + dailies_path = '$TEST_DIR/vault/daily', + notes_subdir = 'notes', + default_template = 'basic', +}) + +print('✅ markdown-notes.nvim loaded successfully!') +print('📁 Test vault: $TEST_DIR/vault') +print('') +print('🧪 Test Commands:') +print(' nd - Create daily note') +print(' nn - Create new note') +print(' nc - Create from template') +print(' nf - Find notes') +print(' ns - Search notes') +print(' np - Insert template') +print(' :help markdown-notes - View documentation') +print('') +print('📂 Available templates:') +print(' - basic.md') +print(' - Daily.md') +print(' - meeting.md') +print(' - project.md') +print('') +print('🔧 To inspect config: :lua print(vim.inspect(require("markdown-notes.config").options))') +EOF + +# Create some sample notes for testing +echo "📄 Creating sample notes..." + +cat > "$TEST_DIR/vault/notes/welcome.md" << 'EOF' +--- +title: Welcome to Test Notes +date: 2025-01-06 +tags: [welcome, test] +--- + +# Welcome to Test Notes + +This is a sample note for testing the markdown-notes.nvim plugin. + +## Features to Test + +- [[daily-workflow]] - Link to daily workflow +- [[project-management]] - Link to project management +- Create new notes with templates +- Search functionality + +## Sample Links + +Try following these links with `gf`: +- [[welcome]] (this note) +- [[nonexistent-note]] (will create new note) + +EOF + +cat > "$TEST_DIR/vault/notes/daily-workflow.md" << 'EOF' +--- +title: Daily Workflow +date: 2025-01-06 +tags: [workflow, daily] +--- + +# Daily Workflow + +This note demonstrates the daily workflow with markdown-notes.nvim. + +## Morning Routine +1. Open daily note with `nd` +2. Review yesterday's note with `ny` +3. Plan today's tasks + +## Evening Review +1. Update daily note +2. Plan tomorrow with `nt` + +This note links back to [[welcome]]. + +EOF + +# Create launch script +echo "🚀 Creating launch script..." +cat > "$TEST_DIR/launch-test.sh" << EOF +#!/bin/bash +echo "🧪 Launching markdown-notes.nvim test environment..." +echo "📁 Test vault: $TEST_DIR/vault" +echo "" +cd '$TEST_DIR/vault' +nvim -u '$CONFIG_FILE' +EOF + +chmod +x "$TEST_DIR/launch-test.sh" + +# Create cleanup script +cat > "$TEST_DIR/cleanup.sh" << EOF +#!/bin/bash +echo "🧹 Cleaning up test environment..." +rm -rf '$TEST_DIR' +echo "✅ Test environment cleaned up!" +EOF + +chmod +x "$TEST_DIR/cleanup.sh" + +echo "" +echo "✅ Test environment setup complete!" +echo "" +echo "🚀 To start testing:" +echo " $TEST_DIR/launch-test.sh" +echo "" +echo "📁 Test vault location:" +echo " $TEST_DIR/vault" +echo "" +echo "⚙️ Config file:" +echo " $CONFIG_FILE" +echo "" +echo "🧹 To cleanup when done:" +echo " $TEST_DIR/cleanup.sh" +echo "" +echo "📋 Test checklist:" +echo " □ Daily notes (nd, ny, nt)" +echo " □ New notes (nn, nc)" +echo " □ Finding notes (nf)" +echo " □ Searching content (ns)" +echo " □ Following links (gf on [[links]])" +echo " □ Inserting templates (np)" +echo " □ Help documentation (:help markdown-notes)" +echo " □ Workspace functionality" \ No newline at end of file diff --git a/tests/markdown-notes/config_spec.lua b/tests/markdown-notes/config_spec.lua index c14ebed..d83ad1a 100644 --- a/tests/markdown-notes/config_spec.lua +++ b/tests/markdown-notes/config_spec.lua @@ -1,245 +1,246 @@ local config = require("markdown-notes.config") describe("config", function() - before_each(function() - config.options = {} - config.workspaces = {} - config.default_workspace = nil - config.current_active_workspace = nil - end) - - it("has default configuration", function() - assert.is_not_nil(config.defaults) - assert.is_not_nil(config.defaults.vault_path) - assert.is_not_nil(config.defaults.templates_path) - assert.is_not_nil(config.defaults.template_vars) - assert.is_not_nil(config.defaults.mappings) - end) - - it("merges user options with defaults", function() - local user_opts = { - vault_path = "/custom/path", - custom_option = "test" - } - - config.setup(user_opts) - - assert.are.equal("/custom/path", config.options.vault_path) - assert.are.equal("test", config.options.custom_option) - assert.is_not_nil(config.options.templates_path) - end) - - it("deep merges nested options", function() - local user_opts = { - mappings = { - daily_note_today = "dt" - } - } - - config.setup(user_opts) - - assert.are.equal("dt", config.options.mappings.daily_note_today) - assert.is_not_nil(config.options.mappings.new_note) - end) - - it("merges user-defined template variables with defaults", function() - local user_opts = { - template_vars = { - author = function() return "Test Author" end, - project = function() return "test-project" end, - custom_string = "static-value" - } - } - - config.setup(user_opts) - - -- User variables should be present - assert.is_function(config.options.template_vars.author) - assert.is_function(config.options.template_vars.project) - assert.are.equal("static-value", config.options.template_vars.custom_string) - - -- Default variables should still be present - assert.is_function(config.options.template_vars.date) - assert.is_function(config.options.template_vars.time) - assert.is_function(config.options.template_vars.title) - - -- Test that user functions work - assert.are.equal("Test Author", config.options.template_vars.author()) - assert.are.equal("test-project", config.options.template_vars.project()) - end) - - describe("workspaces", function() - it("sets up workspace with custom config", function() - local workspace_opts = { - vault_path = "/work/notes", - templates_path = "/work/templates" - } - - config.setup_workspace("work", workspace_opts) - - assert.is_not_nil(config.workspaces.work) - assert.are.equal("/work/notes", config.workspaces.work.vault_path) - assert.are.equal("/work/templates", config.workspaces.work.templates_path) - assert.is_not_nil(config.workspaces.work.dailies_path) -- Should inherit from defaults - end) - - it("merges workspace config with defaults", function() - local workspace_opts = { - vault_path = "/personal/notes" - } - - config.setup_workspace("personal", workspace_opts) - - assert.are.equal("/personal/notes", config.workspaces.personal.vault_path) - assert.is_not_nil(config.workspaces.personal.templates_path) - assert.is_not_nil(config.workspaces.personal.template_vars) - assert.is_not_nil(config.workspaces.personal.mappings) - end) - - it("returns workspace list", function() - config.setup_workspace("work", { vault_path = "/work" }) - config.setup_workspace("personal", { vault_path = "/personal" }) - - local workspaces = config.get_workspaces() - - assert.is_not_nil(workspaces.work) - assert.is_not_nil(workspaces.personal) - assert.are.equal("/work", workspaces.work.vault_path) - assert.are.equal("/personal", workspaces.personal.vault_path) - end) - end) - - describe("workspace detection", function() - before_each(function() - config.setup_workspace("work", { vault_path = "/work/notes" }) - config.setup_workspace("personal", { vault_path = "/personal/notes" }) - -- Set work as default to match expected behavior - config.set_default_workspace("work") - end) - - it("uses first workspace as active workspace", function() - -- With simplified system, first workspace configured becomes active - local workspace_config, workspace_name = config.get_current_config() - - -- "work" is set up first in before_each, so it becomes the active workspace - assert.are.equal("work", workspace_name) - assert.are.equal("/work/notes", workspace_config.vault_path) - end) - - - it("handles empty buffer name by using active workspace", function() - -- No need to mock buffer name - simplified system doesn't depend on it - local workspace_config, workspace_name = config.get_current_config() - - -- Should use the first workspace (work) that was set up in before_each - assert.are.equal("work", workspace_name) - assert.are.equal("/work/notes", workspace_config.vault_path) - end) - end) - - describe("default workspace", function() - before_each(function() - config.setup({ vault_path = "/fallback/notes" }) - config.setup_workspace("work", { vault_path = "/work/notes" }) - config.setup_workspace("personal", { vault_path = "/personal/notes" }) - end) - - it("sets and gets default workspace", function() - local success = config.set_default_workspace("work") - - assert.is_true(success) - assert.are.equal("work", config.get_default_workspace()) - end) - - it("fails to set non-existent workspace as default", function() - local success = config.set_default_workspace("nonexistent") - - assert.is_false(success) - assert.is_nil(config.get_default_workspace()) - end) - - it("sets default workspace from main config", function() - -- Reset state - config.options = {} - config.workspaces = {} - config.default_workspace = nil - config.current_active_workspace = nil - - -- Setup with default_workspace in config - config.setup({ - vault_path = "/base/notes", - default_workspace = "personal" - }) - config.setup_workspace("work", { vault_path = "/work/notes" }) - config.setup_workspace("personal", { vault_path = "/personal/notes" }) - - assert.are.equal("personal", config.get_default_workspace()) - - -- Should use personal workspace as active workspace - local workspace_config, workspace_name = config.get_current_config() - assert.are.equal("personal", workspace_name) - assert.are.equal("/personal/notes", workspace_config.vault_path) - end) - - it("first workspace becomes default when no default_workspace specified", function() - -- Reset state - config.options = {} - config.workspaces = {} - config.default_workspace = nil - config.current_active_workspace = nil - - -- Setup without default_workspace in config - config.setup({ vault_path = "/base/notes" }) - - -- First workspace should become default (via init.lua logic) - local init = require("markdown-notes.init") - init.setup_workspace("work", { vault_path = "/work/notes" }) - init.setup_workspace("personal", { vault_path = "/personal/notes" }) - - assert.are.equal("work", config.get_default_workspace()) - - -- Should use work workspace as active workspace - local workspace_config, workspace_name = config.get_current_config() - assert.are.equal("work", workspace_name) - assert.are.equal("/work/notes", workspace_config.vault_path) - end) - - it("uses default workspace for empty buffer paths", function() - config.set_default_workspace("personal") - - -- Mock vim.api.nvim_buf_get_name to return empty string - local original_get_name = vim.api.nvim_buf_get_name - vim.api.nvim_buf_get_name = function(bufnr) - return "" - end - - local workspace_config, workspace_name = config.get_current_config() - - assert.are.equal("personal", workspace_name) - assert.are.equal("/personal/notes", workspace_config.vault_path) - - -- Restore original function - vim.api.nvim_buf_get_name = original_get_name - end) - - it("uses default workspace when no path matches", function() - config.set_default_workspace("work") - - -- Mock vim.api.nvim_buf_get_name to return non-matching path - local original_get_name = vim.api.nvim_buf_get_name - vim.api.nvim_buf_get_name = function(bufnr) - return "/random/location/file.md" - end - - local workspace_config, workspace_name = config.get_current_config() - - assert.are.equal("work", workspace_name) - assert.are.equal("/work/notes", workspace_config.vault_path) - - -- Restore original function - vim.api.nvim_buf_get_name = original_get_name - end) - - - end) -end) \ No newline at end of file + before_each(function() + config.options = {} + config.workspaces = {} + config.default_workspace = nil + config.current_active_workspace = nil + end) + + it("has default configuration", function() + assert.is_not_nil(config.defaults) + assert.is_not_nil(config.defaults.vault_path) + assert.is_not_nil(config.defaults.templates_path) + assert.is_not_nil(config.defaults.template_vars) + assert.is_not_nil(config.defaults.mappings) + end) + + it("merges user options with defaults", function() + local user_opts = { + vault_path = "/custom/path", + custom_option = "test", + } + + config.setup(user_opts) + + assert.are.equal("/custom/path", config.options.vault_path) + assert.are.equal("test", config.options.custom_option) + assert.is_not_nil(config.options.templates_path) + end) + + it("merges user-defined template variables with defaults", function() + local user_opts = { + template_vars = { + author = function() + return "Test Author" + end, + project = function() + return "test-project" + end, + custom_string = "static-value", + }, + } + + config.setup(user_opts) + + -- User variables should be present + assert.is_function(config.options.template_vars.author) + assert.is_function(config.options.template_vars.project) + assert.are.equal("static-value", config.options.template_vars.custom_string) + + -- Default variables should still be present + assert.is_function(config.options.template_vars.date) + assert.is_function(config.options.template_vars.time) + assert.is_function(config.options.template_vars.title) + + -- Test that user functions work + assert.are.equal("Test Author", config.options.template_vars.author()) + assert.are.equal("test-project", config.options.template_vars.project()) + end) + + it("deep merges nested options", function() + local user_opts = { + mappings = { + daily_note_today = "dt", + }, + } + + config.setup(user_opts) + + assert.are.equal("dt", config.options.mappings.daily_note_today) + assert.is_not_nil(config.options.mappings.new_note) + end) + + describe("workspaces", function() + it("sets up workspace with custom config", function() + local workspace_opts = { + vault_path = "/work/notes", + templates_path = "/work/templates", + } + + config.setup_workspace("work", workspace_opts) + + assert.is_not_nil(config.workspaces.work) + assert.are.equal("/work/notes", config.workspaces.work.vault_path) + assert.are.equal("/work/templates", config.workspaces.work.templates_path) + assert.is_not_nil(config.workspaces.work.dailies_path) -- Should inherit from defaults + end) + + it("merges workspace config with defaults", function() + local workspace_opts = { + vault_path = "/personal/notes", + } + + config.setup_workspace("personal", workspace_opts) + + assert.are.equal("/personal/notes", config.workspaces.personal.vault_path) + assert.is_not_nil(config.workspaces.personal.templates_path) + assert.is_not_nil(config.workspaces.personal.template_vars) + assert.is_not_nil(config.workspaces.personal.mappings) + end) + + it("returns workspace list", function() + config.setup_workspace("work", { vault_path = "/work" }) + config.setup_workspace("personal", { vault_path = "/personal" }) + + local workspaces = config.get_workspaces() + + assert.is_not_nil(workspaces.work) + assert.is_not_nil(workspaces.personal) + assert.are.equal("/work", workspaces.work.vault_path) + assert.are.equal("/personal", workspaces.personal.vault_path) + end) + end) + + describe("workspace detection", function() + before_each(function() + config.setup_workspace("work", { vault_path = "/work/notes" }) + config.setup_workspace("personal", { vault_path = "/personal/notes" }) + -- Set work as default to match expected behavior + config.set_default_workspace("work") + end) + + it("uses first workspace as active workspace", function() + -- With simplified system, first workspace configured becomes active + local workspace_config, workspace_name = config.get_current_config() + + -- "work" is set up first in before_each, so it becomes the active workspace + assert.are.equal("work", workspace_name) + assert.are.equal("/work/notes", workspace_config.vault_path) + end) + + it("handles empty buffer name by using active workspace", function() + -- No need to mock buffer name - simplified system doesn't depend on it + local workspace_config, workspace_name = config.get_current_config() + + -- Should use the first workspace (work) that was set up in before_each + assert.are.equal("work", workspace_name) + assert.are.equal("/work/notes", workspace_config.vault_path) + end) + end) + + describe("default workspace", function() + before_each(function() + config.setup({ vault_path = "/fallback/notes" }) + config.setup_workspace("work", { vault_path = "/work/notes" }) + config.setup_workspace("personal", { vault_path = "/personal/notes" }) + end) + + it("sets and gets default workspace", function() + local success = config.set_default_workspace("work") + + assert.is_true(success) + assert.are.equal("work", config.get_default_workspace()) + end) + + it("fails to set non-existent workspace as default", function() + local success = config.set_default_workspace("nonexistent") + + assert.is_false(success) + assert.is_nil(config.get_default_workspace()) + end) + + it("sets default workspace from main config", function() + -- Reset state + config.options = {} + config.workspaces = {} + config.default_workspace = nil + config.current_active_workspace = nil + + -- Setup with default_workspace in config + config.setup({ + vault_path = "/base/notes", + default_workspace = "personal", + }) + config.setup_workspace("work", { vault_path = "/work/notes" }) + config.setup_workspace("personal", { vault_path = "/personal/notes" }) + + assert.are.equal("personal", config.get_default_workspace()) + + -- Should use personal workspace as active workspace + local workspace_config, workspace_name = config.get_current_config() + assert.are.equal("personal", workspace_name) + assert.are.equal("/personal/notes", workspace_config.vault_path) + end) + + it("first workspace becomes default when no default_workspace specified", function() + -- Reset state + config.options = {} + config.workspaces = {} + config.default_workspace = nil + config.current_active_workspace = nil + + -- Setup without default_workspace in config + config.setup({ vault_path = "/base/notes" }) + + -- First workspace should become default (via init.lua logic) + local init = require("markdown-notes.init") + init.setup_workspace("work", { vault_path = "/work/notes" }) + init.setup_workspace("personal", { vault_path = "/personal/notes" }) + + assert.are.equal("work", config.get_default_workspace()) + + -- Should use work workspace as active workspace + local workspace_config, workspace_name = config.get_current_config() + assert.are.equal("work", workspace_name) + assert.are.equal("/work/notes", workspace_config.vault_path) + end) + + it("uses default workspace for empty buffer paths", function() + config.set_default_workspace("personal") + + -- Mock vim.api.nvim_buf_get_name to return empty string + local original_get_name = vim.api.nvim_buf_get_name + vim.api.nvim_buf_get_name = function(bufnr) + return "" + end + + local workspace_config, workspace_name = config.get_current_config() + + assert.are.equal("personal", workspace_name) + assert.are.equal("/personal/notes", workspace_config.vault_path) + + -- Restore original function + vim.api.nvim_buf_get_name = original_get_name + end) + + it("uses default workspace when no path matches", function() + config.set_default_workspace("work") + + -- Mock vim.api.nvim_buf_get_name to return non-matching path + local original_get_name = vim.api.nvim_buf_get_name + vim.api.nvim_buf_get_name = function(bufnr) + return "/random/location/file.md" + end + + local workspace_config, workspace_name = config.get_current_config() + + assert.are.equal("work", workspace_name) + assert.are.equal("/work/notes", workspace_config.vault_path) + + -- Restore original function + vim.api.nvim_buf_get_name = original_get_name + end) + end) +end) diff --git a/tests/markdown-notes/links_spec.lua b/tests/markdown-notes/links_spec.lua index 9929af1..51f8272 100644 --- a/tests/markdown-notes/links_spec.lua +++ b/tests/markdown-notes/links_spec.lua @@ -2,402 +2,400 @@ local links = require("markdown-notes.links") local config = require("markdown-notes.config") describe("links", function() - local temp_dir = "/tmp/markdown-notes-test" - local vault_path = temp_dir .. "/vault" - - before_each(function() - -- Clean up and create test directory - vim.fn.system("rm -rf " .. temp_dir) - vim.fn.mkdir(vault_path, "p") - - -- Setup config - config.setup({ - vault_path = vault_path - }) - - -- Mock vim.fn.confirm to always return 1 (Yes) - _G.original_confirm = vim.fn.confirm - vim.fn.confirm = function(message, choices, default) - return 1 - end - - -- Mock vim.notify to capture notifications - _G.notifications = {} - _G.original_notify = vim.notify - vim.notify = function(message, level) - table.insert(_G.notifications, {message = message, level = level}) - end - end) - - after_each(function() - -- Restore original functions - vim.fn.confirm = _G.original_confirm - vim.notify = _G.original_notify - - -- Clean up - vim.fn.system("rm -rf " .. temp_dir) - end) - - describe("rename_note", function() - it("renames a note without links", function() - -- Create a test note - local note_path = vault_path .. "/test-note.md" - vim.fn.writefile({"# Test Note", "This is a test note"}, note_path) - - -- Open the note - vim.cmd("edit " .. note_path) - - -- Rename the note - links.rename_note("renamed-note") - - -- Check that the file was renamed - assert.is_true(vim.fn.filereadable(vault_path .. "/renamed-note.md") == 1) - assert.is_true(vim.fn.filereadable(note_path) == 0) - - -- Check notification - assert.is_true(#_G.notifications > 0) - assert.is_not_nil(_G.notifications[#_G.notifications].message:match("Renamed note")) - end) - - it("renames a note and updates simple links", function() - -- Create notes with links - local note_path = vault_path .. "/original-note.md" - local linking_note_path = vault_path .. "/linking-note.md" - - vim.fn.writefile({"# Original Note", "Content here"}, note_path) - vim.fn.writefile({"# Linking Note", "See [[original-note]] for details"}, linking_note_path) - - -- Verify initial state - local initial_content = table.concat(vim.fn.readfile(linking_note_path), "\n") - assert.is_not_nil(initial_content:find("[[original-note]]", 1, true)) - - -- Open the original note - vim.cmd("edit " .. note_path) - - -- Rename the note - links.rename_note("new-name") - - -- Check that the file was renamed - assert.is_true(vim.fn.filereadable(vault_path .. "/new-name.md") == 1) - assert.is_true(vim.fn.filereadable(note_path) == 0) - - -- Check that the link was updated - local linking_content = vim.fn.readfile(linking_note_path) - local content_str = table.concat(linking_content, "\n") - - - assert.is_not_nil(content_str:find("[[new-name]]", 1, true)) - assert.is_nil(content_str:find("[[original-note]]", 1, true)) - - -- Check notification mentions updating files - assert.is_true(#_G.notifications > 0) - assert.is_not_nil(_G.notifications[#_G.notifications].message:match("updated 1 files")) - end) - - it("renames a note and updates display text links", function() - -- Create notes with display text links - local note_path = vault_path .. "/technical-doc.md" - local linking_note_path = vault_path .. "/index.md" - - vim.fn.writefile({"# Technical Documentation", "Content here"}, note_path) - vim.fn.writefile({"# Index", "Check out [[technical-doc|Technical Docs]] for info"}, linking_note_path) - - -- Open the original note - vim.cmd("edit " .. note_path) - - -- Rename the note - links.rename_note("tech-guide") - - -- Check that the file was renamed - assert.is_true(vim.fn.filereadable(vault_path .. "/tech-guide.md") == 1) - assert.is_true(vim.fn.filereadable(note_path) == 0) - - -- Check that the display text link was updated but display text preserved - local linking_content = vim.fn.readfile(linking_note_path) - local content_str = table.concat(linking_content, "\n") - assert.is_not_nil(content_str:find("[[tech-guide|Technical Docs]]", 1, true)) - assert.is_nil(content_str:find("[[technical-doc|Technical Docs]]", 1, true)) - end) - - it("handles multiple files with different link types", function() - -- Create the note to be renamed - local note_path = vault_path .. "/main-topic.md" - vim.fn.writefile({"# Main Topic", "Content here"}, note_path) - - -- Create multiple files with different link types - local file1_path = vault_path .. "/file1.md" - local file2_path = vault_path .. "/file2.md" - local file3_path = vault_path .. "/file3.md" - - vim.fn.writefile({"See [[main-topic]] for details"}, file1_path) - vim.fn.writefile({"Reference: [[main-topic|Main Topic Page]]"}, file2_path) - vim.fn.writefile({"Both [[main-topic]] and [[main-topic|the main topic]]"}, file3_path) - - -- Open the note to rename - vim.cmd("edit " .. note_path) - - -- Rename the note - links.rename_note("primary-topic") - - -- Check file was renamed - assert.is_true(vim.fn.filereadable(vault_path .. "/primary-topic.md") == 1) - assert.is_true(vim.fn.filereadable(note_path) == 0) - - -- Check all links were updated - local file1_content = table.concat(vim.fn.readfile(file1_path), "\n") - local file2_content = table.concat(vim.fn.readfile(file2_path), "\n") - local file3_content = table.concat(vim.fn.readfile(file3_path), "\n") - - assert.is_not_nil(file1_content:find("[[primary-topic]]", 1, true)) - assert.is_not_nil(file2_content:find("[[primary-topic|Main Topic Page]]", 1, true)) - assert.is_not_nil(file3_content:find("[[primary-topic]]", 1, true)) - assert.is_not_nil(file3_content:find("[[primary-topic|the main topic]]", 1, true)) - - -- Check notification mentions updating 3 files - assert.is_true(#_G.notifications > 0) - assert.is_not_nil(_G.notifications[#_G.notifications].message:match("updated 3 files")) - end) - - it("prevents overwriting existing files", function() - -- Create original note and target note - local note_path = vault_path .. "/original.md" - local existing_path = vault_path .. "/existing.md" - - vim.fn.writefile({"# Original"}, note_path) - vim.fn.writefile({"# Existing"}, existing_path) - - -- Open original note - vim.cmd("edit " .. note_path) - - -- Try to rename to existing file name - links.rename_note("existing") - - -- Check that original file was not renamed - assert.is_true(vim.fn.filereadable(note_path) == 1) - assert.is_true(vim.fn.filereadable(existing_path) == 1) - - -- Check error notification - assert.is_true(#_G.notifications > 0) - local found_error = false - for _, notification in ipairs(_G.notifications) do - if notification.message:match("File already exists") then - found_error = true - break - end - end - assert.is_true(found_error) - end) - - it("handles notes in subdirectories", function() - -- Create subdirectory and note - local subdir = vault_path .. "/projects" - vim.fn.mkdir(subdir, "p") - local note_path = subdir .. "/project-a.md" - local linking_note_path = vault_path .. "/index.md" - - vim.fn.writefile({"# Project A"}, note_path) - vim.fn.writefile({"See [[projects/project-a]] for details"}, linking_note_path) - - -- Open the note - vim.cmd("edit " .. note_path) - - -- Rename the note - links.rename_note("alpha-project") - - -- Check file was renamed in same directory - assert.is_true(vim.fn.filereadable(subdir .. "/alpha-project.md") == 1) - assert.is_true(vim.fn.filereadable(note_path) == 0) - - -- Check link was updated with correct path - local linking_content = table.concat(vim.fn.readfile(linking_note_path), "\n") - assert.is_not_nil(linking_content:find("[[projects/alpha-project]]", 1, true)) - assert.is_nil(linking_content:find("[[projects/project-a]]", 1, true)) - end) - - it("strips .md extension from new name", function() - -- Create a test note - local note_path = vault_path .. "/test.md" - vim.fn.writefile({"# Test"}, note_path) - - -- Open the note - vim.cmd("edit " .. note_path) - - -- Rename with .md extension - links.rename_note("new-name.md") - - -- Check that file was renamed without double extension - assert.is_true(vim.fn.filereadable(vault_path .. "/new-name.md") == 1) - assert.is_true(vim.fn.filereadable(vault_path .. "/new-name.md.md") == 0) - end) - - it("handles similar note names without partial matches", function() - -- Create notes with similar names - local note_path = vault_path .. "/project.md" - local similar_note_path = vault_path .. "/project-archive.md" - local linking_note_path = vault_path .. "/index.md" - - vim.fn.writefile({"# Project"}, note_path) - vim.fn.writefile({"# Project Archive"}, similar_note_path) - vim.fn.writefile({ - "# Index", - "See [[project]] for current work", - "See [[project-archive]] for old work" - }, linking_note_path) - - -- Verify initial state - local initial_content = table.concat(vim.fn.readfile(linking_note_path), "\n") - assert.is_not_nil(initial_content:find("[[project]]", 1, true)) - assert.is_not_nil(initial_content:find("[[project-archive]]", 1, true)) - - -- Open and rename the "project" note - vim.cmd("edit " .. note_path) - links.rename_note("main-project") - - -- Check that files were renamed correctly - assert.is_true(vim.fn.filereadable(vault_path .. "/main-project.md") == 1) - assert.is_true(vim.fn.filereadable(note_path) == 0) - assert.is_true(vim.fn.filereadable(similar_note_path) == 1) -- Should still exist - - -- Check that links were updated correctly - local final_content = table.concat(vim.fn.readfile(linking_note_path), "\n") - - -- Should find the new link - assert.is_not_nil(final_content:find("[[main-project]]", 1, true)) - - -- Should NOT find the old exact link - assert.is_nil(final_content:find("[[project]]", 1, true)) - - -- Should still have the similar link UNCHANGED - assert.is_not_nil(final_content:find("[[project-archive]]", 1, true)) - - -- Verify the similar link wasn't corrupted - assert.is_nil(final_content:find("[[main-project-archive]]", 1, true)) - - print("Final content:", final_content) - end) - - it("handles very similar note names correctly", function() - -- Test even trickier edge cases - local note_path = vault_path .. "/api.md" - local linking_note_path = vault_path .. "/docs.md" - - vim.fn.writefile({"# API"}, note_path) - vim.fn.writefile({ - "Links: [[api]], [[api-docs]], [[api-v2]], [[legacy-api]]" - }, linking_note_path) - - -- Open and rename - vim.cmd("edit " .. note_path) - links.rename_note("new-api") - - -- Check final content - local final_content = table.concat(vim.fn.readfile(linking_note_path), "\n") - - -- Only the exact match should be replaced - assert.is_not_nil(final_content:find("[[new-api]]", 1, true)) - assert.is_not_nil(final_content:find("[[api-docs]]", 1, true)) - assert.is_not_nil(final_content:find("[[api-v2]]", 1, true)) - assert.is_not_nil(final_content:find("[[legacy-api]]", 1, true)) - - -- The old exact link should be gone - assert.is_nil(final_content:find("[[api]]", 1, true)) - - print("Very similar test result:", final_content) - end) - - it("handles files with spaces in names", function() - -- Create notes with spaces in filenames - local note_path = vault_path .. "/my project.md" - local linking_note_path = vault_path .. "/index.md" - - vim.fn.writefile({"# My Project"}, note_path) - vim.fn.writefile({"See [[my project]] for details"}, linking_note_path) - - -- Open and rename - vim.cmd("edit " .. vim.fn.fnameescape(note_path)) - links.rename_note("new project name") - - -- Check results - assert.is_true(vim.fn.filereadable(vault_path .. "/new project name.md") == 1) - assert.is_true(vim.fn.filereadable(note_path) == 0) - - local final_content = table.concat(vim.fn.readfile(linking_note_path), "\n") - assert.is_not_nil(final_content:find("[[new project name]]", 1, true)) - assert.is_nil(final_content:find("[[my project]]", 1, true)) - end) - - it("handles empty or whitespace-only names gracefully", function() - local note_path = vault_path .. "/test.md" - vim.fn.writefile({"# Test"}, note_path) - vim.cmd("edit " .. note_path) - - -- Clear notifications - _G.notifications = {} - - -- Test empty string - links.rename_note("") - assert.is_true(vim.fn.filereadable(note_path) == 1) -- Should still exist - - -- Test whitespace only - links.rename_note(" ") - assert.is_true(vim.fn.filereadable(note_path) == 1) -- Should still exist - - -- Should have error notifications - local found_error = false - for _, notification in ipairs(_G.notifications) do - if notification.level == vim.log.levels.ERROR then - found_error = true - break - end - end - assert.is_true(found_error) - end) - - it("handles invalid filename characters", function() - local note_path = vault_path .. "/test.md" - vim.fn.writefile({"# Test"}, note_path) - vim.cmd("edit " .. note_path) - - -- Clear notifications - _G.notifications = {} - - -- Test invalid characters - links.rename_note("test/invalid") - links.rename_note("test\\invalid") - links.rename_note("test:invalid") - - -- File should still exist since rename should fail - assert.is_true(vim.fn.filereadable(note_path) == 1) - - -- Should have error notifications - local found_error = false - for _, notification in ipairs(_G.notifications) do - if notification.level == vim.log.levels.ERROR and - notification.message:find("invalid characters") then - found_error = true - break - end - end - assert.is_true(found_error) - end) - - it("handles no current file gracefully", function() - -- Clear current buffer - vim.cmd("enew") - - -- Should handle gracefully without crashing - links.rename_note("test") - - -- Check for appropriate notification - assert.is_true(#_G.notifications > 0) - local found_warning = false - for _, notification in ipairs(_G.notifications) do - if notification.message:find("No current file") then - found_warning = true - break - end - end - assert.is_true(found_warning) - end) - end) -end) \ No newline at end of file + local temp_dir = "/tmp/markdown-notes-test" + local vault_path = temp_dir .. "/vault" + + before_each(function() + -- Clean up and create test directory + vim.fn.system("rm -rf " .. temp_dir) + vim.fn.mkdir(vault_path, "p") + + -- Setup config + config.setup({ + vault_path = vault_path, + }) + + -- Mock vim.fn.confirm to always return 1 (Yes) + _G.original_confirm = vim.fn.confirm + vim.fn.confirm = function(message, choices, default) + return 1 + end + + -- Mock vim.notify to capture notifications + _G.notifications = {} + _G.original_notify = vim.notify + vim.notify = function(message, level) + table.insert(_G.notifications, { message = message, level = level }) + end + end) + + after_each(function() + -- Restore original functions + vim.fn.confirm = _G.original_confirm + vim.notify = _G.original_notify + + -- Clean up + vim.fn.system("rm -rf " .. temp_dir) + end) + + describe("rename_note", function() + it("renames a note without links", function() + -- Create a test note + local note_path = vault_path .. "/test-note.md" + vim.fn.writefile({ "# Test Note", "This is a test note" }, note_path) + + -- Open the note + vim.cmd("edit " .. note_path) + + -- Rename the note + links.rename_note("renamed-note") + + -- Check that the file was renamed + assert.is_true(vim.fn.filereadable(vault_path .. "/renamed-note.md") == 1) + assert.is_true(vim.fn.filereadable(note_path) == 0) + + -- Check notification + assert.is_true(#_G.notifications > 0) + assert.is_not_nil(_G.notifications[#_G.notifications].message:match("Renamed note")) + end) + + it("renames a note and updates simple links", function() + -- Create notes with links + local note_path = vault_path .. "/original-note.md" + local linking_note_path = vault_path .. "/linking-note.md" + + vim.fn.writefile({ "# Original Note", "Content here" }, note_path) + vim.fn.writefile({ "# Linking Note", "See [[original-note]] for details" }, linking_note_path) + + -- Verify initial state + local initial_content = table.concat(vim.fn.readfile(linking_note_path), "\n") + assert.is_not_nil(initial_content:find("[[original-note]]", 1, true)) + + -- Open the original note + vim.cmd("edit " .. note_path) + + -- Rename the note + links.rename_note("new-name") + + -- Check that the file was renamed + assert.is_true(vim.fn.filereadable(vault_path .. "/new-name.md") == 1) + assert.is_true(vim.fn.filereadable(note_path) == 0) + + -- Check that the link was updated + local linking_content = vim.fn.readfile(linking_note_path) + local content_str = table.concat(linking_content, "\n") + + assert.is_not_nil(content_str:find("[[new-name]]", 1, true)) + assert.is_nil(content_str:find("[[original-note]]", 1, true)) + + -- Check notification mentions updating files + assert.is_true(#_G.notifications > 0) + assert.is_not_nil(_G.notifications[#_G.notifications].message:match("updated 1 files")) + end) + + it("renames a note and updates display text links", function() + -- Create notes with display text links + local note_path = vault_path .. "/technical-doc.md" + local linking_note_path = vault_path .. "/index.md" + + vim.fn.writefile({ "# Technical Documentation", "Content here" }, note_path) + vim.fn.writefile({ "# Index", "Check out [[technical-doc|Technical Docs]] for info" }, linking_note_path) + + -- Open the original note + vim.cmd("edit " .. note_path) + + -- Rename the note + links.rename_note("tech-guide") + + -- Check that the file was renamed + assert.is_true(vim.fn.filereadable(vault_path .. "/tech-guide.md") == 1) + assert.is_true(vim.fn.filereadable(note_path) == 0) + + -- Check that the display text link was updated but display text preserved + local linking_content = vim.fn.readfile(linking_note_path) + local content_str = table.concat(linking_content, "\n") + assert.is_not_nil(content_str:find("[[tech-guide|Technical Docs]]", 1, true)) + assert.is_nil(content_str:find("[[technical-doc|Technical Docs]]", 1, true)) + end) + + it("handles multiple files with different link types", function() + -- Create the note to be renamed + local note_path = vault_path .. "/main-topic.md" + vim.fn.writefile({ "# Main Topic", "Content here" }, note_path) + + -- Create multiple files with different link types + local file1_path = vault_path .. "/file1.md" + local file2_path = vault_path .. "/file2.md" + local file3_path = vault_path .. "/file3.md" + + vim.fn.writefile({ "See [[main-topic]] for details" }, file1_path) + vim.fn.writefile({ "Reference: [[main-topic|Main Topic Page]]" }, file2_path) + vim.fn.writefile({ "Both [[main-topic]] and [[main-topic|the main topic]]" }, file3_path) + + -- Open the note to rename + vim.cmd("edit " .. note_path) + + -- Rename the note + links.rename_note("primary-topic") + + -- Check file was renamed + assert.is_true(vim.fn.filereadable(vault_path .. "/primary-topic.md") == 1) + assert.is_true(vim.fn.filereadable(note_path) == 0) + + -- Check all links were updated + local file1_content = table.concat(vim.fn.readfile(file1_path), "\n") + local file2_content = table.concat(vim.fn.readfile(file2_path), "\n") + local file3_content = table.concat(vim.fn.readfile(file3_path), "\n") + + assert.is_not_nil(file1_content:find("[[primary-topic]]", 1, true)) + assert.is_not_nil(file2_content:find("[[primary-topic|Main Topic Page]]", 1, true)) + assert.is_not_nil(file3_content:find("[[primary-topic]]", 1, true)) + assert.is_not_nil(file3_content:find("[[primary-topic|the main topic]]", 1, true)) + + -- Check notification mentions updating 3 files + assert.is_true(#_G.notifications > 0) + assert.is_not_nil(_G.notifications[#_G.notifications].message:match("updated 3 files")) + end) + + it("prevents overwriting existing files", function() + -- Create original note and target note + local note_path = vault_path .. "/original.md" + local existing_path = vault_path .. "/existing.md" + + vim.fn.writefile({ "# Original" }, note_path) + vim.fn.writefile({ "# Existing" }, existing_path) + + -- Open original note + vim.cmd("edit " .. note_path) + + -- Try to rename to existing file name + links.rename_note("existing") + + -- Check that original file was not renamed + assert.is_true(vim.fn.filereadable(note_path) == 1) + assert.is_true(vim.fn.filereadable(existing_path) == 1) + + -- Check error notification + assert.is_true(#_G.notifications > 0) + local found_error = false + for _, notification in ipairs(_G.notifications) do + if notification.message:match("File already exists") then + found_error = true + break + end + end + assert.is_true(found_error) + end) + + it("handles notes in subdirectories", function() + -- Create subdirectory and note + local subdir = vault_path .. "/projects" + vim.fn.mkdir(subdir, "p") + local note_path = subdir .. "/project-a.md" + local linking_note_path = vault_path .. "/index.md" + + vim.fn.writefile({ "# Project A" }, note_path) + vim.fn.writefile({ "See [[projects/project-a]] for details" }, linking_note_path) + + -- Open the note + vim.cmd("edit " .. note_path) + + -- Rename the note + links.rename_note("alpha-project") + + -- Check file was renamed in same directory + assert.is_true(vim.fn.filereadable(subdir .. "/alpha-project.md") == 1) + assert.is_true(vim.fn.filereadable(note_path) == 0) + + -- Check link was updated with correct path + local linking_content = table.concat(vim.fn.readfile(linking_note_path), "\n") + assert.is_not_nil(linking_content:find("[[projects/alpha-project]]", 1, true)) + assert.is_nil(linking_content:find("[[projects/project-a]]", 1, true)) + end) + + it("strips .md extension from new name", function() + -- Create a test note + local note_path = vault_path .. "/test.md" + vim.fn.writefile({ "# Test" }, note_path) + + -- Open the note + vim.cmd("edit " .. note_path) + + -- Rename with .md extension + links.rename_note("new-name.md") + + -- Check that file was renamed without double extension + assert.is_true(vim.fn.filereadable(vault_path .. "/new-name.md") == 1) + assert.is_true(vim.fn.filereadable(vault_path .. "/new-name.md.md") == 0) + end) + + it("handles similar note names without partial matches", function() + -- Create notes with similar names + local note_path = vault_path .. "/project.md" + local similar_note_path = vault_path .. "/project-archive.md" + local linking_note_path = vault_path .. "/index.md" + + vim.fn.writefile({ "# Project" }, note_path) + vim.fn.writefile({ "# Project Archive" }, similar_note_path) + vim.fn.writefile({ + "# Index", + "See [[project]] for current work", + "See [[project-archive]] for old work", + }, linking_note_path) + + -- Verify initial state + local initial_content = table.concat(vim.fn.readfile(linking_note_path), "\n") + assert.is_not_nil(initial_content:find("[[project]]", 1, true)) + assert.is_not_nil(initial_content:find("[[project-archive]]", 1, true)) + + -- Open and rename the "project" note + vim.cmd("edit " .. note_path) + links.rename_note("main-project") + + -- Check that files were renamed correctly + assert.is_true(vim.fn.filereadable(vault_path .. "/main-project.md") == 1) + assert.is_true(vim.fn.filereadable(note_path) == 0) + assert.is_true(vim.fn.filereadable(similar_note_path) == 1) -- Should still exist + + -- Check that links were updated correctly + local final_content = table.concat(vim.fn.readfile(linking_note_path), "\n") + + -- Should find the new link + assert.is_not_nil(final_content:find("[[main-project]]", 1, true)) + + -- Should NOT find the old exact link + assert.is_nil(final_content:find("[[project]]", 1, true)) + + -- Should still have the similar link UNCHANGED + assert.is_not_nil(final_content:find("[[project-archive]]", 1, true)) + + -- Verify the similar link wasn't corrupted + assert.is_nil(final_content:find("[[main-project-archive]]", 1, true)) + + print("Final content:", final_content) + end) + + it("handles very similar note names correctly", function() + -- Test even trickier edge cases + local note_path = vault_path .. "/api.md" + local linking_note_path = vault_path .. "/docs.md" + + vim.fn.writefile({ "# API" }, note_path) + vim.fn.writefile({ + "Links: [[api]], [[api-docs]], [[api-v2]], [[legacy-api]]", + }, linking_note_path) + + -- Open and rename + vim.cmd("edit " .. note_path) + links.rename_note("new-api") + + -- Check final content + local final_content = table.concat(vim.fn.readfile(linking_note_path), "\n") + + -- Only the exact match should be replaced + assert.is_not_nil(final_content:find("[[new-api]]", 1, true)) + assert.is_not_nil(final_content:find("[[api-docs]]", 1, true)) + assert.is_not_nil(final_content:find("[[api-v2]]", 1, true)) + assert.is_not_nil(final_content:find("[[legacy-api]]", 1, true)) + + -- The old exact link should be gone + assert.is_nil(final_content:find("[[api]]", 1, true)) + + print("Very similar test result:", final_content) + end) + + it("handles files with spaces in names", function() + -- Create notes with spaces in filenames + local note_path = vault_path .. "/my project.md" + local linking_note_path = vault_path .. "/index.md" + + vim.fn.writefile({ "# My Project" }, note_path) + vim.fn.writefile({ "See [[my project]] for details" }, linking_note_path) + + -- Open and rename + vim.cmd("edit " .. vim.fn.fnameescape(note_path)) + links.rename_note("new project name") + + -- Check results + assert.is_true(vim.fn.filereadable(vault_path .. "/new project name.md") == 1) + assert.is_true(vim.fn.filereadable(note_path) == 0) + + local final_content = table.concat(vim.fn.readfile(linking_note_path), "\n") + assert.is_not_nil(final_content:find("[[new project name]]", 1, true)) + assert.is_nil(final_content:find("[[my project]]", 1, true)) + end) + + it("handles empty or whitespace-only names gracefully", function() + local note_path = vault_path .. "/test.md" + vim.fn.writefile({ "# Test" }, note_path) + vim.cmd("edit " .. note_path) + + -- Clear notifications + _G.notifications = {} + + -- Test empty string + links.rename_note("") + assert.is_true(vim.fn.filereadable(note_path) == 1) -- Should still exist + + -- Test whitespace only + links.rename_note(" ") + assert.is_true(vim.fn.filereadable(note_path) == 1) -- Should still exist + + -- Should have error notifications + local found_error = false + for _, notification in ipairs(_G.notifications) do + if notification.level == vim.log.levels.ERROR then + found_error = true + break + end + end + assert.is_true(found_error) + end) + + it("handles invalid filename characters", function() + local note_path = vault_path .. "/test.md" + vim.fn.writefile({ "# Test" }, note_path) + vim.cmd("edit " .. note_path) + + -- Clear notifications + _G.notifications = {} + + -- Test invalid characters + links.rename_note("test/invalid") + links.rename_note("test\\invalid") + links.rename_note("test:invalid") + + -- File should still exist since rename should fail + assert.is_true(vim.fn.filereadable(note_path) == 1) + + -- Should have error notifications + local found_error = false + for _, notification in ipairs(_G.notifications) do + if notification.level == vim.log.levels.ERROR and notification.message:find("invalid characters") then + found_error = true + break + end + end + assert.is_true(found_error) + end) + + it("handles no current file gracefully", function() + -- Clear current buffer + vim.cmd("enew") + + -- Should handle gracefully without crashing + links.rename_note("test") + + -- Check for appropriate notification + assert.is_true(#_G.notifications > 0) + local found_warning = false + for _, notification in ipairs(_G.notifications) do + if notification.message:find("No current file") then + found_warning = true + break + end + end + assert.is_true(found_warning) + end) + end) +end) diff --git a/tests/markdown-notes/notes_spec.lua b/tests/markdown-notes/notes_spec.lua index a422edc..6f3bc09 100644 --- a/tests/markdown-notes/notes_spec.lua +++ b/tests/markdown-notes/notes_spec.lua @@ -2,141 +2,151 @@ local notes = require("markdown-notes.notes") local config = require("markdown-notes.config") describe("notes", function() - before_each(function() - config.options = {} - config.workspaces = {} - config.setup({ - vault_path = "/tmp/test-vault", - notes_subdir = "notes" - }) - end) - - describe("create_new_note", function() - it("has create_new_note function", function() - assert.is_not_nil(notes.create_new_note) - assert.are.equal("function", type(notes.create_new_note)) - end) - - it("generates timestamp-based filename format", function() - -- Test the timestamp generation logic by checking it uses os.time - local original_time = os.time - local test_timestamp = 1720094400 - os.time = function() return test_timestamp end - - -- Mock vim.fn.input to return empty string (no title) - local original_input = vim.fn.input - vim.fn.input = function() return "" end - - -- Mock file operations to avoid actual file creation - local original_expand = vim.fn.expand - vim.fn.expand = function(path) - if path:match("^/tmp/test%-vault") then - return path - end - return original_expand(path) - end - - local original_fnamemodify = vim.fn.fnamemodify - vim.fn.fnamemodify = function(path, modifier) - if modifier == ":h" then - return "/tmp/test-vault/notes" - end - return original_fnamemodify(path, modifier) - end - - local original_isdirectory = vim.fn.isdirectory - vim.fn.isdirectory = function() return 1 end - - local opened_file = nil - local original_cmd = vim.cmd - vim.cmd = function(cmd) - if cmd:match("^edit ") then - opened_file = cmd:match("^edit (.+)$") - end - end - - local original_buf_set_lines = vim.api.nvim_buf_set_lines - vim.api.nvim_buf_set_lines = function() end - - -- Call the function - notes.create_new_note() - - -- Verify timestamp-based filename was used - assert.is_not_nil(opened_file) - assert.matches(tostring(test_timestamp), opened_file) - - -- Restore mocks - os.time = original_time - vim.fn.input = original_input - vim.fn.expand = original_expand - vim.fn.fnamemodify = original_fnamemodify - vim.fn.isdirectory = original_isdirectory - vim.cmd = original_cmd - vim.api.nvim_buf_set_lines = original_buf_set_lines - end) - end) - - describe("workspace integration", function() - it("uses workspace-specific vault path", function() - -- Set up workspace - config.setup_workspace("work", { - vault_path = "/work/notes", - notes_subdir = "projects" - }) - - -- Mock vim.api.nvim_buf_get_name to return work workspace path - local original_get_name = vim.api.nvim_buf_get_name - vim.api.nvim_buf_get_name = function(bufnr) - return "/work/notes/existing.md" - end - - -- Mock other functions - local original_input = vim.fn.input - vim.fn.input = function() return "test-note" end - - local original_expand = vim.fn.expand - vim.fn.expand = function(path) - return path - end - - local original_fnamemodify = vim.fn.fnamemodify - vim.fn.fnamemodify = function(path, modifier) - if modifier == ":h" then - return "/work/notes/projects" - end - return path - end - - local original_isdirectory = vim.fn.isdirectory - vim.fn.isdirectory = function() return 1 end - - local opened_file = nil - local original_cmd = vim.cmd - vim.cmd = function(cmd) - if cmd:match("^edit ") then - opened_file = cmd:match("^edit (.+)$") - end - end - - local original_buf_set_lines = vim.api.nvim_buf_set_lines - vim.api.nvim_buf_set_lines = function() end - - -- Call the function - notes.create_new_note() - - -- Verify workspace-specific path was used - assert.is_not_nil(opened_file) - assert.matches("/work/notes/projects", opened_file) - assert.matches("test%-note", opened_file) - - -- Restore mocks - vim.api.nvim_buf_get_name = original_get_name - vim.fn.input = original_input - vim.fn.expand = original_expand - vim.fn.fnamemodify = original_fnamemodify - vim.fn.isdirectory = original_isdirectory - vim.cmd = original_cmd - vim.api.nvim_buf_set_lines = original_buf_set_lines - end) - end) -end) \ No newline at end of file + before_each(function() + config.options = {} + config.workspaces = {} + config.setup({ + vault_path = "/tmp/test-vault", + notes_subdir = "notes", + }) + end) + + describe("create_new_note", function() + it("has create_new_note function", function() + assert.is_not_nil(notes.create_new_note) + assert.are.equal("function", type(notes.create_new_note)) + end) + + it("generates timestamp-based filename format", function() + -- Test the timestamp generation logic by checking it uses os.time + local original_os = _G.os + local test_timestamp = 1720094400 + _G.os = setmetatable({ + time = function() return test_timestamp end + }, { __index = original_os }) + + -- Mock vim.fn.input to return empty string (no title) + local original_input = vim.fn.input + vim.fn.input = function() + return "" + end + + -- Mock file operations to avoid actual file creation + local original_expand = vim.fn.expand + vim.fn.expand = function(path) + if path:match("^/tmp/test%-vault") then + return path + end + return original_expand(path) + end + + local original_fnamemodify = vim.fn.fnamemodify + vim.fn.fnamemodify = function(path, modifier) + if modifier == ":h" then + return "/tmp/test-vault/notes" + end + return original_fnamemodify(path, modifier) + end + + local original_isdirectory = vim.fn.isdirectory + vim.fn.isdirectory = function() + return 1 + end + + local opened_file = nil + local original_cmd = vim.cmd + vim.cmd = function(cmd) + if cmd:match("^edit ") then + opened_file = cmd:match("^edit (.+)$") + end + end + + local original_buf_set_lines = vim.api.nvim_buf_set_lines + vim.api.nvim_buf_set_lines = function() end + + -- Call the function + notes.create_new_note() + + -- Verify timestamp-based filename was used + assert.is_not_nil(opened_file) + assert.matches(tostring(test_timestamp), opened_file) + + -- Restore mocks + _G.os = original_os + vim.fn.input = original_input + vim.fn.expand = original_expand + vim.fn.fnamemodify = original_fnamemodify + vim.fn.isdirectory = original_isdirectory + vim.cmd = original_cmd + vim.api.nvim_buf_set_lines = original_buf_set_lines + end) + end) + + describe("workspace integration", function() + it("uses workspace-specific vault path", function() + -- Set up workspace + config.setup_workspace("work", { + vault_path = "/work/notes", + notes_subdir = "projects", + }) + + -- Mock vim.api.nvim_buf_get_name to return work workspace path + local original_get_name = vim.api.nvim_buf_get_name + vim.api.nvim_buf_get_name = function(bufnr) + return "/work/notes/existing.md" + end + + -- Mock other functions + local original_input = vim.fn.input + vim.fn.input = function() + return "test-note" + end + + local original_expand = vim.fn.expand + vim.fn.expand = function(path) + return path + end + + local original_fnamemodify = vim.fn.fnamemodify + vim.fn.fnamemodify = function(path, modifier) + if modifier == ":h" then + return "/work/notes/projects" + end + return path + end + + local original_isdirectory = vim.fn.isdirectory + vim.fn.isdirectory = function() + return 1 + end + + local opened_file = nil + local original_cmd = vim.cmd + vim.cmd = function(cmd) + if cmd:match("^edit ") then + opened_file = cmd:match("^edit (.+)$") + end + end + + local original_buf_set_lines = vim.api.nvim_buf_set_lines + vim.api.nvim_buf_set_lines = function() end + + -- Call the function + notes.create_new_note() + + -- Verify workspace-specific path was used + assert.is_not_nil(opened_file) + assert.matches("/work/notes/projects", opened_file) + assert.matches("test%-note", opened_file) + + -- Restore mocks + vim.api.nvim_buf_get_name = original_get_name + vim.fn.input = original_input + vim.fn.expand = original_expand + vim.fn.fnamemodify = original_fnamemodify + vim.fn.isdirectory = original_isdirectory + vim.cmd = original_cmd + vim.api.nvim_buf_set_lines = original_buf_set_lines + end) + end) +end) diff --git a/tests/markdown-notes/templates_spec.lua b/tests/markdown-notes/templates_spec.lua index fd0b013..3381b0f 100644 --- a/tests/markdown-notes/templates_spec.lua +++ b/tests/markdown-notes/templates_spec.lua @@ -2,94 +2,106 @@ local templates = require("markdown-notes.templates") local config = require("markdown-notes.config") describe("templates", function() - before_each(function() - config.options = {} - config.workspaces = {} - config.setup({}) - end) - - describe("substitute_template_vars", function() - it("substitutes date variables", function() - local content = {"Today is {{date}}", "Time is {{time}}"} - local result = templates.substitute_template_vars(content) - - assert.is_not.equal("Today is {{date}}", result[1]) - assert.is_not.equal("Time is {{time}}", result[2]) - assert.matches("%d%d%d%d%-%d%d%-%d%d", result[1]) - assert.matches("%d%d:%d%d", result[2]) - end) - - it("substitutes custom variables", function() - local content = {"Hello {{name}}"} - local custom_vars = {name = "World"} - local result = templates.substitute_template_vars(content, custom_vars) - - assert.are.equal("Hello World", result[1]) - end) - - it("handles function variables", function() - local content = {"Value is {{custom}}"} - local custom_vars = {custom = function() return "dynamic" end} - local result = templates.substitute_template_vars(content, custom_vars) - - assert.are.equal("Value is dynamic", result[1]) - end) - - it("handles multiple substitutions in one line", function() - local content = {"{{date}} - {{time}} - {{title}}"} - local result = templates.substitute_template_vars(content) - - assert.matches("%d%d%d%d%-%d%d%-%d%d", result[1]) - assert.matches("%d%d:%d%d", result[1]) - end) - - it("uses config-defined template variables", function() - -- Setup config with custom template variables - config.setup({ - template_vars = { - author = function() return "Test Author" end, - project = function() return "my-project" end, - custom_static = "static-content" - } - }) - - local content = { - "Author: {{author}}", - "Project: {{project}}", - "Static: {{custom_static}}", - "Date: {{date}}" - } - - local result = templates.substitute_template_vars(content) - - -- Custom variables should be substituted - assert.are.equal("Author: Test Author", result[1]) - assert.are.equal("Project: my-project", result[2]) - assert.are.equal("Static: static-content", result[3]) - - -- Default variables should still work - assert.matches("Date: %d%d%d%d%-%d%d%-%d%d", result[4]) - end) - - it("overrides config variables with custom vars parameter", function() - -- Setup config with a template variable - config.setup({ - template_vars = { - author = function() return "Config Author" end, - } - }) - - local content = {"Author: {{author}}"} - - -- Override with custom vars - local custom_vars = { - author = function() return "Override Author" end - } - - local result = templates.substitute_template_vars(content, custom_vars) - - -- Should use the override, not the config value - assert.are.equal("Author: Override Author", result[1]) - end) - end) -end) \ No newline at end of file + before_each(function() + config.options = {} + config.workspaces = {} + config.setup({}) + end) + + describe("substitute_template_vars", function() + it("substitutes date variables", function() + local content = { "Today is {{date}}", "Time is {{time}}" } + local result = templates.substitute_template_vars(content) + + assert.is_not.equal("Today is {{date}}", result[1]) + assert.is_not.equal("Time is {{time}}", result[2]) + assert.matches("%d%d%d%d%-%d%d%-%d%d", result[1]) + assert.matches("%d%d:%d%d", result[2]) + end) + + it("substitutes custom variables", function() + local content = { "Hello {{name}}" } + local custom_vars = { name = "World" } + local result = templates.substitute_template_vars(content, custom_vars) + + assert.are.equal("Hello World", result[1]) + end) + + it("handles function variables", function() + local content = { "Value is {{custom}}" } + local custom_vars = { + custom = function() + return "dynamic" + end, + } + local result = templates.substitute_template_vars(content, custom_vars) + + assert.are.equal("Value is dynamic", result[1]) + end) + + it("handles multiple substitutions in one line", function() + local content = { "{{date}} - {{time}} - {{title}}" } + local result = templates.substitute_template_vars(content) + + assert.matches("%d%d%d%d%-%d%d%-%d%d", result[1]) + assert.matches("%d%d:%d%d", result[1]) + end) + + it("uses config-defined template variables", function() + -- Setup config with custom template variables + config.setup({ + template_vars = { + author = function() + return "Test Author" + end, + project = function() + return "my-project" + end, + custom_static = "static-content", + }, + }) + + local content = { + "Author: {{author}}", + "Project: {{project}}", + "Static: {{custom_static}}", + "Date: {{date}}", + } + + local result = templates.substitute_template_vars(content) + + -- Custom variables should be substituted + assert.are.equal("Author: Test Author", result[1]) + assert.are.equal("Project: my-project", result[2]) + assert.are.equal("Static: static-content", result[3]) + + -- Default variables should still work + assert.matches("Date: %d%d%d%d%-%d%d%-%d%d", result[4]) + end) + + it("overrides config variables with custom vars parameter", function() + -- Setup config with a template variable + config.setup({ + template_vars = { + author = function() + return "Config Author" + end, + }, + }) + + local content = { "Author: {{author}}" } + + -- Override with custom vars + local custom_vars = { + author = function() + return "Override Author" + end, + } + + local result = templates.substitute_template_vars(content, custom_vars) + + -- Should use the override, not the config value + assert.are.equal("Author: Override Author", result[1]) + end) + end) +end) diff --git a/tests/markdown-notes/workspace_spec.lua b/tests/markdown-notes/workspace_spec.lua index 2c5feae..ec91c62 100644 --- a/tests/markdown-notes/workspace_spec.lua +++ b/tests/markdown-notes/workspace_spec.lua @@ -1,63 +1,61 @@ local config = require("markdown-notes.config") describe("workspace", function() - before_each(function() - config.options = {} - config.workspaces = {} - config.default_workspace = nil - end) - - describe("workspace configuration", function() - it("sets up workspace correctly", function() - config.setup_workspace("work", { vault_path = "/work/notes" }) - - local workspaces = config.get_workspaces() - assert.is_not_nil(workspaces.work) - assert.are.equal("/work/notes", workspaces.work.vault_path) - end) - - it("sets up multiple workspaces", function() - config.setup_workspace("work", { vault_path = "/work/notes" }) - config.setup_workspace("personal", { vault_path = "/personal/notes" }) - - local workspaces = config.get_workspaces() - assert.is_not_nil(workspaces.work) - assert.is_not_nil(workspaces.personal) - assert.are.equal("/work/notes", workspaces.work.vault_path) - assert.are.equal("/personal/notes", workspaces.personal.vault_path) - end) - - it("workspace inherits from defaults", function() - config.setup_workspace("test", { vault_path = "/test" }) - - local workspaces = config.get_workspaces() - local test_workspace = workspaces.test - - assert.are.equal("/test", test_workspace.vault_path) - assert.is_not_nil(test_workspace.templates_path) - assert.is_not_nil(test_workspace.template_vars) - assert.is_not_nil(test_workspace.mappings) - end) - end) - - describe("workspace detection", function() - before_each(function() - config.setup({ vault_path = "/default/notes" }) - config.setup_workspace("work", { vault_path = "/work/notes" }) - config.setup_workspace("personal", { vault_path = "/personal/notes" }) - -- Set work as default to match expected behavior - config.set_default_workspace("work") - end) - - it("uses first workspace as active workspace", function() - -- With simplified system, first workspace configured becomes active - local workspace_config, workspace_name = config.get_current_config() - - -- "work" is set up first in before_each, so it becomes the active workspace - assert.are.equal("work", workspace_name) - assert.are.equal("/work/notes", workspace_config.vault_path) - end) - - - end) -end) \ No newline at end of file + before_each(function() + config.options = {} + config.workspaces = {} + config.default_workspace = nil + end) + + describe("workspace configuration", function() + it("sets up workspace correctly", function() + config.setup_workspace("work", { vault_path = "/work/notes" }) + + local workspaces = config.get_workspaces() + assert.is_not_nil(workspaces.work) + assert.are.equal("/work/notes", workspaces.work.vault_path) + end) + + it("sets up multiple workspaces", function() + config.setup_workspace("work", { vault_path = "/work/notes" }) + config.setup_workspace("personal", { vault_path = "/personal/notes" }) + + local workspaces = config.get_workspaces() + assert.is_not_nil(workspaces.work) + assert.is_not_nil(workspaces.personal) + assert.are.equal("/work/notes", workspaces.work.vault_path) + assert.are.equal("/personal/notes", workspaces.personal.vault_path) + end) + + it("workspace inherits from defaults", function() + config.setup_workspace("test", { vault_path = "/test" }) + + local workspaces = config.get_workspaces() + local test_workspace = workspaces.test + + assert.are.equal("/test", test_workspace.vault_path) + assert.is_not_nil(test_workspace.templates_path) + assert.is_not_nil(test_workspace.template_vars) + assert.is_not_nil(test_workspace.mappings) + end) + end) + + describe("workspace detection", function() + before_each(function() + config.setup({ vault_path = "/default/notes" }) + config.setup_workspace("work", { vault_path = "/work/notes" }) + config.setup_workspace("personal", { vault_path = "/personal/notes" }) + -- Set work as default to match expected behavior + config.set_default_workspace("work") + end) + + it("uses first workspace as active workspace", function() + -- With simplified system, first workspace configured becomes active + local workspace_config, workspace_name = config.get_current_config() + + -- "work" is set up first in before_each, so it becomes the active workspace + assert.are.equal("work", workspace_name) + assert.are.equal("/work/notes", workspace_config.vault_path) + end) + end) +end) diff --git a/tests/minimal_init.vim b/tests/minimal_init.vim index bf4f596..ff7a088 100644 --- a/tests/minimal_init.vim +++ b/tests/minimal_init.vim @@ -2,4 +2,4 @@ set rtp+=. set rtp+=~/.local/share/nvim/site/pack/vendor/start/plenary.nvim set rtp+=~/.local/share/nvim/site/pack/vendor/start/fzf-lua -runtime plugin/plenary.vim \ No newline at end of file +runtime plugin/plenary.vim From 02b161b798f65a299c9063459ac2a0225ad3fe89 Mon Sep 17 00:00:00 2001 From: Jason Paris Date: Sun, 6 Jul 2025 22:26:27 -0400 Subject: [PATCH 3/3] ci: add linting to GitHub Actions workflow and update contributing docs - Rename workflow from 'Tests' to 'CI' for broader scope - Add parallel lint job with luacheck installation - Update test job to use make commands for consistency - Enhance README contributing section with development tools documentation - Document all Makefile targets and CI integration --- .github/workflows/test.yml | 22 ++++++++++++++++++++-- README.md | 25 +++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d3566d0..7d59a54 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Tests +name: CI on: push: @@ -7,6 +7,24 @@ on: branches: [ main, develop ] jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Lua and LuaRocks + run: | + sudo apt-get update + sudo apt-get install -y lua5.4 luarocks + + - name: Install luacheck + run: | + sudo luarocks install luacheck + + - name: Run linting + run: | + make lint + test: runs-on: ubuntu-latest strategy: @@ -29,4 +47,4 @@ jobs: - name: Run tests run: | - nvim --headless -c "PlenaryBustedDirectory tests/ { minimal_init = 'tests/minimal_init.vim' }" \ No newline at end of file + make test \ No newline at end of file diff --git a/README.md b/README.md index 5b697fc..ec3934d 100644 --- a/README.md +++ b/README.md @@ -465,10 +465,31 @@ We welcome contributions! Here's how to get started: git clone https://github.com/yourusername/markdown-notes.nvim.git cd markdown-notes.nvim -# Run tests (requires plenary.nvim) -nvim --headless -u tests/minimal_init.vim -c "lua require('plenary.test_harness').test_directory('tests')" +# Install luacheck for linting (optional but recommended) +luarocks install luacheck + +# Run tests +make test + +# Run linting +make lint + +# Fix linting issues with detailed output +make lint-fix ``` +### Development Tools + +This project includes several development tools accessible via the Makefile: + +- `make test` - Run all tests using plenary.nvim +- `make lint` - Run luacheck linter on source and test files +- `make lint-fix` - Run luacheck with detailed output for fixing issues +- `make clean` - Clean up temporary files +- `make check-deps` - Verify required tools are installed + +The project uses GitHub Actions for CI, which automatically runs both tests and linting on all pull requests. + ### Reporting Issues - Use GitHub Issues for bug reports and feature requests