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

Reply via email to