tree2guide 1.0.0__py3-none-any.whl
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.
- tree2guide/__init__.py +50 -0
- tree2guide/cli.py +147 -0
- tree2guide/ignore.py +92 -0
- tree2guide/llm.py +211 -0
- tree2guide/renderers/__init__.py +7 -0
- tree2guide/renderers/html.py +218 -0
- tree2guide/renderers/json_renderer.py +48 -0
- tree2guide/renderers/llm.py +81 -0
- tree2guide/renderers/markdown.py +39 -0
- tree2guide/renderers/text.py +29 -0
- tree2guide/renderers/yaml_renderer.py +64 -0
- tree2guide/scanner.py +149 -0
- tree2guide-1.0.0.dist-info/METADATA +475 -0
- tree2guide-1.0.0.dist-info/RECORD +18 -0
- tree2guide-1.0.0.dist-info/WHEEL +5 -0
- tree2guide-1.0.0.dist-info/entry_points.txt +2 -0
- tree2guide-1.0.0.dist-info/licenses/LICENSE +21 -0
- tree2guide-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree2guide.renderers.html — renders the tree as a self-contained HTML page.
|
|
3
|
+
|
|
4
|
+
Produces a single .html file with:
|
|
5
|
+
- Collapsible folder nodes (pure CSS + minimal JS, no external deps)
|
|
6
|
+
- Expand/collapse all buttons
|
|
7
|
+
- A monospace tree with the same connector characters as the text/markdown renderers
|
|
8
|
+
- Clean, minimal styling that works in any modern browser
|
|
9
|
+
- An optional title shown as a <h1>
|
|
10
|
+
- An optional attribution footer
|
|
11
|
+
|
|
12
|
+
No external CSS frameworks, fonts, or scripts are loaded — the file works
|
|
13
|
+
fully offline.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
from tree2guide.scanner import TreeNode
|
|
18
|
+
|
|
19
|
+
_CSS = """\
|
|
20
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
21
|
+
body {
|
|
22
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
23
|
+
background: #0f1117;
|
|
24
|
+
color: #e2e8f0;
|
|
25
|
+
min-height: 100vh;
|
|
26
|
+
padding: 2rem;
|
|
27
|
+
}
|
|
28
|
+
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1.25rem; color: #f8fafc; }
|
|
29
|
+
.controls { margin-bottom: 1rem; display: flex; gap: 0.5rem; }
|
|
30
|
+
button {
|
|
31
|
+
background: #1e2433;
|
|
32
|
+
color: #94a3b8;
|
|
33
|
+
border: 1px solid #2d3748;
|
|
34
|
+
border-radius: 6px;
|
|
35
|
+
padding: 0.3rem 0.75rem;
|
|
36
|
+
font-size: 0.8rem;
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
transition: background 0.15s, color 0.15s;
|
|
39
|
+
}
|
|
40
|
+
button:hover { background: #2d3748; color: #e2e8f0; }
|
|
41
|
+
.tree {
|
|
42
|
+
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
|
43
|
+
font-size: 0.88rem;
|
|
44
|
+
line-height: 1.7;
|
|
45
|
+
background: #1a1f2e;
|
|
46
|
+
border: 1px solid #2d3748;
|
|
47
|
+
border-radius: 10px;
|
|
48
|
+
padding: 1.25rem 1.5rem;
|
|
49
|
+
overflow-x: auto;
|
|
50
|
+
}
|
|
51
|
+
ul { list-style: none; padding: 0; }
|
|
52
|
+
li { white-space: nowrap; }
|
|
53
|
+
.entry { display: inline-flex; align-items: center; gap: 0.3rem; }
|
|
54
|
+
.connector { color: #4a5568; user-select: none; }
|
|
55
|
+
.toggle {
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
background: none;
|
|
58
|
+
border: none;
|
|
59
|
+
padding: 0;
|
|
60
|
+
font: inherit;
|
|
61
|
+
color: inherit;
|
|
62
|
+
display: inline-flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
gap: 0.3rem;
|
|
65
|
+
}
|
|
66
|
+
.toggle:hover .dirname { color: #7dd3fc; }
|
|
67
|
+
.icon { font-size: 0.8em; color: #64748b; user-select: none; transition: transform 0.15s; }
|
|
68
|
+
.collapsed > .children { display: none; }
|
|
69
|
+
.collapsed .icon { transform: rotate(-90deg); }
|
|
70
|
+
.dirname { color: #93c5fd; font-weight: 500; }
|
|
71
|
+
.filename { color: #e2e8f0; }
|
|
72
|
+
.symlink { color: #a78bfa; }
|
|
73
|
+
.symlink-arrow { color: #64748b; }
|
|
74
|
+
.root { color: #34d399; font-weight: 600; }
|
|
75
|
+
footer {
|
|
76
|
+
margin-top: 1.5rem;
|
|
77
|
+
font-size: 0.78rem;
|
|
78
|
+
color: #4a5568;
|
|
79
|
+
}
|
|
80
|
+
footer a { color: #64748b; }
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
_JS = """\
|
|
84
|
+
function expandAll() {
|
|
85
|
+
document.querySelectorAll('.dir-item').forEach(el => el.classList.remove('collapsed'));
|
|
86
|
+
}
|
|
87
|
+
function collapseAll() {
|
|
88
|
+
document.querySelectorAll('.dir-item').forEach(el => el.classList.add('collapsed'));
|
|
89
|
+
}
|
|
90
|
+
function toggle(el) {
|
|
91
|
+
el.closest('.dir-item').classList.toggle('collapsed');
|
|
92
|
+
}
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
FOOTER_HTML = (
|
|
96
|
+
'<footer>Generated with '
|
|
97
|
+
'<a href="https://github.com/law4percent/tree2guide">tree2guide</a> '
|
|
98
|
+
'by <a href="https://github.com/law4percent">Lawrence Roble</a> '
|
|
99
|
+
'— open source, MIT licensed.</footer>'
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _node_to_html(node: TreeNode, prefix: str = "", is_last: bool = True, is_root: bool = False) -> list[str]:
|
|
104
|
+
lines: list[str] = []
|
|
105
|
+
|
|
106
|
+
if is_root:
|
|
107
|
+
# Root node — no connector, special styling, always expanded
|
|
108
|
+
lines.append('<li class="dir-item">')
|
|
109
|
+
lines.append(
|
|
110
|
+
f' <span class="entry">'
|
|
111
|
+
f'<button class="toggle" onclick="toggle(this)">'
|
|
112
|
+
f'<span class="icon">▾</span>'
|
|
113
|
+
f'<span class="root">{_esc(node.name)}/</span>'
|
|
114
|
+
f'</button></span>'
|
|
115
|
+
)
|
|
116
|
+
if node.children:
|
|
117
|
+
lines.append(' <ul class="children">')
|
|
118
|
+
for i, child in enumerate(node.children):
|
|
119
|
+
lines.extend(_node_to_html(child, prefix="", is_last=(i == len(node.children) - 1)))
|
|
120
|
+
lines.append(" </ul>")
|
|
121
|
+
lines.append("</li>")
|
|
122
|
+
return lines
|
|
123
|
+
|
|
124
|
+
connector = "└── " if is_last else "├── "
|
|
125
|
+
child_prefix = prefix + (" " if is_last else "│ ")
|
|
126
|
+
|
|
127
|
+
if node.is_symlink:
|
|
128
|
+
lines.append("<li>")
|
|
129
|
+
lines.append(
|
|
130
|
+
f' <span class="entry">'
|
|
131
|
+
f'<span class="connector">{_esc(prefix + connector)}</span>'
|
|
132
|
+
f'<span class="symlink">{_esc(node.name)}</span>'
|
|
133
|
+
f'<span class="symlink-arrow"> -> </span>'
|
|
134
|
+
f'<span class="symlink">{_esc(node.symlink_target or "")}</span>'
|
|
135
|
+
f'</span>'
|
|
136
|
+
)
|
|
137
|
+
lines.append("</li>")
|
|
138
|
+
return lines
|
|
139
|
+
|
|
140
|
+
if not node.is_dir:
|
|
141
|
+
lines.append("<li>")
|
|
142
|
+
lines.append(
|
|
143
|
+
f' <span class="entry">'
|
|
144
|
+
f'<span class="connector">{_esc(prefix + connector)}</span>'
|
|
145
|
+
f'<span class="filename">{_esc(node.name)}</span>'
|
|
146
|
+
f'</span>'
|
|
147
|
+
)
|
|
148
|
+
lines.append("</li>")
|
|
149
|
+
return lines
|
|
150
|
+
|
|
151
|
+
# Directory — collapsible
|
|
152
|
+
lines.append('<li class="dir-item">')
|
|
153
|
+
lines.append(
|
|
154
|
+
f' <span class="entry">'
|
|
155
|
+
f'<span class="connector">{_esc(prefix + connector)}</span>'
|
|
156
|
+
f'<button class="toggle" onclick="toggle(this)">'
|
|
157
|
+
f'<span class="icon">▾</span>'
|
|
158
|
+
f'<span class="dirname">{_esc(node.name)}/</span>'
|
|
159
|
+
f'</button></span>'
|
|
160
|
+
)
|
|
161
|
+
if node.children:
|
|
162
|
+
lines.append(' <ul class="children">')
|
|
163
|
+
for i, child in enumerate(node.children):
|
|
164
|
+
lines.extend(_node_to_html(child, prefix=child_prefix, is_last=(i == len(node.children) - 1)))
|
|
165
|
+
lines.append(" </ul>")
|
|
166
|
+
lines.append("</li>")
|
|
167
|
+
return lines
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _esc(s: str) -> str:
|
|
171
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def render_html(
|
|
175
|
+
tree: TreeNode,
|
|
176
|
+
title: str | None = None,
|
|
177
|
+
include_footer: bool = True,
|
|
178
|
+
) -> str:
|
|
179
|
+
"""
|
|
180
|
+
Render a TreeNode as a self-contained HTML page string with a trailing newline.
|
|
181
|
+
"""
|
|
182
|
+
title_tag = title or f"{tree.name} — tree2guide"
|
|
183
|
+
|
|
184
|
+
body_parts: list[str] = []
|
|
185
|
+
if title:
|
|
186
|
+
body_parts.append(f"<h1>{_esc(title)}</h1>")
|
|
187
|
+
body_parts.append(
|
|
188
|
+
'<div class="controls">'
|
|
189
|
+
'<button onclick="expandAll()">Expand all</button>'
|
|
190
|
+
'<button onclick="collapseAll()">Collapse all</button>'
|
|
191
|
+
'</div>'
|
|
192
|
+
)
|
|
193
|
+
body_parts.append('<div class="tree"><ul>')
|
|
194
|
+
body_parts.extend(_node_to_html(tree, is_root=True))
|
|
195
|
+
body_parts.append("</ul></div>")
|
|
196
|
+
if include_footer:
|
|
197
|
+
body_parts.append(FOOTER_HTML)
|
|
198
|
+
|
|
199
|
+
body = "\n".join(body_parts)
|
|
200
|
+
|
|
201
|
+
return f"""<!DOCTYPE html>
|
|
202
|
+
<html lang="en">
|
|
203
|
+
<head>
|
|
204
|
+
<meta charset="UTF-8">
|
|
205
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
206
|
+
<title>{_esc(title_tag)}</title>
|
|
207
|
+
<style>
|
|
208
|
+
{_CSS}
|
|
209
|
+
</style>
|
|
210
|
+
</head>
|
|
211
|
+
<body>
|
|
212
|
+
{body}
|
|
213
|
+
<script>
|
|
214
|
+
{_JS}
|
|
215
|
+
</script>
|
|
216
|
+
</body>
|
|
217
|
+
</html>
|
|
218
|
+
"""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree2guide.renderers.json_renderer — renders the tree as a JSON document.
|
|
3
|
+
|
|
4
|
+
The JSON shape is designed to be stable and easy to consume by other tools
|
|
5
|
+
(the --llm mode in Phase 4, web-based interactive visualizations, etc.):
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"name": "src",
|
|
9
|
+
"type": "directory",
|
|
10
|
+
"children": [
|
|
11
|
+
{ "name": "main.py", "type": "file" },
|
|
12
|
+
{ "name": "api", "type": "directory", "children": [...] }
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Symlinks carry an extra "target" key and have type "symlink".
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
import json
|
|
21
|
+
from tree2guide.scanner import TreeNode
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _node_to_dict(node: TreeNode) -> dict:
|
|
25
|
+
if node.is_symlink:
|
|
26
|
+
return {
|
|
27
|
+
"name": node.name,
|
|
28
|
+
"type": "symlink",
|
|
29
|
+
"target": node.symlink_target,
|
|
30
|
+
}
|
|
31
|
+
if node.is_dir:
|
|
32
|
+
result: dict = {
|
|
33
|
+
"name": node.name,
|
|
34
|
+
"type": "directory",
|
|
35
|
+
}
|
|
36
|
+
if node.children:
|
|
37
|
+
result["children"] = [_node_to_dict(c) for c in node.children]
|
|
38
|
+
else:
|
|
39
|
+
result["children"] = []
|
|
40
|
+
return result
|
|
41
|
+
return {"name": node.name, "type": "file"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def render_json(tree: TreeNode, indent: int = 2) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Render a TreeNode as a pretty-printed JSON string with a trailing newline.
|
|
47
|
+
"""
|
|
48
|
+
return json.dumps(_node_to_dict(tree), indent=indent) + "\n"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree2guide.renderers.llm — AI-consumption-friendly project summary renderer.
|
|
3
|
+
|
|
4
|
+
Produces a structured plain-text document optimised for pasting into an LLM
|
|
5
|
+
context window. The format is deliberately verbose and unambiguous: explicit
|
|
6
|
+
labels, no markdown fences that an LLM might misread as code to execute,
|
|
7
|
+
and a tree that uses the same connector characters humans are used to so
|
|
8
|
+
spatial relationships are clear.
|
|
9
|
+
|
|
10
|
+
No network calls, no API keys, no third-party dependencies.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
from tree2guide.llm import LlmSummary, analyze
|
|
15
|
+
from tree2guide.scanner import TreeNode, render_lines
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_SEPARATOR = "=" * 60
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_llm(tree: TreeNode, title: str | None = None) -> str:
|
|
22
|
+
"""
|
|
23
|
+
Render a TreeNode as an LLM-friendly project summary.
|
|
24
|
+
Returns a string with a trailing newline.
|
|
25
|
+
"""
|
|
26
|
+
summary: LlmSummary = analyze(tree)
|
|
27
|
+
project_name = title or tree.name
|
|
28
|
+
lines: list[str] = []
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------ header
|
|
31
|
+
lines.append(_SEPARATOR)
|
|
32
|
+
lines.append(f"PROJECT STRUCTURE SUMMARY: {project_name}")
|
|
33
|
+
lines.append(_SEPARATOR)
|
|
34
|
+
lines.append("")
|
|
35
|
+
|
|
36
|
+
# ----------------------------------------------------------- detected stack
|
|
37
|
+
lines.append("DETECTED STACK / LANGUAGE:")
|
|
38
|
+
if summary.detected_stack:
|
|
39
|
+
for item in summary.detected_stack:
|
|
40
|
+
lines.append(f" - {item}")
|
|
41
|
+
else:
|
|
42
|
+
lines.append(" (No known stack signals detected)")
|
|
43
|
+
lines.append("")
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------ counts
|
|
46
|
+
lines.append("SIZE:")
|
|
47
|
+
lines.append(f" Files : {summary.file_count}")
|
|
48
|
+
lines.append(f" Directories: {summary.dir_count}")
|
|
49
|
+
lines.append("")
|
|
50
|
+
|
|
51
|
+
# --------------------------------------------------------- top-level layout
|
|
52
|
+
lines.append("TOP-LEVEL LAYOUT:")
|
|
53
|
+
if summary.top_level_dirs:
|
|
54
|
+
lines.append(" Directories:")
|
|
55
|
+
for d in summary.top_level_dirs:
|
|
56
|
+
lines.append(f" {d}")
|
|
57
|
+
if summary.top_level_files:
|
|
58
|
+
lines.append(" Files:")
|
|
59
|
+
for f in summary.top_level_files:
|
|
60
|
+
lines.append(f" {f}")
|
|
61
|
+
lines.append("")
|
|
62
|
+
|
|
63
|
+
# --------------------------------------------------------------- flags
|
|
64
|
+
if summary.notable_flags:
|
|
65
|
+
lines.append("NOTABLE FLAGS:")
|
|
66
|
+
for flag in summary.notable_flags:
|
|
67
|
+
lines.append(f" - {flag}")
|
|
68
|
+
lines.append("")
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------- full tree
|
|
71
|
+
lines.append(_SEPARATOR)
|
|
72
|
+
lines.append("FULL DIRECTORY TREE:")
|
|
73
|
+
lines.append(_SEPARATOR)
|
|
74
|
+
lines.append("")
|
|
75
|
+
lines.extend(render_lines(tree))
|
|
76
|
+
lines.append("")
|
|
77
|
+
lines.append(_SEPARATOR)
|
|
78
|
+
lines.append("END OF PROJECT STRUCTURE SUMMARY")
|
|
79
|
+
lines.append(_SEPARATOR)
|
|
80
|
+
|
|
81
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree2guide.renderers.markdown — renders the tree as a markdown code block.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from tree2guide.scanner import TreeNode, render_lines
|
|
7
|
+
|
|
8
|
+
FOOTER = (
|
|
9
|
+
"*Generated with [tree2guide](https://github.com/law4percent/tree2guide) "
|
|
10
|
+
"by Lawrence Roble ([@law4percent](https://github.com/law4percent)) "
|
|
11
|
+
"— open source, MIT licensed. Contributions welcome!*"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render_markdown(
|
|
16
|
+
tree: "TreeNode | list[str]",
|
|
17
|
+
title: str | None = None,
|
|
18
|
+
include_footer: bool = True,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Render a TreeNode (or raw line list for backward compat) as a markdown document.
|
|
22
|
+
Returns a string with a trailing newline.
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(tree, list):
|
|
25
|
+
lines = tree
|
|
26
|
+
else:
|
|
27
|
+
lines = render_lines(tree)
|
|
28
|
+
|
|
29
|
+
md: list[str] = []
|
|
30
|
+
if title:
|
|
31
|
+
md.append(f"# {title}\n")
|
|
32
|
+
md.append("```")
|
|
33
|
+
md.extend(lines)
|
|
34
|
+
md.append("```")
|
|
35
|
+
if include_footer:
|
|
36
|
+
md.append("")
|
|
37
|
+
md.append("---")
|
|
38
|
+
md.append(FOOTER)
|
|
39
|
+
return "\n".join(md) + "\n"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree2guide.renderers.text — renders the tree as plain text (no markdown fencing).
|
|
3
|
+
|
|
4
|
+
Identical visual output to the markdown renderer but with no ``` fences,
|
|
5
|
+
no title heading syntax, and no footer — just the raw tree lines.
|
|
6
|
+
Useful for terminal output, clipboard piping, and non-markdown tools.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
from tree2guide.scanner import TreeNode, render_lines
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render_text(
|
|
14
|
+
tree: "TreeNode | list[str]",
|
|
15
|
+
title: str | None = None,
|
|
16
|
+
) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Render a TreeNode as plain text. Returns a string with a trailing newline.
|
|
19
|
+
Title (if given) is printed as a plain line above the tree, not a heading.
|
|
20
|
+
"""
|
|
21
|
+
lines: list[str] = []
|
|
22
|
+
if title:
|
|
23
|
+
lines.append(title)
|
|
24
|
+
lines.append("")
|
|
25
|
+
if isinstance(tree, list):
|
|
26
|
+
lines.extend(tree)
|
|
27
|
+
else:
|
|
28
|
+
lines.extend(render_lines(tree))
|
|
29
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree2guide.renderers.yaml — renders the tree as YAML, zero third-party deps.
|
|
3
|
+
|
|
4
|
+
Output shape mirrors the JSON renderer exactly, hand-serialized so the
|
|
5
|
+
package stays dependency-free. Each node is a YAML mapping; children are
|
|
6
|
+
a YAML sequence of nested mappings.
|
|
7
|
+
|
|
8
|
+
name: src
|
|
9
|
+
type: directory
|
|
10
|
+
children:
|
|
11
|
+
- name: main.py
|
|
12
|
+
type: file
|
|
13
|
+
- name: api
|
|
14
|
+
type: directory
|
|
15
|
+
children: []
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
from tree2guide.scanner import TreeNode
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _node_to_yaml(node: TreeNode, indent: int) -> list[str]:
|
|
23
|
+
pad = " " * indent
|
|
24
|
+
lines: list[str] = []
|
|
25
|
+
|
|
26
|
+
if node.is_symlink:
|
|
27
|
+
lines.append(f"{pad}name: {_scalar(node.name)}")
|
|
28
|
+
lines.append(f"{pad}type: symlink")
|
|
29
|
+
lines.append(f"{pad}target: {_scalar(node.symlink_target or '')}")
|
|
30
|
+
return lines
|
|
31
|
+
|
|
32
|
+
lines.append(f"{pad}name: {_scalar(node.name)}")
|
|
33
|
+
if node.is_dir:
|
|
34
|
+
lines.append(f"{pad}type: directory")
|
|
35
|
+
lines.append(f"{pad}children:")
|
|
36
|
+
if node.children:
|
|
37
|
+
for child in node.children:
|
|
38
|
+
lines.append(f"{pad} -")
|
|
39
|
+
lines.extend(_node_to_yaml(child, indent + 4))
|
|
40
|
+
else:
|
|
41
|
+
lines.append(f"{pad} []")
|
|
42
|
+
else:
|
|
43
|
+
lines.append(f"{pad}type: file")
|
|
44
|
+
|
|
45
|
+
return lines
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _scalar(value: str) -> str:
|
|
49
|
+
"""Quote a scalar value if it contains YAML-unsafe characters."""
|
|
50
|
+
unsafe = {":", "#", "{", "}", "[", "]", ",", "&", "*", "?", "|", "-", "<", ">",
|
|
51
|
+
"=", "!", "%", "@", "`", "'", '"', "\n", "\r"}
|
|
52
|
+
if any(c in value for c in unsafe) or not value:
|
|
53
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
54
|
+
return f'"{escaped}"'
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render_yaml(tree: TreeNode) -> str:
|
|
59
|
+
"""
|
|
60
|
+
Render a TreeNode as a YAML string with a trailing newline.
|
|
61
|
+
No third-party dependencies — hand-serialized to keep the package zero-dep.
|
|
62
|
+
"""
|
|
63
|
+
lines = _node_to_yaml(tree, indent=0)
|
|
64
|
+
return "\n".join(lines) + "\n"
|
tree2guide/scanner.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree2guide.scanner — walks the filesystem and builds a tree model.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from tree2guide.ignore import ExcludeMatcher
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class TreeNode:
|
|
15
|
+
"""Internal tree model — one node per filesystem entry."""
|
|
16
|
+
name: str
|
|
17
|
+
is_dir: bool
|
|
18
|
+
is_symlink: bool = False
|
|
19
|
+
symlink_target: str | None = None
|
|
20
|
+
children: list["TreeNode"] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TreeOptions:
|
|
25
|
+
max_depth: int | None = None
|
|
26
|
+
dirs_only: bool = False
|
|
27
|
+
files_only: bool = False
|
|
28
|
+
no_hidden: bool = False
|
|
29
|
+
sort: str = "dirs-first" # "dirs-first" | "files-first" | "alpha"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sort_key(sort_mode: str):
|
|
33
|
+
if sort_mode == "alpha":
|
|
34
|
+
return lambda p: p.name.lower()
|
|
35
|
+
if sort_mode == "files-first":
|
|
36
|
+
return lambda p: (p.is_dir(), p.name.lower())
|
|
37
|
+
return lambda p: (p.is_file(), p.name.lower())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_node_tree(
|
|
41
|
+
root: Path,
|
|
42
|
+
matcher: ExcludeMatcher,
|
|
43
|
+
options: TreeOptions | None = None,
|
|
44
|
+
) -> TreeNode:
|
|
45
|
+
"""
|
|
46
|
+
Build and return a TreeNode tree rooted at `root`.
|
|
47
|
+
|
|
48
|
+
This is the canonical Scanner output — a single O(n) pass that
|
|
49
|
+
every renderer consumes. build_tree() is kept for backward compat
|
|
50
|
+
and simply calls render_lines() on the result.
|
|
51
|
+
"""
|
|
52
|
+
options = options or TreeOptions()
|
|
53
|
+
sort_key = _sort_key(options.sort)
|
|
54
|
+
|
|
55
|
+
def rel_str(path: Path) -> str:
|
|
56
|
+
return path.relative_to(root).as_posix()
|
|
57
|
+
|
|
58
|
+
def is_excluded(path: Path, is_dir: bool) -> bool:
|
|
59
|
+
return matcher.is_excluded(rel_str(path), is_dir)
|
|
60
|
+
|
|
61
|
+
def recurse(dir_path: Path, depth: int) -> list[TreeNode]:
|
|
62
|
+
if options.max_depth is not None and depth > options.max_depth:
|
|
63
|
+
return []
|
|
64
|
+
try:
|
|
65
|
+
entries = sorted(dir_path.iterdir(), key=sort_key)
|
|
66
|
+
except PermissionError:
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
children: list[TreeNode] = []
|
|
70
|
+
for entry in entries:
|
|
71
|
+
if options.no_hidden and entry.name.startswith("."):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
is_symlink = entry.is_symlink()
|
|
75
|
+
entry_is_dir = entry.is_dir() and not is_symlink
|
|
76
|
+
excluded = is_excluded(entry, entry.is_dir())
|
|
77
|
+
|
|
78
|
+
child_nodes = recurse(entry, depth + 1) if entry_is_dir else []
|
|
79
|
+
|
|
80
|
+
hide_for_type = (
|
|
81
|
+
(options.dirs_only and not entry.is_dir())
|
|
82
|
+
or (options.files_only and entry.is_dir() and not child_nodes)
|
|
83
|
+
)
|
|
84
|
+
if hide_for_type:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if excluded and not (entry_is_dir and child_nodes):
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
symlink_target: str | None = None
|
|
91
|
+
if is_symlink:
|
|
92
|
+
try:
|
|
93
|
+
symlink_target = str(Path(os.readlink(entry)))
|
|
94
|
+
except OSError:
|
|
95
|
+
symlink_target = str(entry.resolve())
|
|
96
|
+
|
|
97
|
+
node = TreeNode(
|
|
98
|
+
name=entry.name,
|
|
99
|
+
is_dir=entry.is_dir(),
|
|
100
|
+
is_symlink=is_symlink,
|
|
101
|
+
symlink_target=symlink_target,
|
|
102
|
+
children=child_nodes,
|
|
103
|
+
)
|
|
104
|
+
children.append(node)
|
|
105
|
+
|
|
106
|
+
return children
|
|
107
|
+
|
|
108
|
+
root_node = TreeNode(name=root.name, is_dir=True, children=recurse(root, depth=1))
|
|
109
|
+
return root_node
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def render_lines(node: TreeNode, prefix: str = "") -> list[str]:
|
|
113
|
+
"""Flatten a TreeNode tree into connector-prefixed display lines (for text/markdown)."""
|
|
114
|
+
lines: list[str] = [f"{node.name}/"]
|
|
115
|
+
_render_children(node.children, prefix="", lines=lines)
|
|
116
|
+
return lines
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _render_children(children: list[TreeNode], prefix: str, lines: list[str]) -> None:
|
|
120
|
+
for i, child in enumerate(children):
|
|
121
|
+
is_last = i == len(children) - 1
|
|
122
|
+
connector = "└── " if is_last else "├── "
|
|
123
|
+
|
|
124
|
+
if child.is_symlink:
|
|
125
|
+
display = f"{child.name} -> {child.symlink_target}"
|
|
126
|
+
elif child.is_dir:
|
|
127
|
+
display = f"{child.name}/"
|
|
128
|
+
else:
|
|
129
|
+
display = child.name
|
|
130
|
+
|
|
131
|
+
lines.append(f"{prefix}{connector}{display}")
|
|
132
|
+
|
|
133
|
+
if child.is_dir and not child.is_symlink and child.children:
|
|
134
|
+
extension = " " if is_last else "│ "
|
|
135
|
+
_render_children(child.children, prefix + extension, lines)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Backward-compatible API — kept so existing callers don't break
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def build_tree(
|
|
143
|
+
root: Path,
|
|
144
|
+
matcher: ExcludeMatcher,
|
|
145
|
+
options: TreeOptions | None = None,
|
|
146
|
+
) -> list[str]:
|
|
147
|
+
"""Return tree display lines (backward-compatible wrapper around build_node_tree)."""
|
|
148
|
+
node = build_node_tree(root, matcher, options)
|
|
149
|
+
return render_lines(node)
|