From de43a3721dc8397627388696ba97960bd5f2dda0 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 2 Oct 2026 12:21:32 +0200 Subject: [PATCH] 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. --- crates/ork-server/src/state.rs | 43 +++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index 1942098..eb452a6 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -1126,6 +1126,18 @@ fn section_body_text(content: &str, s: &Section) -> String { 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 if trimmed.starts_with(':') && trimmed.ends_with(':') && trimmed.len() > 2 { 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 seen_content = true; result_lines.push(line); @@ -2408,6 +2412,29 @@ mod tests { (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] fn test_delete_section() { let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n");