Thank you for the bug report! It is clear, concise, and easy to replicate.
I have come up with a fix attached as 4 patches. Tests pass after each commit on emacs 30. Tests pass on emacs 28, and 29 after final commit. Emacs parses the local variables in `hack-local-variables--find-variables'. There it ignores the local variables if they are not terminated with "End:", so I think we too can ignore them if they aren't properly terminated. I've implemented a change where we pull out the "Local Variable" block with more precision using the "End:". At first I wanted to only save the "Local Variable" block if it was after the last headline but I realized that it would still be used by emacs if it was within the last 3000 characters of the file so I scratched that idea. I've also included a patch to ensure that inserting a new headline with C-<return> inserts it before the local variables.
>From 47aff54ee31db54ed58446f1f9786b8c9dd2c403 Mon Sep 17 00:00:00 2001 From: Morgan Smith <[email protected]> Date: Thu, 4 Dec 2025 12:03:27 -0500 Subject: [PATCH 1/4] Preserve "Local Variables" block without preserving following text * lisp/org-macs.el (org-preserve-local-variables): Extract "Local Variables" block exactly to prevent preserving text that follows the block. --- lisp/org-macs.el | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/lisp/org-macs.el b/lisp/org-macs.el index c3be41d02..8b75e80ef 100644 --- a/lisp/org-macs.el +++ b/lisp/org-macs.el @@ -250,11 +250,18 @@ org-preserve-local-variables (org-with-wide-buffer (goto-char (point-max)) (let ((case-fold-search t)) - (and (re-search-backward "^[ \t]*# +Local Variables:" - (max (- (point) 3000) 1) - t) - (let ((buffer-undo-list t)) - (delete-and-extract-region (point) (point-max))))))) + (and (re-search-backward + ,(rx-let ((prefix + (seq line-start (zero-or-more whitespace) + "#" (one-or-more whitespace)))) + (rx prefix "Local Variables:" + (one-or-more anychar) + prefix "End:" + (zero-or-more whitespace) (optional "\n"))) + (max (- (point) 3000) 1) + t) + (let ((buffer-undo-list t)) + (delete-and-extract-region (match-beginning 0) (match-end 0))))))) (tick-counter-before (buffer-modified-tick))) (unwind-protect (progn ,@body) (when local-variables base-commit: 72db4de02d0080d78a5b4dda137ffdb1b1e7140f -- 2.51.2
>From 3bbe2c065ee4a50ecd200d9465ff03a4ef6c7db8 Mon Sep 17 00:00:00 2001 From: Morgan Smith <[email protected]> Date: Thu, 4 Dec 2025 12:05:35 -0500 Subject: [PATCH 2/4] Testing: Test moving a subtree with a "Local Variables" block * testing/lisp/test-org.el (test-org/move-subtree): Add tests that include a "Local Variables" block. --- testing/lisp/test-org.el | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/testing/lisp/test-org.el b/testing/lisp/test-org.el index e7769ba6c..09b9a09c4 100644 --- a/testing/lisp/test-org.el +++ b/testing/lisp/test-org.el @@ -5905,6 +5905,22 @@ test-org/move-subtree (org-test-with-temp-text "* H1\n** H1.2<point>\n" (org-metaup) (buffer-string))) + ;; Local variables + (let ((local-variable-string "# Local Variables: +# fill-column: 120 +# End:\n")) + (should + (equal (concat "* H2\n* H1\n" local-variable-string) + (org-test-with-temp-text + (concat "* H1<point>\n* H2\n" local-variable-string) + (org-metadown) + (buffer-string)))) + (should + (equal (concat "* H2\n* H1\n* H3\n" local-variable-string) + (org-test-with-temp-text + (concat "* H1<point>\n* H2\n" local-variable-string "* H3\n") + (org-metadown) + (buffer-string))))) ;; With selection (should (equal "* T\n** H3\n** H1\n** H2\n" -- 2.51.2
>From 951e37575e6ddbbab3f55801014be1d2c51e1843 Mon Sep 17 00:00:00 2001 From: Morgan Smith <[email protected]> Date: Thu, 4 Dec 2025 12:06:20 -0500 Subject: [PATCH 3/4] Preserve "Local Variables" block when inserting a heading *lisp/org.el (org-insert-heading): When we are respecting content, preserve the "Local Variables" block by using `org-preserve-local-variables'. --- lisp/org.el | 77 +++++++++++++++++++++++++++-------------------------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/lisp/org.el b/lisp/org.el index d0b349487..1a43a8cce 100644 --- a/lisp/org.el +++ b/lisp/org.el @@ -6616,44 +6616,45 @@ org-insert-heading (invisible-p (max (1- (point)) (point-min))))) ;; Position point at the location of insertion. Make sure we ;; end up on a visible headline if INVISIBLE-OK is nil. - (org-with-limited-levels - (if (not current-level) (outline-next-heading) ;before first headline - (org-back-to-heading invisible-ok) - (when (equal arg '(16)) (org-up-heading-safe)) - (org-end-of-subtree invisible-ok 'to-heading))) - ;; At `point-max', if the file does not have ending newline, - ;; create one, so that we are not appending stars at non-empty - ;; line. - (unless (bolp) (insert "\n")) - (when (and blank? (save-excursion - (backward-char) - (org-before-first-heading-p))) - (insert "\n") - (backward-char)) - (when (and (not current-level) (not (eobp)) (not (bobp))) - (when (org-at-heading-p) (insert "\n")) - (backward-char)) - (unless (and blank? (org-previous-line-empty-p)) - (org-N-empty-lines-before-current (if blank? 1 0))) - (insert stars " " "\n") - ;; Move point after stars. - (backward-char) - ;; Retain blank lines before next heading. - (funcall maybe-add-blank-after blank?) - ;; When INVISIBLE-OK is non-nil, ensure newly created headline - ;; is visible. - (unless invisible-ok - (if (eq org-fold-core-style 'text-properties) - (cond - ((org-fold-folded-p - (max (point-min) - (1- (line-beginning-position)))) - (org-fold-region (line-end-position 0) (line-end-position) nil)) - (t nil)) - (pcase (get-char-property-and-overlay (point) 'invisible) - (`(outline . ,o) - (move-overlay o (overlay-start o) (line-end-position 0))) - (_ nil))))) + (org-preserve-local-variables + (org-with-limited-levels + (if (not current-level) (outline-next-heading) ;before first headline + (org-back-to-heading invisible-ok) + (when (equal arg '(16)) (org-up-heading-safe)) + (org-end-of-subtree invisible-ok 'to-heading))) + ;; At `point-max', if the file does not have ending newline, + ;; create one, so that we are not appending stars at non-empty + ;; line. + (unless (bolp) (insert "\n")) + (when (and blank? (save-excursion + (backward-char) + (org-before-first-heading-p))) + (insert "\n") + (backward-char)) + (when (and (not current-level) (not (eobp)) (not (bobp))) + (when (org-at-heading-p) (insert "\n")) + (backward-char)) + (unless (and blank? (org-previous-line-empty-p)) + (org-N-empty-lines-before-current (if blank? 1 0))) + (insert stars " " "\n") + ;; Move point after stars. + (backward-char) + ;; Retain blank lines before next heading. + (funcall maybe-add-blank-after blank?) + ;; When INVISIBLE-OK is non-nil, ensure newly created headline + ;; is visible. + (unless invisible-ok + (if (eq org-fold-core-style 'text-properties) + (cond + ((org-fold-folded-p + (max (point-min) + (1- (line-beginning-position)))) + (org-fold-region (line-end-position 0) (line-end-position) nil)) + (t nil)) + (pcase (get-char-property-and-overlay (point) 'invisible) + (`(outline . ,o) + (move-overlay o (overlay-start o) (line-end-position 0))) + (_ nil)))))) ;; At a headline... ((org-at-heading-p) (cond ((bolp) -- 2.51.2
>From 00e3309a6e26afcd4f44f36d0efbac439ce19f47 Mon Sep 17 00:00:00 2001 From: Morgan Smith <[email protected]> Date: Thu, 4 Dec 2025 12:06:44 -0500 Subject: [PATCH 4/4] Testing: Test inserting a heading with a "Local Variables" block * testing/lisp/test-org.el (test-org/insert-heading): Test inserting a heading with a "Local Variables" block. --- testing/lisp/test-org.el | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/testing/lisp/test-org.el b/testing/lisp/test-org.el index 09b9a09c4..0c90bcef7 100644 --- a/testing/lisp/test-org.el +++ b/testing/lisp/test-org.el @@ -1880,6 +1880,20 @@ test-org/insert-heading (org-test-with-temp-text "<point>P" (org-insert-heading) (buffer-string)))) + ;; Move local variable string to end when we respect the content + (let ((local-variable-string "# Local Variables: +# fill-column: 120 +# End:\n")) + (should + (equal (concat "* \n" local-variable-string) + (org-test-with-temp-text local-variable-string + (org-insert-heading-respect-content) + (buffer-string)))) + (should + (equal (concat "* H\n* \n" local-variable-string) + (org-test-with-temp-text (concat "* H<point>\n" local-variable-string) + (org-insert-heading-respect-content) + (buffer-string))))) ;; In the middle of a line, split the line if allowed, otherwise, ;; insert the headline at its end. (should -- 2.51.2
