Hi Ihor,
Thank you for the reminder, indeed, I spent some considerable time
trying to resolve your comments. Here's a v2 patch addressing all four
issues, plus your nit about quoting Elisp symbols in the commit message:
1. #+filetags may not be before first heading
Fixed. The lookup no longer bounds the search to "before the first
heading" -- it scans the whole (widened) buffer, so a '#+filetags:' line
after a heading is found and updated correctly.
2. Not everything matching #+filetags: ... is a keyword
Fixed exactly as suggested. Every regexp match is now validated with
'org-element-at-point' (checking it parses as a 'keyword' node with
':key' "FILETAGS"), so a line that merely looks like '#+filetags:...'
inside a '#+begin_comment' or '#+begin_example' block is correctly ignored.
3. Fast tag selection overlay is appended, not replacing the tag list
Fixed. 'org-fast-tag-selection' now recognizes a '#+filetags:' line and
anchors the overlay to the existing tag-value text (mirroring how it
already handles headline tag lines), so the live selection preview
replaces the tags in place instead of being appended after them.
4. Multiple #+filetags: lines are allowed and combined
Handled explicitly rather than silently guessing in either direction:
when more than one valid '#+FILETAGS:' line exists, writing back now
asks (via 'y-or-n-p') whether to merge them into a single line at the
first one's location. On confirmation, the first line is updated and the
others are deleted; on refusal, nothing in the buffer is touched and a
'user-error' is signaled.
Also, while re-checking the Worg contribution guide, I noticed
the original patch never added an 'etc/ORG-NEWS' entry, which is
expected for a new feature -- added in v2.
v2 patch attached. All four points were re-tested manually, plus the
existing and newly added ERT tests pass (`make test`).
Thanks again for the thorough review.
On 8/6/26 8:23 PM, Ihor Radchenko wrote:
Ihor Radchenko <[email protected]> writes:
Abe Itani <[email protected]> writes:
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.
Thanks a lot for implementing this long-standing missing feature!
<more comments ...>
It has been a month.
Did you have a chance to look into my comments?
From f719d993477019ee68f4d7ebefc9fa9931af9190 Mon Sep 17 00:00:00 2001
From: Abdurahman Itani <[email protected]>
Date: Fri, 7 Aug 2026 16:13:52 +0300
Subject: [PATCH] lisp, testing, etc: Fix #+FILETAGS review issues in
org-set-tags-command
Ihor Radchenko reviewed the initial '#+FILETAGS' patch and found four
issues by manual testing: (1) '#+filetags:' keyword lines after the
first heading were not recognized, (2) a naive regexp match did not
verify a matched line was a real keyword rather than e.g. text inside
a '#+begin_comment' block, (3) the fast-tag-selection overlay was
appended after the existing tags instead of replacing them, and (4)
multiple '#+FILETAGS:' lines, which Org allows and combines, were not
handled when writing the tags back. This commit addresses all four.
* lisp/org.el (org--file-tags-keyword-positions): New function.
Scan the whole (widened) buffer for '#+FILETAGS:' keyword lines,
validating each match with 'org-element-at-point' so that lines
inside a block such as '#+begin_comment' or '#+begin_example' are
correctly excluded. Return an ordered list of buffer positions for
every valid line.
(org-set-tags-command): Use the new function to position point at the
first valid '#+FILETAGS:' line, if any, for overlay placement, instead
of a bounded, unvalidated 're-search-forward'.
(org-set-file-tags): Use the new function instead of a search bounded
to the first heading. When no valid line exists, insert one as
before. When exactly one exists, replace its value in place. When
more than one exists, ask with 'y-or-n-p' whether to merge them into
a single line at the first one's location; on confirmation, update
the first line and delete the rest, on refusal, signal a 'user-error'
and leave the buffer unmodified.
(org-fast-tag-selection): Add a branch to the overlay-positioning
'cond' recognizing a '#+filetags:' line, so that the selection overlay
replaces the existing tag value instead of being appended after it.
* testing/lisp/test-org.el (test-org/set-file-tags): Add cases for a
'#+filetags:' line after the first heading, decoy lines inside
'#+begin_example' and '#+begin_comment' blocks, and merging multiple
valid lines on confirmation or leaving the buffer untouched on
refusal.
* etc/ORG-NEWS: Add a "New features" entry for the '#+FILETAGS:'
support introduced in org-set-tags-command, which the previous commit
omitted.
---
etc/ORG-NEWS | 8 +++
lisp/org.el | 135 ++++++++++++++++++++++++++++++++-------
testing/lisp/test-org.el | 57 ++++++++++++++++-
3 files changed, 177 insertions(+), 23 deletions(-)
diff --git a/etc/ORG-NEWS b/etc/ORG-NEWS
index 5d778ebfe..156b8755f 100644
--- a/etc/ORG-NEWS
+++ b/etc/ORG-NEWS
@@ -47,6 +47,14 @@ the correct ~(FILE . ...)~ cons.
# We list the most important features, and the features that may
# require user action to be used.
+*** =#+FILETAGS:= can now be set interactively
+
+Calling ~org-set-tags-command~ (=C-c C-q=) before the first heading no
+longer signals a "not supported" error. It now edits the file's
+=#+FILETAGS:= keyword instead, using the same completion or fast tag
+selection interface used for headline tags. See the new function
+~org-set-file-tags~.
+
*** Link targets can be used as search option in =id:= links
When storing an =id:= link to target, the target will now be used as a
diff --git a/lisp/org.el b/lisp/org.el
index 8bff1a07f..15d46999d 100644
--- a/lisp/org.el
+++ b/lisp/org.el
@@ -12210,6 +12210,47 @@ tags."
;; it now points to BLANK-START. Use COLUMN instead.
(if in-blank? (org-move-to-column column) (goto-char origin))))))
+(defun org--file-tags-keyword-positions ()
+ "Return buffer positions of every valid \"#+FILETAGS:\" keyword line.
+
+Search the whole buffer (regardless of any active narrowing) for
+lines that look like a FILETAGS keyword, then keep only the ones
+`org-element-at-point' actually parses as a `keyword' node whose
+`:key' is \"FILETAGS\" -- this excludes lines that merely match
+the regexp while being inside a block such as
+\"#+begin_example\"...\"#+end_example\" or
+\"#+begin_comment\"...\"#+end_comment\".
+
+Return an ordered list (by ascending buffer position) of plists,
+one per valid line, with the following properties:
+
+ :line-beg position of the beginning of the line
+ :line-end position one past the end of the line, i.e. including
+ its trailing newline (or `point-max' if the line is
+ the last line of the buffer and has no newline)
+ :value-beg position of the beginning of the keyword's value,
+ i.e. right after \"#+filetags:\" and any blanks
+ :value-end position of the end of the keyword's value (end of
+ line)"
+ (org-with-wide-buffer
+ (goto-char (point-min))
+ (let ((case-fold-search t)
+ (regexp (org-make-options-regexp '("FILETAGS")))
+ positions)
+ (while (re-search-forward regexp nil t)
+ (let ((line-beg (match-beginning 0))
+ (value-beg (match-beginning 2))
+ (value-end (match-end 2))
+ (element (save-match-data (org-element-at-point))))
+ (when (and (org-element-type-p element 'keyword)
+ (equal (org-element-property :key element) "FILETAGS"))
+ (push (list :line-beg line-beg
+ :line-end (min (point-max) (1+ value-end))
+ :value-beg value-beg
+ :value-end value-end)
+ positions))))
+ (nreverse positions))))
+
(defun org-set-tags-command (&optional arg)
"Set the tags for the current visible entry.
@@ -12265,13 +12306,16 @@ in Lisp code use `org-set-tags' instead."
(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)))
+ ;; Position point at the first valid #+FILETAGS
+ ;; keyword line, if any, for overlay placement.
+ ;; (If several valid lines exist, only the first
+ ;; matters here; multi-line consolidation is
+ ;; handled later, when writing back, by
+ ;; `org-set-file-tags'.)
+ (let ((first
+ (car (org--file-tags-keyword-positions))))
+ (when first
+ (goto-char (plist-get first :line-beg))))
(org-fast-tag-selection
current-tags nil table
(and org-fast-tag-selection-include-todo
@@ -12405,6 +12449,24 @@ 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.
+This function looks for `#+FILETAGS:' keyword lines anywhere in
+the buffer (not just before the first heading), and only
+considers lines that are actually parsed as a FILETAGS keyword
+by `org-element-at-point' -- e.g. a line that merely looks like
+\"#+filetags:...\" inside an example or comment block is
+ignored.
+
+If no valid `#+FILETAGS:' line exists, one is inserted among the
+other leading keyword lines (or the file tags are left unset if
+TAGS is empty). If exactly one exists, its value is replaced in
+place, preserving the existing keyword's letter case. If more
+than one exists, the user is asked, with `y-or-n-p', whether to
+merge them into a single line at the first one's location; on
+confirmation the first line is updated and all other valid
+`#+FILETAGS:' lines are deleted (including their trailing
+newline); on refusal, a `user-error' is signaled and the buffer
+is left completely unmodified.
+
Updates the #+FILETAGS keyword in the buffer and refreshes
`org-file-tags'."
(let* ((tag-list
@@ -12414,21 +12476,43 @@ Updates the #+FILETAGS keyword in the buffer and refreshes
(_ (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)
+ (let* ((positions (org--file-tags-keyword-positions))
+ (first (car positions))
+ (rest (cdr positions)))
+ (cond
+ ;; No existing FILETAGS line: insert one, as before.
+ ((null positions)
(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")))))
+ (insert "#+filetags: " tags-string "\n")))
+ ;; Exactly one existing line: replace its value in place.
+ ((null rest)
+ (delete-region (plist-get first :value-beg)
+ (plist-get first :value-end))
+ (goto-char (plist-get first :value-beg))
+ (insert tags-string))
+ ;; Multiple existing lines: ask whether to merge.
+ (t
+ (or (y-or-n-p
+ (format "%d #+FILETAGS: lines found; merge into one at \
+the first location? "
+ (length positions)))
+ (user-error
+ "Aborted: multiple #+FILETAGS lines found, buffer not modified"))
+ ;; Delete extra lines from last to second (descending buffer
+ ;; order) so a deletion never invalidates an unprocessed
+ ;; position; FIRST is always earlier than everything in REST,
+ ;; so its own stored positions are never shifted by any of
+ ;; these deletions and it can safely be updated last.
+ (dolist (pos (reverse rest))
+ (delete-region (plist-get pos :line-beg) (plist-get pos :line-end)))
+ (delete-region (plist-get first :value-beg)
+ (plist-get first :value-end))
+ (goto-char (plist-get first :value-beg))
+ (insert tags-string)))))
(org-set-regexps-and-options 'tags-only)))
(defun org-change-tag-in-region (beg end tag off)
@@ -12618,10 +12702,17 @@ Returns the new tags string, or nil to not change the current settings."
;; updating the overlay text.
(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 "")
+ (cond
+ ((looking-at org-tag-line-re)
+ (setq ov-start (match-beginning 1)
+ ov-end (match-end 1)
+ ov-prefix ""))
+ ((let ((case-fold-search t))
+ (looking-at (org-make-options-regexp '("FILETAGS"))))
+ (setq ov-start (match-beginning 2)
+ ov-end (match-end 2)
+ ov-prefix ""))
+ (t
(setq ov-start (max (point-min) (1- (line-end-position)))
ov-end (1+ ov-start))
(skip-chars-forward "^\n\r")
@@ -12632,7 +12723,7 @@ Returns the new tags string, or nil to not change the current settings."
"")
(if (> (current-column) org-tags-column)
" "
- (make-string (- org-tags-column (current-column)) ?\ ))))))
+ (make-string (- org-tags-column (current-column)) ?\ )))))))
(move-overlay org-tags-overlay ov-start ov-end)
;; Highlight tags overlay in Org buffer.
(org-set-current-tags-overlay current-tags ov-prefix)
diff --git a/testing/lisp/test-org.el b/testing/lisp/test-org.el
index 390432174..1bdb72965 100644
--- a/testing/lisp/test-org.el
+++ b/testing/lisp/test-org.el
@@ -8635,7 +8635,62 @@ Paragraph<point>"
(equal '("tag1" "tag2")
(org-test-with-temp-text ""
(org-set-file-tags '("tag1" "tag2"))
- (mapcar #'substring-no-properties org-file-tags)))))
+ (mapcar #'substring-no-properties org-file-tags))))
+ ;; A #+filetags line after the first heading is still valid and
+ ;; is updated in place, without inserting a spurious extra line
+ ;; before the heading.
+ (should
+ (equal "* H\nSome text\n#+filetags: :new1:\n"
+ (org-test-with-temp-text "* H\nSome text\n#+filetags: :old:\n"
+ (org-set-file-tags '("new1"))
+ (buffer-string))))
+ ;; A line that merely looks like #+filetags but is inside an
+ ;; example block is not a real keyword and must be ignored.
+ (should
+ (equal "#+filetags: :new1:\n#+begin_example\n#+filetags: :fake:\n#+end_example\n* H"
+ (org-test-with-temp-text
+ "#+filetags: :real:\n#+begin_example\n#+filetags: :fake:\n#+end_example\n* H"
+ (org-set-file-tags '("new1"))
+ (buffer-string))))
+ ;; Same, but inside a comment block.
+ (should
+ (equal "#+filetags: :new1:\n#+begin_comment\n#+filetags: :fake:\n#+end_comment\n* H"
+ (org-test-with-temp-text
+ "#+filetags: :real:\n#+begin_comment\n#+filetags: :fake:\n#+end_comment\n* H"
+ (org-set-file-tags '("new1"))
+ (buffer-string))))
+ ;; A decoy inside a comment block with no real #+filetags line
+ ;; elsewhere: a new line must still be inserted, not mistaken for
+ ;; the decoy (it lands after the leading #+ lines, including the
+ ;; comment block, matching the pre-existing insertion behavior).
+ (should
+ (equal "#+begin_comment\n#+filetags: :fake:\n#+end_comment\n#+filetags: :new1:\n* H"
+ (org-test-with-temp-text
+ "#+begin_comment\n#+filetags: :fake:\n#+end_comment\n* H"
+ (org-set-file-tags '("new1"))
+ (buffer-string))))
+ ;; Multiple valid #+filetags lines, confirmed: merge into the
+ ;; first line's location and delete the rest.
+ (should
+ (equal "#+filetags: :new1:\n* H"
+ (org-test-with-temp-text "#+filetags: :a:\n#+filetags: :b:\n* H"
+ (cl-letf (((symbol-function 'y-or-n-p) (lambda (&rest _) t)))
+ (org-set-file-tags '("new1")))
+ (buffer-string))))
+ ;; Multiple valid #+filetags lines, declined: abort with the
+ ;; buffer completely unmodified.
+ (let ((text "#+filetags: :a:\n#+filetags: :b:\n* H"))
+ (should-error
+ (org-test-with-temp-text text
+ (cl-letf (((symbol-function 'y-or-n-p) 'ignore))
+ (org-set-file-tags '("new1")))))
+ (should
+ (equal text
+ (org-test-with-temp-text text
+ (ignore-errors
+ (cl-letf (((symbol-function 'y-or-n-p) 'ignore))
+ (org-set-file-tags '("new1"))))
+ (buffer-string))))))
(ert-deftest test-org/set-tags-command ()
"Test `org-set-tags-command' specifications."
--
2.43.0