From c1cd8691dd21b1478857482322e444d86e2e0f75 Mon Sep 17 00:00:00 2001 From: Guilherme Thomazi Bonicontro Date: Wed, 6 May 2026 11:30:15 +0200 Subject: [PATCH 1/2] fix: tag search parsing and shell-safe ripgrep globs --- grove-backlink.el | 2 +- grove-core.el | 32 ++++++++++++++++++++++++++++++-- grove-search.el | 24 +++++++++++++++++++++--- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/grove-backlink.el b/grove-backlink.el index 22ec8b8..5101225 100644 --- a/grove-backlink.el +++ b/grove-backlink.el @@ -48,7 +48,7 @@ Uses the cache if available, otherwise the filename." Each result is a plist (:file :line :context) found via ripgrep." (grove--ensure-directory) (let* ((pattern (format "\\[\\[%s\\]\\]" (regexp-quote title))) - (cmd (format "rg --no-heading --line-number --context 1 --glob=*.org %s %s" + (cmd (format "rg --no-heading --line-number --context 1 --glob='*.org' %s %s" (shell-quote-argument pattern) (shell-quote-argument grove-directory))) (output (shell-command-to-string cmd)) diff --git a/grove-core.el b/grove-core.el index 77136db..3fee498 100644 --- a/grove-core.el +++ b/grove-core.el @@ -67,6 +67,33 @@ Passed to `format-time-string'." "Hash table mapping absolute file paths to note metadata plists. Each value is a plist with keys :title :tags :links :mtime.") +(defconst grove--hashtag-regexp + "\\(?:^\\|[^[:alnum:]_#]\\)#\\([[:alnum:]_][[:alnum:]_/-]*\\)\\b" + "Regexp matching inline hashtags in note content.") + +(defun grove--collect-inline-tags () + "Return inline hashtags from the current buffer. +Matches #tag-style markers while ignoring Org keyword lines such as +#+title: and returns tags without the leading hash." + (let (tags) + (goto-char (point-min)) + (while (re-search-forward grove--hashtag-regexp nil t) + (push (match-string-no-properties 1) tags)) + (nreverse tags))) + +(defun grove--merge-tags (&rest tag-lists) + "Return unique tags from TAG-LISTS, preserving first-seen order." + (let ((seen (make-hash-table :test #'equal)) + result) + (dolist (tags tag-lists) + (dolist (tag tags) + (unless (or (null tag) + (string-empty-p tag) + (gethash tag seen)) + (puthash tag t seen) + (push tag result)))) + (nreverse result))) + (defun grove--ensure-directory () "Ensure `grove-directory' is set and exists, or prompt the user." (unless grove-directory @@ -107,10 +134,11 @@ Returns (:title TITLE :tags TAGS :links LINKS :mtime MTIME)." (goto-char (point-min)) (when (re-search-forward "^#\\+title:\\s-*\\(.+\\)" nil t) (setq title (string-trim (match-string 1)))) - ;; Extract #+filetags: or inline #hashtags + ;; Extract #+filetags: and inline #hashtags. (goto-char (point-min)) (when (re-search-forward "^#\\+filetags:\\s-*\\(.+\\)" nil t) (setq tags (split-string (match-string 1) ":" t "\\s-*"))) + (setq tags (grove--merge-tags tags (grove--collect-inline-tags))) ;; Extract [[wikilinks]] (goto-char (point-min)) (while (re-search-forward "\\[\\[\\([^]]+\\)\\]\\]" nil t) @@ -120,7 +148,7 @@ Returns (:title TITLE :tags TAGS :links LINKS :mtime MTIME)." (push link links))))) (list :title (or title (file-name-sans-extension (file-name-nondirectory file))) - :tags tags + :tags (and tags (copy-sequence tags)) :links (nreverse links) :mtime mtime))) diff --git a/grove-search.el b/grove-search.el index 79a7fa9..0423f1e 100644 --- a/grove-search.el +++ b/grove-search.el @@ -29,6 +29,7 @@ ;;; Code: (require 'grove-core) +(require 'subr-x) (declare-function consult--grep "consult") (declare-function consult--grep-make-builder "consult") @@ -58,7 +59,7 @@ available, otherwise falls back to `grep'." (defun grove-search--grep (&optional initial) "Search vault with grep. INITIAL is the initial input." (let ((pattern (read-string "Grove search: " initial))) - (grep (format "rg --no-heading --line-number --glob=*.org %s %s" + (grep (format "rg --no-heading --line-number --glob='*.org' %s %s" (shell-quote-argument pattern) (shell-quote-argument grove-directory))))) @@ -77,6 +78,23 @@ available, otherwise falls back to `grep'." ;;;; Tag search +(defun grove-search--normalize-tag (tag) + "Normalize TAG input from the minibuffer. +Accepts bare tag names plus #tag and :tag: syntax, and returns the +plain tag name." + (let ((normalized (string-trim tag))) + (setq normalized (replace-regexp-in-string "\\`#+" "" normalized)) + (setq normalized (replace-regexp-in-string "\\`:+\\|:+\\'" "" normalized)) + normalized)) + +(defun grove-search--tag-pattern (tag) + "Build a ripgrep pattern for TAG." + (let* ((normalized (grove-search--normalize-tag tag)) + (quoted (regexp-quote normalized))) + (when (string-empty-p normalized) + (user-error "Tag cannot be empty")) + (format "(#%s\\b|:%s:)" quoted quoted))) + ;;;###autoload (defun grove-search-tag (&optional initial) "Search for notes by tag. @@ -85,13 +103,13 @@ With optional INITIAL input string. Searches for both org-style (interactive) (grove--ensure-directory) (let* ((tag (or initial (read-string "Tag: "))) - (pattern (format "(#%s\\b|:%s:)" tag tag))) + (pattern (grove-search--tag-pattern tag))) (if (featurep 'consult) (let ((consult-ripgrep-args (concat consult-ripgrep-args " --glob=*.org"))) (consult--grep "Grove tags" #'consult--grep-make-builder grove-directory pattern)) - (grep (format "rg --no-heading --line-number --glob=*.org %s %s" + (grep (format "rg --no-heading --line-number --glob='*.org' %s %s" (shell-quote-argument pattern) (shell-quote-argument grove-directory)))))) From 0df7b185f38e122be6582b7ab12711e5f0eb6948 Mon Sep 17 00:00:00 2001 From: Guilherme Thomazi Bonicontro Date: Wed, 6 May 2026 11:37:07 +0200 Subject: [PATCH 2/2] feat: add tag tests --- tests/grove-tags-test.el | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/grove-tags-test.el diff --git a/tests/grove-tags-test.el b/tests/grove-tags-test.el new file mode 100644 index 0000000..1a2ca9a --- /dev/null +++ b/tests/grove-tags-test.el @@ -0,0 +1,42 @@ +;;; grove-tags-test.el --- Tag handling tests for grove -*- lexical-binding: t -*- + +;; Copyright 2026 Guilherme Thomazi Bonicontro + +;;; Commentary: + +;; Tests covering tag parsing and search pattern construction. + +;;; Code: + +(require 'ert) +(require 'grove-core) +(require 'grove-search) + +(ert-deftest grove-parse-note-collects-inline-hashtags () + (let ((file (make-temp-file "grove-tags" nil ".org" + "#+title: Tag test\n\nBody with #alpha and #beta.\n"))) + (unwind-protect + (should (equal (plist-get (grove--parse-note file) :tags) + '("alpha" "beta"))) + (delete-file file)))) + +(ert-deftest grove-parse-note-merges-filetags-and-inline-hashtags () + (let ((file (make-temp-file "grove-tags" nil ".org" + "#+title: Tag test\n#+filetags: :alpha:beta:\n\nBody with #beta and #gamma.\n"))) + (unwind-protect + (should (equal (plist-get (grove--parse-note file) :tags) + '("alpha" "beta" "gamma"))) + (delete-file file)))) + +(ert-deftest grove-search-tag-normalizes-documented-input-forms () + (should (equal (grove-search--normalize-tag "work") "work")) + (should (equal (grove-search--normalize-tag "#work") "work")) + (should (equal (grove-search--normalize-tag ":work:") "work")) + (should (equal (grove-search--normalize-tag " #work ") "work"))) + +(ert-deftest grove-search-tag-pattern-escapes-regexp-characters () + (should (equal (grove-search--tag-pattern "#a+b?") + "(#a\\+b\\?\\b|:a\\+b\\?:)"))) + +(provide 'grove-tags-test) +;;; grove-tags-test.el ends here