outliner-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. outliner_cli-0.1.0/LICENSE +21 -0
  2. outliner_cli-0.1.0/PKG-INFO +126 -0
  3. outliner_cli-0.1.0/README.md +113 -0
  4. outliner_cli-0.1.0/pyproject.toml +31 -0
  5. outliner_cli-0.1.0/setup.cfg +4 -0
  6. outliner_cli-0.1.0/src/outliner/__init__.py +1 -0
  7. outliner_cli-0.1.0/src/outliner/cli.py +115 -0
  8. outliner_cli-0.1.0/src/outliner/parsers/__init__.py +36 -0
  9. outliner_cli-0.1.0/src/outliner/parsers/asciidoc.py +89 -0
  10. outliner_cli-0.1.0/src/outliner/parsers/c.py +281 -0
  11. outliner_cli-0.1.0/src/outliner/parsers/clojure.py +238 -0
  12. outliner_cli-0.1.0/src/outliner/parsers/csharp.py +243 -0
  13. outliner_cli-0.1.0/src/outliner/parsers/go.py +66 -0
  14. outliner_cli-0.1.0/src/outliner/parsers/java.py +102 -0
  15. outliner_cli-0.1.0/src/outliner/parsers/javascript.py +312 -0
  16. outliner_cli-0.1.0/src/outliner/parsers/markdown.py +153 -0
  17. outliner_cli-0.1.0/src/outliner/parsers/orgmode.py +120 -0
  18. outliner_cli-0.1.0/src/outliner/parsers/perl.py +100 -0
  19. outliner_cli-0.1.0/src/outliner/parsers/php.py +90 -0
  20. outliner_cli-0.1.0/src/outliner/parsers/python.py +73 -0
  21. outliner_cli-0.1.0/src/outliner/parsers/rst.py +102 -0
  22. outliner_cli-0.1.0/src/outliner/parsers/ruby.py +88 -0
  23. outliner_cli-0.1.0/src/outliner/parsers/rust.py +82 -0
  24. outliner_cli-0.1.0/src/outliner/parsers/scala.py +127 -0
  25. outliner_cli-0.1.0/src/outliner/parsers/shell.py +62 -0
  26. outliner_cli-0.1.0/src/outliner/parsers/swift.py +116 -0
  27. outliner_cli-0.1.0/src/outliner/parsers/util.py +58 -0
  28. outliner_cli-0.1.0/src/outliner/parsers/zig.py +103 -0
  29. outliner_cli-0.1.0/src/outliner/types.py +8 -0
  30. outliner_cli-0.1.0/src/outliner_cli.egg-info/PKG-INFO +126 -0
  31. outliner_cli-0.1.0/src/outliner_cli.egg-info/SOURCES.txt +54 -0
  32. outliner_cli-0.1.0/src/outliner_cli.egg-info/dependency_links.txt +1 -0
  33. outliner_cli-0.1.0/src/outliner_cli.egg-info/entry_points.txt +2 -0
  34. outliner_cli-0.1.0/src/outliner_cli.egg-info/top_level.txt +1 -0
  35. outliner_cli-0.1.0/tests/test_asciidoc.py +294 -0
  36. outliner_cli-0.1.0/tests/test_c.py +534 -0
  37. outliner_cli-0.1.0/tests/test_cli.py +130 -0
  38. outliner_cli-0.1.0/tests/test_clojure.py +434 -0
  39. outliner_cli-0.1.0/tests/test_csharp.py +374 -0
  40. outliner_cli-0.1.0/tests/test_go.py +284 -0
  41. outliner_cli-0.1.0/tests/test_java.py +389 -0
  42. outliner_cli-0.1.0/tests/test_javascript.py +565 -0
  43. outliner_cli-0.1.0/tests/test_markdown.py +233 -0
  44. outliner_cli-0.1.0/tests/test_orgmode.py +333 -0
  45. outliner_cli-0.1.0/tests/test_parsers.py +96 -0
  46. outliner_cli-0.1.0/tests/test_perl.py +490 -0
  47. outliner_cli-0.1.0/tests/test_php.py +413 -0
  48. outliner_cli-0.1.0/tests/test_python.py +294 -0
  49. outliner_cli-0.1.0/tests/test_rst.py +252 -0
  50. outliner_cli-0.1.0/tests/test_ruby.py +440 -0
  51. outliner_cli-0.1.0/tests/test_rust.py +581 -0
  52. outliner_cli-0.1.0/tests/test_scala.py +448 -0
  53. outliner_cli-0.1.0/tests/test_shell.py +317 -0
  54. outliner_cli-0.1.0/tests/test_swift.py +479 -0
  55. outliner_cli-0.1.0/tests/test_util.py +43 -0
  56. outliner_cli-0.1.0/tests/test_zig.py +365 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Per Cederberg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: outliner-cli
