Fix empty section body when content follows a property drawer

section_body_text() checked for a drawer START (:NAME:) before checking
for a drawer END (:END:). But :END: itself matches the generic :NAME:
pattern, so the end line re-opened a drawer instead of closing it,
leaving in_drawer set and swallowing the body that followed. A section
whose only content came after a property drawer therefore rendered an
empty /body (and, by extension, empty fuzzy-resolve bodies and GUI
sections).

Reorder the two checks: when already inside a drawer, handle :END:
first and consume the line, before the drawer-start pattern can match
it. This also leaves planning-line and leading-blank handling intact.

Add regression tests: body after a drawer directly under the headline,
and body after a planning line plus drawer. Server tests: 63 passed.
This commit is contained in:
Levi Neely 2026-10-02 12:21:32 +02:00
parent 2f040bf810
commit de43a3721d
1 changed files with 35 additions and 8 deletions

View File

@ -1126,6 +1126,18 @@ fn section_body_text(content: &str, s: &Section) -> String {
continue; continue;
} }
// Handle drawer end first: when inside a drawer, consume lines until
// :END:. This must precede the drawer-start check below, because
// :END: also matches the generic :NAME: drawer-start pattern and
// would otherwise be misread as opening a new drawer (swallowing the
// body that follows).
if in_drawer {
if trimmed == ":END:" {
in_drawer = false;
}
continue;
}
// Handle drawer start // Handle drawer start
if trimmed.starts_with(':') && trimmed.ends_with(':') && trimmed.len() > 2 { if trimmed.starts_with(':') && trimmed.ends_with(':') && trimmed.len() > 2 {
let drawer_name = &trimmed[1..trimmed.len() - 1]; let drawer_name = &trimmed[1..trimmed.len() - 1];
@ -1135,14 +1147,6 @@ fn section_body_text(content: &str, s: &Section) -> String {
} }
} }
// Handle drawer end
if in_drawer {
if trimmed == ":END:" {
in_drawer = false;
}
continue;
}
// This is actual content // This is actual content
seen_content = true; seen_content = true;
result_lines.push(line); result_lines.push(line);
@ -2408,6 +2412,29 @@ mod tests {
(dir, state) (dir, state)
} }
#[test]
fn body_after_drawer_is_not_swallowed() {
// Regression: :END: matched the generic :NAME: drawer-start pattern,
// re-opening a drawer and swallowing the body that followed. A section
// whose only content follows a property drawer must still expose it.
let (_d, state) = state_with(
"#+TITLE: W\n* Task A\n:PROPERTIES:\n:ID: a1\n:END:\nThis is the body of A.\n",
);
let body = state.section_body("w", "a1").unwrap();
assert_eq!(body, "This is the body of A.");
}
#[test]
fn body_after_planning_and_drawer() {
// Planning line + property drawer, then body. All three preamble parts
// are stripped; only the body remains.
let (_d, state) = state_with(
"#+TITLE: W\n* TODO Blocked\nDEADLINE: <2025-01-01>\n:PROPERTIES:\n:BLOCKER: approval\n:END:\nWaiting on external input.\n",
);
let body = state.section_body("w", "blocked").unwrap();
assert_eq!(body, "Waiting on external input.");
}
#[test] #[test]
fn test_delete_section() { fn test_delete_section() {
let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n"); let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n");