From beb2acd8eed058d72fccb05ab2886598befd14a1 Mon Sep 17 00:00:00 2001 From: Dima Gerasimov Date: Sat, 26 Sep 2026 14:41:28 +0100 Subject: [PATCH] feat: expose entry end line numbers --- src/orgparse/node.py | 24 ++++++++ src/orgparse/tests/test_corpus.py | 6 ++ src/orgparse/tests/test_line_numbers.py | 75 +++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 src/orgparse/tests/test_line_numbers.py diff --git a/src/orgparse/node.py b/src/orgparse/node.py index d31da07..d46d915 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -534,6 +534,30 @@ def __init__(self, env: OrgEnv, index: int | None = None) -> None: """ + @property + def end_linenumber(self) -> int: + """One-based, inclusive end line of this node's own source text. + + Includes the heading, metadata, and trailing blank lines, excluding descendants. + For :class:`OrgRootNode`, this covers the preamble before the first heading and returns 0 when it is empty. + Use ``node[-1].end_linenumber`` for the end of the entire subtree. + + >>> from orgparse import loads + >>> root = loads('''Preamble + ... * Parent + ... body + ... ** Child + ... child body + ... * Last''') + >>> [(node.linenumber, node.end_linenumber) for node in root] + [(1, 1), (2, 3), (4, 5), (6, 6)] + >>> root[1][-1].end_linenumber + 5 + >>> loads('* Heading').end_linenumber + 0 + """ + return self.linenumber + len(self._lines) - 1 + def __iter__(self): yield self level = self.level diff --git a/src/orgparse/tests/test_corpus.py b/src/orgparse/tests/test_corpus.py index 723807f..351151d 100644 --- a/src/orgparse/tests/test_corpus.py +++ b/src/orgparse/tests/test_corpus.py @@ -25,6 +25,7 @@ def test_corpus(path: Path) -> None: and formatting of populated timestamps. Check tag inclusion, parent/child links, a shared root, increasing heading line numbers, and heading levels against the source text. + Check that entry line ranges cover the source without gaps or overlaps. These are smoke and consistency checks; exact parsed values require separate expected-output tests. """ root = load(path) @@ -33,7 +34,10 @@ def test_corpus(path: Path) -> None: assert root.parent is None previous_line = 0 + previous_end = 0 for node in root: + assert node.linenumber == previous_end + 1 + previous_end = node.end_linenumber # Access lazy formatting as well as the eagerly parsed attributes. assert isinstance(node.heading, str) assert isinstance(node.body, str) @@ -68,3 +72,5 @@ def test_corpus(path: Path) -> None: assert parent is not None assert parent.level < node.level assert any(child is node for child in parent.children) + + assert previous_end == len(lines) diff --git a/src/orgparse/tests/test_line_numbers.py b/src/orgparse/tests/test_line_numbers.py new file mode 100644 index 0000000..8eb8260 --- /dev/null +++ b/src/orgparse/tests/test_line_numbers.py @@ -0,0 +1,75 @@ +from pathlib import Path + +import pytest + +from .. import load, loadi, loads + + +def test_entry_line_ranges() -> None: + root = loads('''\ +Preamble +#+TITLE: Example + +* TODO Parent +SCHEDULED: <2026-01-02 Fri> +:PROPERTIES: +:ID: parent +:END: +Body +#* Commented heading + +** Child +Child body + +*** Grandchild +* Sibling +body + +''') + assert [(node.linenumber, node.end_linenumber) for node in root] == [ + (1, 3), + (4, 11), + (12, 14), + (15, 15), + (16, 18), + ] + assert root[1][-1].end_linenumber == 15 + assert root[-1].end_linenumber == 18 + + +@pytest.mark.parametrize( + ('content', 'expected_end'), + [ + ('* Last', 1), + ('* Last\n', 1), + ('* Last\n\n', 2), + ('* Last\nbody', 2), + ('* Last\nbody\n', 2), + ('* Last\nbody\n\n', 3), + ('* Last\r\nbody\r\n\r\n', 3), + ], +) +def test_final_entry_end_linenumber(content: str, expected_end: int, tmp_path: Path) -> None: + path = tmp_path / 'input.org' + path.write_bytes(content.encode('utf-8')) + for root in (loads(content), loadi(content.splitlines()), load(path)): + [node] = root.children + assert node.linenumber == 1 + assert node.end_linenumber == expected_end + + +@pytest.mark.parametrize( + ('content', 'expected_end'), + [ + ('', 0), + ('\n', 1), + ('Preamble', 1), + ('Preamble\n\n', 2), + ('* Heading', 0), + ('Preamble\n* Heading', 1), + ], +) +def test_preamble_end_linenumber(content: str, expected_end: int) -> None: + root = loads(content) + assert root.linenumber == 1 + assert root.end_linenumber == expected_end