3
+ Version: 0.1.0
4
+ Summary: Print the structural outline of source files for LLM navigation
5
+ Author: Per Cederberg
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/cederberg/incubator/tree/main/outliner
8
+ Project-URL: Repository, https://github.com/cederberg/incubator
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # outliner
15
+
16
+ Print the structural outline of source files — declarations with line ranges —
17
+ so an LLM agent (or human) can navigate a file without reading it whole.
18
+
19
+ ## Usage
20
+
21
+ ```
22
+ outliner [OPTIONS] [FILE...]
23
+ ```
24
+
25
+ | Option | Description |
26
+ | ------------------- | --------------------------------------------------------------- |
27
+ | `-g, --grep EXPR` | Only show items whose signature matches EXPR (case-insensitive) |
28
+ | `-s, --syntax LANG` | Override syntax auto-detection when it is ambiguous |
29
+
30
+ Pass a file, a directory (walked recursively), or omit arguments to read stdin.
31
+ Use `-` to read stdin explicitly. `--syntax` is only needed when content
32
+ auto-detection cannot identify the language (e.g. an ambiguous extensionless
33
+ script piped on stdin).
34
+
35
+ ## Output
36
+
37
+ ```
38
+ 3,4 type Driver struct
39
+ 19,6 func New() *Driver
40
+ 26,12 func (d *Driver) StartLogging(ctx context.Context, f *os.File) error
41
+ ```
42
+
43
+ Each line: `<start>,<count> <signature>`
44
+
45
+ - `start` — 1-based line number, right-aligned
46
+ - `count` — number of lines covered by the item (including doc-comments above)
47
+ - `signature` — first non-comment line of the declaration; multi-line signatures
48
+ are merged into one line
49
+
50
+ Nesting is visible in two ways: overlapping ranges (a class range contains its
51
+ methods) and native-format indentation in the signature (indented for code,
52
+ `#`/`##` heading levels for Markdown).
53
+
54
+ ## Installation
55
+
56
+ ```sh
57
+ pip install outliner
58
+ ```
59
+
60
+ ## Running
61
+
62
+ ```sh
63
+ # From within the outliner/ directory
64
+ uv run outliner path/to/file.py
65
+
66
+ # From the repository root
67
+ uv run --project outliner outliner path/to/file.py
68
+
69
+ # Outline an entire directory
70
+ uv run --project outliner outliner src/
71
+ ```
72
+
73
+ ## Running Tests
74
+
75
+ ```sh
76
+ # From within the outliner/ directory
77
+ uv run pytest
78
+ ```
79
+
80
+ ## Supported Languages
81
+
82
+ Python, Go, Markdown, reStructuredText — with Java, Rust, JavaScript/TypeScript,
83
+ C/C++, C#, and many more in progress.
84
+
85
+ ## Example Use Cases
86
+
87
+ **Structural overview** — Run on a directory to see all declarations across many files before reading anything:
88
+
89
+ ```
90
+ $ outliner src/
91
+ ==> src/billing.py <==
92
+ 12,8 class Invoice
93
+ 22,4 def create(customer_id: str, items: list[Item]) -> Invoice
94
+ 38,6 def send(invoice: Invoice, method: str) -> bool
95
+
96
+ ==> src/payments.py <==
97
+ 8,3 class PaymentMethod
98
+ 14,12 def charge(method: PaymentMethod, amount: Decimal) -> Receipt
99
+ ```
100
+
101
+ **Find all copies of a pattern** — `--grep serialize` across a source tree locates every implementation of a repeated function in one command:
102
+
103
+ ```
104
+ $ outliner --grep serialize src/
105
+ ==> src/invoice.py <==
106
+ 44,5 def serialize(self) -> dict
107
+
108
+ ==> src/receipt.py <==
109
+ 31,3 def serialize(self) -> dict
110
+ ```
111
+
112
+ **Find functions whose interface mentions a term** — `--grep` searches signatures, not bodies. It finds functions whose interface involves a concept, skipping internal uses, comments, and call sites:
113
+
114
+ ```
115
+ $ outliner --grep payment src/
116
+ 14,12 def charge(method: PaymentMethod, amount: Decimal) -> Receipt
117
+ 61,4 def refund(payment: Payment) -> bool
118
+ ```
119
+
120
+ **Find functions accepting a specific type** — `--grep PaymentMethod` locates every function where the type appears in parameters, return types, or generic bounds. Multi-line signatures are merged into a single line before matching, so nothing is missed:
121
+
122
+ ```
123
+ $ outliner --grep PaymentMethod src/
124
+ 14,12 def charge(method: PaymentMethod, amount: Decimal) -> Receipt
125
+ 88,4 def validate(m: PaymentMethod) -> bool
126
+ ```
@@ -0,0 +1,113 @@
1
+ # outliner
2
+
3
+ Print the structural outline of source files — declarations with line ranges —
4
+ so an LLM agent (or human) can navigate a file without reading it whole.
5
+
6
+ ## Usage
7
+
8
+ ```
9
+ outliner [OPTIONS] [FILE...]
10
+ ```
11
+
12
+ | Option | Description |
13
+ | ------------------- | --------------------------------------------------------------- |
14
+ | `-g, --grep EXPR` | Only show items whose signature matches EXPR (case-insensitive) |
15
+ | `-s, --syntax LANG` | Override syntax auto-detection when it is ambiguous |
16
+
17
+ Pass a file, a directory (walked recursively), or omit arguments to read stdin.
18
+ Use `-` to read stdin explicitly. `--syntax` is only needed when content
19
+ auto-detection cannot identify the language (e.g. an ambiguous extensionless
20
+ script piped on stdin).
21
+
22
+ ## Output
23
+
24
+ ```
25
+ 3,4 type Driver struct
26
+ 19,6 func New() *Driver
27
+ 26,12 func (d *Driver) StartLogging(ctx context.Context, f *os.File) error
28
+ ```
29
+
30
+ Each line: `<start>,<count> <signature>`
31
+
32
+ - `start` — 1-based line number, right-aligned
33
+ - `count` — number of lines covered by the item (including doc-comments above)
34
+ - `signature` — first non-comment line of the declaration; multi-line signatures
35
+ are merged into one line
36
+
37
+ Nesting is visible in two ways: overlapping ranges (a class range contains its
38
+ methods) and native-format indentation in the signature (indented for code,
39
+ `#`/`##` heading levels for Markdown).
40
+
41
+ ## Installation
42
+
43
+ ```sh
44
+ pip install outliner
45
+ ```
46
+
47
+ ## Running
48
+
49
+ ```sh
50
+ # From within the outliner/ directory
51
+ uv run outliner path/to/file.py
52
+
53
+ # From the repository root
54
+ uv run --project outliner outliner path/to/file.py
55
+
56
+ # Outline an entire directory
57
+ uv run --project outliner outliner src/
58
+ ```
59
+
60
+ ## Running Tests
61
+
62
+ ```sh
63
+ # From within the outliner/ directory
64
+ uv run pytest
65
+ ```
66
+
67
+ ## Supported Languages
68
+
69
+ Python, Go, Markdown, reStructuredText — with Java, Rust, JavaScript/TypeScript,
70
+ C/C++, C#, and many more in progress.
71
+
72
+ ## Example Use Cases
73
+
74
+ **Structural overview** — Run on a directory to see all declarations across many files before reading anything:
75
+
76
+ ```
77
+ $ outliner src/
78
+ ==> src/billing.py <==
79
+ 12,8 class Invoice
80
+ 22,4 def create(customer_id: str, items: list[Item]) -> Invoice
81
+ 38,6 def send(invoice: Invoice, method: str) -> bool
82
+
83
+ ==> src/payments.py <==
84
+ 8,3 class PaymentMethod
85
+ 14,12 def charge(method: PaymentMethod, amount: Decimal) -> Receipt
86
+ ```
87
+
88
+ **Find all copies of a pattern** — `--grep serialize` across a source tree locates every implementation of a repeated function in one command:
89
+
90
+ ```
91
+ $ outliner --grep serialize src/
92
+ ==> src/invoice.py <==
93
+ 44,5 def serialize(self) -> dict
94
+
95
+ ==> src/receipt.py <==
96
+ 31,3 def serialize(self) -> dict
97
+ ```
98
+
99
+ **Find functions whose interface mentions a term** — `--grep` searches signatures, not bodies. It finds functions whose interface involves a concept, skipping internal uses, comments, and call sites:
100
+
101
+ ```
102
+ $ outliner --grep payment src/
103
+ 14,12 def charge(method: PaymentMethod, amount: Decimal) -> Receipt
104
+ 61,4 def refund(payment: Payment) -> bool
105
+ ```
106
+
107
+ **Find functions accepting a specific type** — `--grep PaymentMethod` locates every function where the type appears in parameters, return types, or generic bounds. Multi-line signatures are merged into a single line before matching, so nothing is missed:
108
+
109
+ ```
110
+ $ outliner --grep PaymentMethod src/
111
+ 14,12 def charge(method: PaymentMethod, amount: Decimal) -> Receipt
112
+ 88,4 def validate(m: PaymentMethod) -> bool
113
+ ```
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "outliner-cli"
7
+ version = "0.1.0"
8
+ description = "Print the structural outline of source files for LLM navigation"
9
+ authors = [{name = "Per Cederberg"}]
10
+ license = "MIT"
11
+ readme = "README.md"
12
+ requires-python = ">=3.11"
13
+ dependencies = []
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/cederberg/incubator/tree/main/outliner"
17
+ Repository = "https://github.com/cederberg/incubator"
18
+
19
+ [project.scripts]
20
+ outliner = "outliner.cli:main"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "pytest>=9.0.3",
31
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,115 @@
1
+ """outliner — print structural outline of source files."""
2
+
3
+ import argparse
4
+ import os
5
+ import re
6
+ import sys
7
+
8
+ from outliner.parsers import NAMES, EXTENSIONS, detect, outline
9
+ from outliner.types import OutlineItem
10
+
11
+
12
+ def guess_syntax(src: str) -> str | None:
13
+ return EXTENSIONS.get(os.path.splitext(src.lower())[1])
14
+
15
+
16
+ def _expand_sources(sources: list[str]) -> list[str]:
17
+ result = []
18
+ for src in sources:
19
+ if src == "-" or not os.path.isdir(src):
20
+ result.append(src)
21
+ continue
22
+ for root, dirs, files in os.walk(src):
23
+ dirs.sort()
24
+ for name in sorted(files):
25
+ if os.path.splitext(name.lower())[1] in EXTENSIONS:
26
+ result.append(os.path.join(root, name))
27
+ return result
28
+
29
+
30
+ def _format_items(items: list[OutlineItem], grep: re.Pattern | None) -> list[str]:
31
+ if grep:
32
+ items = [it for it in items if grep.search(it.signature)]
33
+ if not items:
34
+ return []
35
+
36
+ max_start_width = max(3, max(len(str(it.start)) for it in items))
37
+ max_field_width = 2 * max_start_width + 1
38
+
39
+ lines = []
40
+ for it in items:
41
+ start_str = str(it.start).rjust(max_start_width)
42
+ combined = f"{start_str},{it.count}"
43
+ lines.append(f"{combined.ljust(max_field_width)} {it.signature}")
44
+ return lines
45
+
46
+
47
+ def main(argv: list[str] | None = None) -> int:
48
+ ap = argparse.ArgumentParser(
49
+ prog="outliner",
50
+ description="Print the structural outline of source files.",
51
+ )
52
+ ap.add_argument("files", nargs="*", metavar="FILE",
53
+ help="Files to outline (omit or use - for stdin)")
54
+ ap.add_argument("-g", "--grep", metavar="EXPR",
55
+ help="Only show items whose signature matches EXPR (case-insensitive)")
56
+ ap.add_argument("-s", "--syntax", metavar="LANG",
57
+ help=f"Override syntax auto-detection (available: {', '.join(NAMES)})")
58
+ args = ap.parse_args(argv)
59
+
60
+ grep_re: re.Pattern | None = None
61
+ if args.grep:
62
+ try:
63
+ grep_re = re.compile(args.grep, re.IGNORECASE)
64
+ except re.error as exc:
65
+ print(f"outliner: invalid --grep expression: {exc}", file=sys.stderr)
66
+ return 2
67
+
68
+ sources = args.files or ["-"]
69
+ if sources == ["-"] and sys.stdin.isatty():
70
+ ap.print_help()
71
+ return 0
72
+ sources = _expand_sources(sources)
73
+ multi = len(sources) > 1
74
+
75
+ exit_code = 0
76
+ for src in sources:
77
+ try:
78
+ if src == "-":
79
+ text = sys.stdin.read()
80
+ else:
81
+ with open(src, encoding="utf-8", errors="replace") as fh:
82
+ text = fh.read()
83
+ except OSError as exc:
84
+ print(f"outliner: {exc}", file=sys.stderr)
85
+ exit_code = 1
86
+ continue
87
+
88
+ syntax = args.syntax or guess_syntax(src) or detect(text)
89
+
90
+ if syntax is None:
91
+ print(f"outliner: cannot auto-detect syntax for '{src}'; use --syntax",
92
+ file=sys.stderr)
93
+ exit_code = 2
94
+ continue
95
+
96
+ items = outline(syntax, text)
97
+ if items is None:
98
+ available = ", ".join(NAMES)
99
+ print(f"outliner: unsupported syntax '{syntax}'; available: {available}",
100
+ file=sys.stderr)
101
+ exit_code = 2
102
+ continue
103
+
104
+ output_lines = _format_items(items, grep_re)
105
+
106
+ if output_lines:
107
+ if multi:
108
+ print(f"\n==> {src} <==")
109
+ print("\n".join(output_lines))
110
+
111
+ return exit_code
112
+
113
+
114
+ if __name__ == "__main__":
115
+ sys.exit(main())
@@ -0,0 +1,36 @@
1
+ import re
2
+
3
+ from . import python, scala, go, java, rust, swift, c, ruby, php, shell, javascript, csharp, perl, zig, clojure, asciidoc, orgmode, rst, markdown
4
+ from outliner.types import OutlineItem
5
+
6
+ _MODULES = [python, scala, go, java, rust, swift, c, ruby, php, shell, javascript, csharp, perl, zig, clojure, asciidoc, orgmode, rst, markdown]
7
+ _PARSERS = {mod.SYNTAX: mod.parse for mod in _MODULES}
8
+ NAMES = sorted(_PARSERS)
9
+ EXTENSIONS = {ext: mod.SYNTAX for mod in _MODULES for ext in mod.EXTENSIONS}
10
+
11
+ _FRONTMATTER_RE = re.compile(r'\A(?:---\n(?:.*\n){0,98}?---\n|\+\+\+\n(?:.*\n){0,98}?\+\+\+\n)')
12
+
13
+
14
+ def _strip_frontmatter(content: str) -> str:
15
+ m = _FRONTMATTER_RE.match(content)
16
+ return content[m.end():] if m else content
17
+
18
+
19
+ def detect(content: str) -> str | None:
20
+ lines = _strip_frontmatter(content).splitlines()[:100]
21
+ for mod in _MODULES:
22
+ if mod.detect(lines):
23
+ return mod.SYNTAX
24
+ return None
25
+
26
+
27
+ def outline(syntax: str, content: str) -> list[OutlineItem] | None:
28
+ parse = _PARSERS.get(syntax)
29
+ if not parse:
30
+ return None
31
+ m = _FRONTMATTER_RE.match(content)
32
+ if not m:
33
+ return list(parse(content))
34
+ offset = m.group(0).count('\n')
35
+ return [OutlineItem(start=it.start + offset, count=it.count, signature=it.signature)
36
+ for it in parse(content[m.end():])]
@@ -0,0 +1,89 @@
1
+ """AsciiDoc outline parser.
2
+
3
+ Recognises ATX-style headings prefixed with one or more `=` characters
4
+ followed by a space (``= Title``, ``== Section``, ``=== Subsection``, …).
5
+ The number of `=` signs determines the heading level; level 0 (`=`) is
6
+ the document title. Each heading's range extends to the line before the
7
+ next heading at the same or higher level (lower `=` count), or to EOF.
8
+
9
+ Content detection looks for AsciiDoc-specific co-occurring markers:
10
+ - a document-title line (``= `` at column 0) together with attribute
11
+ entries (``:attr:`` lines) or block macros (``[source,…]``, ``[NOTE]``,
12
+ ``[TIP]``, etc.)
13
+ - block macros alone are sufficient (``[source,lang]`` / ``[NOTE]``)
14
+ """
15
+
16
+ import re
17
+ from collections.abc import Iterator
18
+
19
+ from outliner.types import OutlineItem
20
+ from outliner.parsers.util import extract_summary
21
+
22
+ SYNTAX = "asciidoc"
23
+ EXTENSIONS = (".adoc", ".asciidoc")
24
+
25
+ _HEADING_RE = re.compile(r"^(={1,6})\s+(.+?)\s*$")
26
+ _ATTR_ENTRY_RE = re.compile(r"^:!?[A-Za-z0-9_-]+:(?:\s|$)")
27
+ _BLOCK_MACRO_RE = re.compile(r"^\[(?:source|NOTE|TIP|WARNING|CAUTION|IMPORTANT|listing|example|quote|verse|sidebar|pass|abstract|partintro)[,\]]")
28
+
29
+
30
+ def detect(lines: list[str]) -> bool:
31
+ """Return True when content has unambiguous AsciiDoc markers.
32
+
33
+ We require co-occurring signals to avoid false-positives:
34
+ - A ``= Document Title`` line (level-0 heading) plus at least one
35
+ attribute entry or block macro; OR
36
+ - A block macro (``[source,…]``, admonition) alone.
37
+ """
38
+ has_doc_title = False
39
+ has_section = False
40
+ has_attr = False
41
+ has_block = False
42
+ for line in lines:
43
+ s = line.rstrip()
44
+ if _BLOCK_MACRO_RE.match(s):
45
+ has_block = True
46
+ if _ATTR_ENTRY_RE.match(s):
47
+ has_attr = True
48
+ m = _HEADING_RE.match(s)
49
+ if m:
50
+ if len(m.group(1)) == 1:
51
+ has_doc_title = True
52
+ else:
53
+ has_section = True
54
+ if has_block:
55
+ return True
56
+ # Level-0 document title paired with attribute entries or section headings
57
+ if has_doc_title and (has_attr or has_section):
58
+ return True
59
+ return False
60
+
61
+
62
+ def parse(text: str) -> Iterator[OutlineItem]:
63
+ lines = text.splitlines(keepends=True)
64
+ n = len(lines)
65
+
66
+ headings: list[tuple[int, int, str]] = [] # (0-based idx, level, sig)
67
+
68
+ for i, line in enumerate(lines):
69
+ stripped = line.rstrip("\r\n")
70
+ m = _HEADING_RE.match(stripped)
71
+ if m:
72
+ level = len(m.group(1))
73
+ sig = "=" * level + " " + m.group(2)
74
+ headings.append((i, level, sig))
75
+
76
+ if not headings:
77
+ # Fallback: first non-empty line spans the whole file
78
+ first_sig = next((l.strip() for l in lines if l.strip()), "")
79
+ if first_sig:
80
+ yield OutlineItem(start=1, count=n, signature=extract_summary(first_sig))
81
+ return
82
+
83
+ for idx, (line_idx, level, sig) in enumerate(headings):
84
+ end_line = n
85
+ for future_line_idx, future_level, _ in headings[idx + 1:]:
86
+ if future_level <= level:
87
+ end_line = future_line_idx
88
+ break
89
+ yield OutlineItem(start=line_idx + 1, count=end_line - line_idx, signature=sig)