Hi,
Here is a patch implementing the long-standing FIXME in
org-set-tags-command: calling the command before the first heading now
sets #+FILETAGS instead of signaling a user-error.
The patch also fixes a latent buffer-boundary crash in
org-fast-tag-selection that only surfaces in the file-tags context, and
adds a new ert test (test-org/set-file-tags) with seven assertions.
Manually tested: insert with no existing #+FILETAGS line, in-place
update of an existing line, org-file-tags variable refresh after write.
Best,
Abdurahman Itani
From 051f8da149f021cff8a35ca5a475ae4bcdaa29c0 Mon Sep 17 00:00:00 2001
From: Abdurahman Itani <[email protected]>
Date: Wed, 10 Jun 2026 00:42:50 +0300
Subject: [PATCH] lisp, testing: Implement #+FILETAGS support in
org-set-tags-command
Calling org-set-tags-command before the first heading previously
signaled a user-error with "Setting file tags is not supported yet".
This commit implements the feature properly.
* lisp/org.el (org-set-tags-command): Replace the user-error with an
if branch. When point is before the first heading, read current file
tags from org-file-tags, position point at the #+FILETAGS line for
correct overlay placement, invoke the same tag selection UI (fast or
completing-read), and write the result back via org-set-file-tags.
(org-set-file-tags): New function. Find the #+FILETAGS keyword
(case-insensitively, bounded to the file header), replace its value
in-place, or insert a new #+filetags line if none exists. Refresh
org-file-tags via org-set-regexps-and-options.
(org-fast-tag-selection): Fix overlay positioning when point is at
buffer position 1. The fallback branch computed ov-start as
(1- (line-end-position)) which evaluates to 0 at the top of an empty
buffer -- before (point-min). Clamp with (max (point-min) ...) and
guard the buffer-substring call likewise.
* testing/lisp/test-org.el (test-org/set-file-tags): New test with
seven assertions covering: error on invalid input, insert when no
#+FILETAGS line exists, update existing (lowercase), update existing
(uppercase), string input, clear tags, and org-file-tags refresh.
---
lisp/org.el | 173 ++++++++++++++++++++++++++++-----------
testing/lisp/test-org.el | 43 ++++++++++
2 files changed, 167 insertions(+), 49 deletions(-)
diff --git a/lisp/org.el b/lisp/org.el
index af272a48f..8bff1a07f 100644
--- a/lisp/org.el
+++ b/lisp/org.el
@@ -12241,51 +12241,93 @@ in Lisp code use `org-set-tags' instead."
(lambda () (when (org-invisible-p) (org-end-of-subtree nil t))))))
(t
(save-excursion
- ;; FIXME: We need to add support setting #+FILETAGS.
- (when (org-before-first-heading-p)
- (user-error "Setting file tags is not supported yet"))
- (org-back-to-heading)
- (let* ((all-tags (org-get-tags))
- (local-table (or org-current-tag-alist (org-get-buffer-tags)))
- (table (setq org-last-tags-completion-table
- (append
- ;; Put local tags in front.
- local-table
- (cl-set-difference
- (org--tag-add-to-alist
- (and org-complete-tags-always-offer-all-agenda-tags
- (org-global-tags-completion-table
- (org-agenda-files)))
- local-table)
- local-table))))
- (current-tags
- (cl-remove-if (lambda (tag) (get-text-property 0 'inherited tag))
- all-tags))
- (inherited-tags
- (cl-remove-if-not (lambda (tag) (get-text-property 0 'inherited tag))
- all-tags))
- (tags
- (replace-regexp-in-string
- ;; Ignore all forbidden characters in tags.
- org-tag--invalid-char-re ":"
- (if (or (eq t org-use-fast-tag-selection)
- (and org-use-fast-tag-selection
- (delq nil (mapcar #'cdr table))))
- (org-fast-tag-selection
- current-tags
- inherited-tags
- table
- (and org-fast-tag-selection-include-todo org-todo-key-alist))
- (let ((org-add-colon-after-tag-completion (< 1 (length table)))
- (crm-separator "[ \t]*:[ \t]*"))
- (mapconcat #'identity
- (completing-read-multiple
- "Tags: "
- org-last-tags-completion-table
- nil nil (org-make-tag-string current-tags)
- 'org-tags-history)
- ":"))))))
- (org-set-tags tags)))))
+ (if (org-before-first-heading-p)
+ ;; Handle #+FILETAGS for file-level tags.
+ (let* ((current-tags
+ (mapcar #'substring-no-properties org-file-tags))
+ (local-table (or org-current-tag-alist (org-get-buffer-tags)))
+ (table (setq org-last-tags-completion-table
+ (append
+ ;; Put local tags in front.
+ local-table
+ (cl-set-difference
+ (org--tag-add-to-alist
+ (and org-complete-tags-always-offer-all-agenda-tags
+ (org-global-tags-completion-table
+ (org-agenda-files)))
+ local-table)
+ local-table))))
+ (tags
+ (replace-regexp-in-string
+ ;; Ignore all forbidden characters in tags.
+ org-tag--invalid-char-re ":"
+ (if (or (eq t org-use-fast-tag-selection)
+ (and org-use-fast-tag-selection
+ (delq nil (mapcar #'cdr table))))
+ (progn
+ ;; Position point at #+FILETAGS for overlay placement.
+ (let ((case-fold-search t))
+ (goto-char (point-min))
+ (when (re-search-forward
+ (org-make-options-regexp '("FILETAGS"))
+ nil t)
+ (beginning-of-line)))
+ (org-fast-tag-selection
+ current-tags nil table
+ (and org-fast-tag-selection-include-todo
+ org-todo-key-alist)))
+ (let ((org-add-colon-after-tag-completion (< 1 (length table)))
+ (crm-separator "[ \t]*:[ \t]*"))
+ (mapconcat #'identity
+ (completing-read-multiple
+ "File tags: "
+ org-last-tags-completion-table
+ nil nil (org-make-tag-string current-tags)
+ 'org-tags-history)
+ ":"))))))
+ (org-set-file-tags tags))
+ (org-back-to-heading)
+ (let* ((all-tags (org-get-tags))
+ (local-table (or org-current-tag-alist (org-get-buffer-tags)))
+ (table (setq org-last-tags-completion-table
+ (append
+ ;; Put local tags in front.
+ local-table
+ (cl-set-difference
+ (org--tag-add-to-alist
+ (and org-complete-tags-always-offer-all-agenda-tags
+ (org-global-tags-completion-table
+ (org-agenda-files)))
+ local-table)
+ local-table))))
+ (current-tags
+ (cl-remove-if (lambda (tag) (get-text-property 0 'inherited tag))
+ all-tags))
+ (inherited-tags
+ (cl-remove-if-not (lambda (tag) (get-text-property 0 'inherited tag))
+ all-tags))
+ (tags
+ (replace-regexp-in-string
+ ;; Ignore all forbidden characters in tags.
+ org-tag--invalid-char-re ":"
+ (if (or (eq t org-use-fast-tag-selection)
+ (and org-use-fast-tag-selection
+ (delq nil (mapcar #'cdr table))))
+ (org-fast-tag-selection
+ current-tags
+ inherited-tags
+ table
+ (and org-fast-tag-selection-include-todo org-todo-key-alist))
+ (let ((org-add-colon-after-tag-completion (< 1 (length table)))
+ (crm-separator "[ \t]*:[ \t]*"))
+ (mapconcat #'identity
+ (completing-read-multiple
+ "Tags: "
+ org-last-tags-completion-table
+ nil nil (org-make-tag-string current-tags)
+ 'org-tags-history)
+ ":"))))))
+ (org-set-tags tags))))))
;; `save-excursion' may not replace the point at the right
;; position.
(when (and (save-excursion (skip-chars-backward "*") (bolp))
@@ -12357,6 +12399,38 @@ This function assumes point is on a headline."
(when (and tags org-auto-align-tags) (org-align-tags))
(when tags-change? (run-hooks 'org-after-tags-change-hook))))))
+(defun org-set-file-tags (tags)
+ "Set the file tags for the current Org buffer to TAGS.
+TAGS may be a tags string like \"tag1:tag2\" or \":tag1:tag2:\",
+or a list of tags. If TAGS is nil or the empty string, clear
+file tags.
+
+Updates the #+FILETAGS keyword in the buffer and refreshes
+`org-file-tags'."
+ (let* ((tag-list
+ (pcase tags
+ ((pred listp) tags)
+ ((pred stringp) (split-string (org-trim tags) ":" t))
+ (_ (error "Invalid tag specification: %S" tags))))
+ (tags-string (org-make-tag-string tag-list)))
+ (org-with-wide-buffer
+ (let ((case-fold-search t))
+ (goto-char (point-min))
+ (if (re-search-forward
+ (org-make-options-regexp '("FILETAGS"))
+ (save-excursion
+ (and (re-search-forward org-outline-regexp-bol nil t)
+ (line-beginning-position)))
+ t)
+ (replace-match tags-string t t nil 2)
+ (unless (string-empty-p tags-string)
+ (goto-char (point-min))
+ (while (and (not (eobp))
+ (looking-at "[ \t]*#\\+"))
+ (forward-line 1))
+ (insert "#+filetags: " tags-string "\n")))))
+ (org-set-regexps-and-options 'tags-only)))
+
(defun org-change-tag-in-region (beg end tag off)
"Add or remove TAG for each entry in the region.
This works in the agenda, and also in an Org buffer."
@@ -12539,22 +12613,23 @@ Returns the new tags string, or nil to not change the current settings."
((guard ingroup)
(cl-decf tag-binding-chars-left))))
(setq ingroup nil) ; It t, it means malformed tag alist. Reset just in case.
- ;; Move global `org-tags-overlay' overlay to current heading.
+ ;; Move global `org-tags-overlay' overlay to current heading or keyword line.
;; Calls to `org-set-current-tags-overlay' will take care about
;; updating the overlay text.
- ;; FIXME: What if we are setting file tags?
(save-excursion
(forward-line 0)
(if (looking-at org-tag-line-re)
(setq ov-start (match-beginning 1)
ov-end (match-end 1)
ov-prefix "")
- (setq ov-start (1- (line-end-position))
+ (setq ov-start (max (point-min) (1- (line-end-position)))
ov-end (1+ ov-start))
(skip-chars-forward "^\n\r")
(setq ov-prefix
(concat
- (buffer-substring (1- (point)) (point))
+ (if (> (point) (point-min))
+ (buffer-substring (1- (point)) (point))
+ "")
(if (> (current-column) org-tags-column)
" "
(make-string (- org-tags-column (current-column)) ?\ ))))))
diff --git a/testing/lisp/test-org.el b/testing/lisp/test-org.el
index 80f95f3dd..390432174 100644
--- a/testing/lisp/test-org.el
+++ b/testing/lisp/test-org.el
@@ -8594,6 +8594,49 @@ Paragraph<point>"
;; And it should be visible (i.e. no overlays)
(overlays-at (point)))))
+(ert-deftest test-org/set-file-tags ()
+ "Test `org-set-file-tags' specifications."
+ ;; Throw an error on invalid data.
+ (should-error
+ (org-test-with-temp-text "* H"
+ (org-set-file-tags 'foo)))
+ ;; Insert #+filetags when no such line exists.
+ (should
+ (string-match-p "#\\+filetags: :tag1:tag2:"
+ (org-test-with-temp-text "Some content\n* H"
+ (org-set-file-tags '("tag1" "tag2"))
+ (buffer-string))))
+ ;; Updates existing #+filetags line, preserving the keyword prefix.
+ (should
+ (equal "#+filetags: :new1:\n* H"
+ (org-test-with-temp-text "#+filetags: :old1:old2:\n* H"
+ (org-set-file-tags '("new1"))
+ (buffer-string))))
+ ;; Case-insensitive match: updates uppercase #+FILETAGS line.
+ (should
+ (equal "#+FILETAGS: :new1:\n* H"
+ (org-test-with-temp-text "#+FILETAGS: :old1:\n* H"
+ (org-set-file-tags '("new1"))
+ (buffer-string))))
+ ;; Accepts a colon-delimited string without surrounding colons.
+ (should
+ (equal "#+filetags: :a:b:\n* H"
+ (org-test-with-temp-text "#+filetags: :x:\n* H"
+ (org-set-file-tags "a:b")
+ (buffer-string))))
+ ;; Clears tags when called with nil.
+ (should
+ (equal "#+filetags: \n* H"
+ (org-test-with-temp-text "#+filetags: :old:\n* H"
+ (org-set-file-tags nil)
+ (buffer-string))))
+ ;; Refreshes org-file-tags after writing.
+ (should
+ (equal '("tag1" "tag2")
+ (org-test-with-temp-text ""
+ (org-set-file-tags '("tag1" "tag2"))
+ (mapcar #'substring-no-properties org-file-tags)))))
+
(ert-deftest test-org/set-tags-command ()
"Test `org-set-tags-command' specifications."
;; Set tags at current headline.
--
2.54.0