Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion grove-backlink.el
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
32 changes: 30 additions & 2 deletions grove-core.el
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)))

Expand Down
24 changes: 21 additions & 3 deletions grove-search.el
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
;;; Code:

(require 'grove-core)
(require 'subr-x)

(declare-function consult--grep "consult")
(declare-function consult--grep-make-builder "consult")
Expand Down Expand Up @@ -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)))))

Expand All @@ -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.
Expand All @@ -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))))))

Expand Down
42 changes: 42 additions & 0 deletions tests/grove-tags-test.el
Original file line number Diff line number Diff line change
@@ -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