Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/orgparse/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/orgparse/tests/test_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
75 changes: 75 additions & 0 deletions src/orgparse/tests/test_line_numbers.py
Original file line number Diff line number Diff line change
@@ -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
Loading