I wrote the patch that caused this, so this is my bad. We can't just change
the order of "(append valid-symbols (list 'contiguous-spaces
'leading-and-trailing-spaces)))" as you suggested since
`org-link--remove-statistics-cookies' and 'org-link--remove-pipe-chars' can
add spaces to the search string. So instead we just trim twice, before we
attempt to normalize the search string and after we finish normalizing the
search string. I added new tests to confirm that this approach works.
From 9b7064c929528f2c6a3709873a3a66a19742f36a Mon Sep 17 00:00:00 2001
From: ApollonDeParnasse <[email protected]>
Date: Thu, 9 Jul 2026 13:04:44 -0500
Subject: [PATCH] lisp/ol.el: Trim search string before attempting to normalize
it.
* lisp/ol.el (org-link-normalize-string): Trim search string before
attempting to normalize it.
* testing/lisp/test-ol.el (test-org-link/precise-link-target):
New tests for `org-link-precise-link-target'.
---
lisp/ol.el | 8 +-
testing/lisp/test-ol.el | 158 ++++++++++++++++++++++++++++++++++++----
2 files changed, 149 insertions(+), 17 deletions(-)
diff --git a/lisp/ol.el b/lisp/ol.el
index 96edafa17..3d0e32bcc 100644
--- a/lisp/ol.el
+++ b/lisp/ol.el
@@ -989,10 +989,14 @@ subset of (statistics-cookies search-syntax pipe-chars):
- `search-syntax' is # in #heading and enclosing () in (ref)
- `pipe-chars' are | characters that may clash with table syntax."
(let* ((valid-symbols (seq-intersection ignored-contents (list 'statistics-cookies 'search-syntax 'pipe-chars)))
- (values-to-remove (append valid-symbols (list 'contiguous-spaces 'leading-and-trailing-spaces))))
+ (values-to-remove (append valid-symbols (list 'contiguous-spaces 'leading-and-trailing-spaces)))
+ ;; We trim twice: before we run our string normalizers
+ ;; and after in order to remove any spaces that were introduced
+ ;; by the normalization process
+ (trimmed-string (org-trim string)))
(seq-reduce (lambda (str func) (funcall (map-elt org-link--string-normalizers func) str))
values-to-remove
- string)))
+ trimmed-string)))
(defun org-link--reveal-maybe (region _)
"Reveal folded link in REGION when needed.
diff --git a/testing/lisp/test-ol.el b/testing/lisp/test-ol.el
index 1487f7d38..bb0c63a8b 100644
--- a/testing/lisp/test-ol.el
+++ b/testing/lisp/test-ol.el
@@ -713,21 +713,149 @@ See https://github.com/yantar92/org/issues/4."
(ert-deftest test-org-link/precise-link-target ()
"Test `org-link-precise-link-target` specifications."
- (org-test-with-temp-text "* H1<point>\n* H2\n"
- (should
- (equal '("*H1" "H1" 1)
- (org-link-precise-link-target))))
- (org-test-with-temp-text "* H1\n#+name: foo<point>\n#+begin_example\nhi\n#+end_example\n"
- (should
- (equal '("foo" "foo" 6)
- (org-link-precise-link-target))))
- (org-test-with-temp-text "\nText<point>\n* H1\n"
- (should
- (equal '("Text" nil 2)
- (org-link-precise-link-target))))
- (org-test-with-temp-text "\n<point>\n* H1\n"
- (should
- (equal nil (org-link-precise-link-target)))))
+ (cl-macrolet ((with-text-in-regular-buffer (text &rest body)
+ (org-with-gensyms (file inside-text buffer)
+ `(let ((,file (make-temp-file "org-test"))
+ (,inside-text (if (stringp ,text) ,text (eval ,text)))
+ ,buffer)
+ (with-temp-file ,file (insert ,inside-text))
+ (unwind-protect
+ (progn
+ (setq ,buffer (find-file ,file))
+ (when (re-search-forward "<point>" nil t)
+ (replace-match ""))
+ (progn ,@body))
+ (let ((kill-buffer-query-functions nil))
+ (org-test-kill-buffer ,buffer)
+ (delete-file ,file)))))))
+ (org-test-with-temp-text "* H1<point>\n* H2\n"
+ (should
+ (equal '("*H1" "H1" 1)
+ (org-link-precise-link-target))))
+ (org-test-with-temp-text "* H1\n#+name: foo<point>\n#+begin_example\nhi\n#+end_example\n"
+ (should
+ (equal '("foo" "foo" 6)
+ (org-link-precise-link-target))))
+ (org-test-with-temp-text "\nText<point>\n* H1\n"
+ (should
+ (equal '("Text" nil 2)
+ (org-link-precise-link-target))))
+ (org-test-with-temp-text "\n<point>\n* H1\n"
+ (should
+ (equal nil (org-link-precise-link-target))))
+ ;; Assert search-syntax is removed
+ ;;# + no leading spaces
+ (with-text-in-regular-buffer
+ "#not-a-heading"
+ (should (equal
+ (list "not-a-heading" nil 1)
+ (org-link-precise-link-target))))
+ (with-text-in-regular-buffer
+ "#still not a heading"
+ (should (equal
+ (list "still not a heading" nil 1)
+ (org-link-precise-link-target))))
+ ;;# + with leading spaces
+ (with-text-in-regular-buffer
+ " #not-a-heading"
+ (should (equal
+ (list "not-a-heading" nil 1)
+ (org-link-precise-link-target))))
+ (with-text-in-regular-buffer
+ " #still not a heading"
+ (should (equal
+ (list "still not a heading" nil 1)
+ (org-link-precise-link-target))))
+ ;;single set of parens + no leading spaces
+ (with-text-in-regular-buffer
+ "(let ((element (org-element-at-point)))"
+ (should (equal
+ (list "let ((element (org-element-at-point))" nil 1)
+ (org-link-precise-link-target))))
+ (with-text-in-regular-buffer
+ "(let ((element (org-element-at-point)))"
+ (progn (set-mark (point))
+ (goto-char (line-end-position)))
+ (should (equal
+ (list "let ((element (org-element-at-point))" nil 1)
+ (org-link-precise-link-target))))
+ (with-text-in-regular-buffer
+ "(cond (line (org-goto-line line)"
+ (should (equal
+ (list "cond (line (org-goto-line line" nil 1)
+ (org-link-precise-link-target))))
+ (with-text-in-regular-buffer
+ "(cond (line (org-goto-line line)"
+ (progn (set-mark (point))
+ (goto-char (line-end-position)))
+ (should (equal
+ (list "cond (line (org-goto-line line" nil 1)
+ (org-link-precise-link-target))))
+ ;;single set of paren + leading spaces
+ (with-text-in-regular-buffer
+ " (let ((element (org-element-at-point)))"
+ (should (equal
+ (list "let ((element (org-element-at-point))" nil 1)
+ (org-link-precise-link-target))))
+ (with-text-in-regular-buffer
+ " (let ((element (org-element-at-point)))"
+ (progn (set-mark (point))
+ (goto-char (line-end-position)))
+ (should (equal
+ (list "let ((element (org-element-at-point))" nil 1)
+ (org-link-precise-link-target))))
+ ;; multiple parens + no leading spaces
+ (with-text-in-regular-buffer
+ "((and (listp org-log-done) (memq 'done org-log-done))"
+ (should (equal
+ (list "and (listp org-log-done) (memq 'done org-log-done" nil 1)
+ (org-link-precise-link-target))))
+ (with-text-in-regular-buffer
+ "and (listp org-log-done) (memq 'done org-log-done"
+ (progn (set-mark (point))
+ (goto-char (line-end-position)))
+ (should (equal
+ (list "and (listp org-log-done) (memq 'done org-log-done" nil 1)
+ (org-link-precise-link-target))))
+ ;; multiple parens + leading spaces
+ (with-text-in-regular-buffer
+ " ((eq var 'prefix) (set-default-toplevel-value var nil))"
+ (should (equal
+ (list "eq var 'prefix) (set-default-toplevel-value var nil" nil 1)
+ (org-link-precise-link-target))))
+ (with-text-in-regular-buffer
+ " ((eq var 'prefix) (set-default-toplevel-value var nil))"
+ (progn (set-mark (point))
+ (goto-char (line-end-position)))
+ (should (equal
+ (list "eq var 'prefix) (set-default-toplevel-value var nil" nil 1)
+ (org-link-precise-link-target))))
+ ;; nothing but parens
+ (with-text-in-regular-buffer
+ "("
+ (should (equal
+ (list "(" nil 1)
+ (org-link-precise-link-target))))
+ (with-text-in-regular-buffer
+ "("
+ (progn (set-mark (point))
+ (goto-char (line-end-position)))
+ (should (equal
+ (list "(" nil 1)
+ (org-link-precise-link-target))))
+ ;; nothing but parens + leading spaces
+ (with-text-in-regular-buffer
+ " ((("
+ (should (equal
+ (list "(((" nil 1)
+ (org-link-precise-link-target))))
+ (with-text-in-regular-buffer
+ " (((("
+ (progn (set-mark (point))
+ (goto-char (line-end-position)))
+ (should (equal
+ (list "((((" nil 1)
+ (org-link-precise-link-target))))))
(defmacro test-ol-stored-link-with-text (text &rest body)
"Return :link and :description from link stored in body."
--
2.54.0