treeing 1.0.1__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.
treeing/core/parser.py ADDED
@@ -0,0 +1,407 @@
1
+ """
2
+ treeing/core/parser.py
3
+
4
+ Defines the ASCII tree-text parsing logic.
5
+ Handles indent detection, branch symbols, file/directory inference,
6
+ auto-repair and edge cases, producing a structured node tree for the generator.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections import Counter
13
+ from functools import reduce
14
+ from math import gcd
15
+
16
+ from ..config import get_string
17
+ from .constants import DOT_ROOT, TRANSPARENT_NODE_NAMES, VIRTUAL_AUTO, VIRTUAL_ROOT
18
+
19
+ # Matches tree branch characters (├└┤┬┴┼) and various horizontal lines
20
+ # (ASCII and Unicode box-drawing characters).
21
+ # MING includes all of these so the parser copes with tree output from
22
+ # different systems and tools.
23
+ _BRANCH_CHARS = r'[├└┤┬┴┼+\-`\\|]'
24
+ _HORIZ_CHARS = r'[-—─━┄┅┈┉]+'
25
+ PREFIX_PATTERN = re.compile(rf'^{_BRANCH_CHARS}\s*{_HORIZ_CHARS}\s*')
26
+
27
+ # Vertical continuation line: │ or | followed by 3 spaces, repeatable.
28
+ # This matches the `tree` command output format, hence the dedicated regex.
29
+ INDENT_PATTERN = re.compile(r'^(?:(?:[│|])\s{3})*')
30
+ SPACE_INDENT_PATTERN = re.compile(r'^ +')
31
+
32
+ # Lines made up only of separators (---, ===, *** and the like) are not tree
33
+ # content and are skipped.
34
+ _SEPARATOR_ONLY = re.compile(r'^[\-=_*·…\.]+$')
35
+
36
+ # Default indent unit: 4 spaces.
37
+ _TREE_STANDARD_UNIT = 4
38
+
39
+ # Filenames without an extension that everyone still recognises as files.
40
+ # MING lists the common ones (LICENSE, README, Makefile) here to avoid
41
+ # misclassifying them as directories.
42
+ _KNOWN_FILE_NAMES = frozenset({
43
+ 'license', 'copying', 'authors', 'contributors', 'readme',
44
+ 'changelog', 'makefile', 'dockerfile', 'procfile', 'gemfile',
45
+ 'rakefile', 'vagrantfile', 'brewfile', 'justfile', 'notice',
46
+ })
47
+
48
+
49
+ def _looks_like_file(name: str, *, strict_dirs: bool = False) -> bool:
50
+ """
51
+ Decide whether a name looks like a file or a directory.
52
+
53
+ By default MING uses a fairly aggressive heuristic: if the name contains an
54
+ uppercase letter, treat it as a file, because the Windows `tree` command
55
+ usually prints directories in lower case while files (especially source
56
+ files) often contain capitals.
57
+
58
+ This rule misclassifies some hand-written directory names, so the
59
+ `--strict-dirs` option turns it off and only trusts extensions, dotfiles
60
+ and the known extension-less filenames.
61
+ """
62
+ if name.lower() in _KNOWN_FILE_NAMES:
63
+ return True
64
+ if name.startswith('.') and len(name) > 1:
65
+ return True
66
+ if '.' in name and not name.startswith('.'):
67
+ return True
68
+ if strict_dirs:
69
+ return False
70
+ return any(c.isupper() for c in name)
71
+
72
+
73
+ def _is_noise_line(stripped: str) -> bool:
74
+ """
75
+ Decide whether a line is "noise", i.e. not tree content.
76
+
77
+ Comment lines (starting with # or //) and pure separator lines (---, ===,
78
+ etc.) are skipped. A lone '.' is a legal root marker and is not noise.
79
+ """
80
+ if stripped == DOT_ROOT:
81
+ return False
82
+ if stripped.startswith('#') or stripped.startswith('//'):
83
+ return True
84
+ return _SEPARATOR_ONLY.match(stripped) is not None
85
+
86
+
87
+ def _is_root_garbage(indent: int, has_branch: bool, name: str) -> bool:
88
+ """
89
+ Decide whether a root-level line is "garbage", i.e. clearly not tree content.
90
+
91
+ If the line has no branch prefix, no indent, contains a space and is not a
92
+ recognised file form, it is most likely prose the user pasted by accident
93
+ (e.g. "random garbage line").
94
+ """
95
+ if has_branch or indent > 0:
96
+ return False
97
+ if name.endswith('/') or name == DOT_ROOT:
98
+ return False
99
+ if _looks_like_file(name):
100
+ return False
101
+ return ' ' in name
102
+
103
+
104
+ def _measure_indent(line: str) -> int:
105
+ """
106
+ Measure the indent length of a line.
107
+
108
+ The indent can come from two sources:
109
+ 1. A tree continuation line (│ or | followed by 3 spaces, repeatable).
110
+ 2. Plain space indent (hand-written trees may just use spaces).
111
+
112
+ MING handles both so the parser copes with all sorts of odd input formats.
113
+ """
114
+ pos_match = INDENT_PATTERN.match(line)
115
+ pos = pos_match.end() if pos_match else 0
116
+ space_match = SPACE_INDENT_PATTERN.match(line[pos:])
117
+ if space_match:
118
+ pos += space_match.end()
119
+ return pos
120
+
121
+
122
+ def parse_line(
123
+ line: str, *, strict_dirs: bool = False,
124
+ ) -> tuple[int, str, bool, bool, bool] | None:
125
+ """
126
+ Parse a single tree-text line, extracting indent, name, directory flag, etc.
127
+
128
+ Returns a 5-tuple: (indent, name, is_dir, has_branch_prefix, is_heuristic_dir).
129
+
130
+ Returns None for blank lines or lines that fail to parse.
131
+ """
132
+ line = line.expandtabs(4)
133
+ line = line.rstrip('\n')
134
+ if not line.strip():
135
+ return None
136
+
137
+ indent = _measure_indent(line)
138
+ rest = line[indent:]
139
+
140
+ prefix_match = PREFIX_PATTERN.match(rest)
141
+ has_branch_prefix = prefix_match is not None
142
+ if prefix_match:
143
+ name = rest[prefix_match.end():].strip()
144
+ else:
145
+ name = rest.strip()
146
+
147
+ if not name:
148
+ return None
149
+
150
+ heuristic_dir = False
151
+ is_dir = name.endswith('/')
152
+ if is_dir:
153
+ name = name[:-1]
154
+ elif has_branch_prefix and not _looks_like_file(name, strict_dirs=strict_dirs):
155
+ is_dir = True
156
+ heuristic_dir = True
157
+
158
+ return indent, name, is_dir, has_branch_prefix, heuristic_dir
159
+
160
+
161
+ def _gcd_of_list(values: list[int]) -> int:
162
+ """
163
+ Compute the greatest common divisor of a list of positive integers.
164
+
165
+ Used by detect_indent_unit: if every indent value is a multiple of some
166
+ number, prefer that multiple as the indent unit. Accepts positive integers
167
+ only; an empty list returns 0.
168
+ """
169
+ return reduce(gcd, values)
170
+
171
+
172
+ def _most_common_diff(positive: list[int]) -> int:
173
+ """
174
+ Find the most common "adjacent difference" among a list of indent values.
175
+
176
+ One of MING's heuristics for guessing the indent unit: if, in a hand-written
177
+ tree, most children are indented 2 spaces more than their parent, the unit
178
+ is probably 2.
179
+ """
180
+ diff_counts: Counter = Counter()
181
+ for i in range(1, len(positive)):
182
+ diff = positive[i] - positive[i - 1]
183
+ if diff > 0:
184
+ diff_counts[diff] += 1
185
+ if diff_counts:
186
+ return diff_counts.most_common(1)[0][0]
187
+ return positive[0]
188
+
189
+
190
+ def detect_indent_unit(indents: list[int]) -> int:
191
+ """
192
+ Infer the most likely indent unit from a set of indent values.
193
+
194
+ MING applies three rules in priority order:
195
+ 1. If every indent value is a multiple of some number >= 4, use that number.
196
+ 2. If the smallest positive indent is >= 4, use that smallest value.
197
+ 3. Otherwise take the larger of "greatest common divisor" and "most common
198
+ difference".
199
+
200
+ Returns the default 4 when there are no positive indents at all.
201
+ """
202
+ if not indents:
203
+ return _TREE_STANDARD_UNIT
204
+ positive = sorted(set(i for i in indents if i > 0))
205
+ if not positive:
206
+ return _TREE_STANDARD_UNIT
207
+ if len(positive) == 1:
208
+ return positive[0]
209
+
210
+ gcd_unit = _gcd_of_list(positive)
211
+ diff_unit = _most_common_diff(positive)
212
+
213
+ if gcd_unit >= _TREE_STANDARD_UNIT:
214
+ return gcd_unit
215
+ if min(positive) >= _TREE_STANDARD_UNIT:
216
+ return min(positive)
217
+ return max(gcd_unit, diff_unit)
218
+
219
+
220
+ def _compute_level(indent: int, unit: int, has_branch_prefix: bool) -> int:
221
+ """
222
+ Compute a node's tree level from its indent value and the indent unit.
223
+
224
+ A node with a branch prefix is one level deeper than a plain-indent node at
225
+ the same column (the branch itself occupies a level).
226
+ """
227
+ if has_branch_prefix:
228
+ return indent // unit + 1 if unit > 0 else 1
229
+ return indent // unit if unit > 0 else 0
230
+
231
+
232
+ def auto_fix_indent(
233
+ parsed: list[tuple[int, str, bool, bool, bool, int]], unit: int
234
+ ) -> tuple[list[tuple[int, str, bool, bool, bool, int]], list[str]]:
235
+ """
236
+ Snap parsed indent values onto the inferred indent unit.
237
+
238
+ If a line's indent is not a multiple of the unit, round it to the nearest
239
+ multiple. Each fix produces a warning telling the user "I fixed line X's
240
+ indent".
241
+
242
+ MING added this auto-repair so that hand-written trees with messy indents
243
+ still parse into a reasonable structure.
244
+ """
245
+ fixed = []
246
+ warnings = []
247
+ for indent, name, is_dir, has_branch, heuristic_dir, line_no in parsed:
248
+ if unit == 0:
249
+ new_indent = 0
250
+ else:
251
+ quotient = round(indent / unit)
252
+ new_indent = quotient * unit
253
+ if new_indent < 0:
254
+ new_indent = 0
255
+ if new_indent != indent:
256
+ warn = get_string(
257
+ "core_warn_indent_fixed",
258
+ line=line_no, old=indent, new=new_indent, unit=unit,
259
+ )
260
+ warnings.append(warn)
261
+ fixed.append((new_indent, name, is_dir, has_branch, heuristic_dir, line_no))
262
+ return fixed, warnings
263
+
264
+
265
+ def _infer_directories(tree: list[dict], warnings: list[str]) -> None:
266
+ """
267
+ Walk the tree and mark entries that have children but no trailing slash as directories.
268
+
269
+ This copes with the Windows `tree` command, which does not add a trailing
270
+ slash to directories, so we infer directory-ness from "has children".
271
+
272
+ MING missed this in an early version, so Windows users often had their
273
+ directories treated as files, which then broke generation.
274
+ """
275
+
276
+ def walk(node: dict) -> None:
277
+ """Recursively walk nodes, correcting entries that have children but are still flagged as files into directories."""
278
+ for child in node.get('children', []):
279
+ walk(child)
280
+ if (
281
+ node.get('children')
282
+ and not node.get('is_dir', False)
283
+ and node['name'] not in TRANSPARENT_NODE_NAMES
284
+ and not _looks_like_file(node['name'])
285
+ ):
286
+ node['is_dir'] = True
287
+ warnings.append(get_string("core_warn_inferred_dir", name=node['name']))
288
+
289
+ for root in tree:
290
+ walk(root)
291
+
292
+
293
+ def build_tree(
294
+ lines: list[str],
295
+ auto_fix: bool = True,
296
+ indent_unit: int | None = None,
297
+ strict_dirs: bool = False,
298
+ ) -> tuple[list[dict], list[str]]:
299
+ """
300
+ Parse multi-line ASCII tree text into a structured node tree.
301
+
302
+ This is the entry point of the parser module. It:
303
+ 1. Strips the BOM (some editors prepend \ufeff).
304
+ 2. Parses line by line, skipping blank lines, comments, separators and
305
+ garbage lines.
306
+ 3. Auto-detects the indent unit, or uses the user-supplied one.
307
+ 4. Auto-repairs misaligned indents (when auto_fix is on).
308
+ 5. Builds the tree, handling indent jumps, virtual roots, multiple roots
309
+ and other edge cases.
310
+ 6. Infers directories that lack a trailing slash.
311
+ 7. Collects warnings and returns them to the caller for display.
312
+
313
+ MING kept this function long on purpose: tree-text parsing has many edge
314
+ cases, and splitting it into tiny helpers makes it easy for callers to
315
+ forget handling some of them.
316
+ """
317
+ warnings: list[str] = []
318
+ lines = [
319
+ line.lstrip('\ufeff') if line.startswith('\ufeff') else line
320
+ for line in lines
321
+ ]
322
+ parsed = []
323
+ for idx, line in enumerate(lines, start=1):
324
+ stripped = line.strip()
325
+ if not stripped:
326
+ continue
327
+ if _is_noise_line(stripped):
328
+ preview = stripped if len(stripped) <= 60 else stripped[:57] + '...'
329
+ warnings.append(get_string("core_warn_unrecognized_line", line=idx, content=preview))
330
+ continue
331
+ result = parse_line(line, strict_dirs=strict_dirs)
332
+ if result is None:
333
+ preview = stripped if len(stripped) <= 60 else stripped[:57] + '...'
334
+ warnings.append(get_string("core_warn_unrecognized_line", line=idx, content=preview))
335
+ continue
336
+ indent, name, is_dir, has_branch, heuristic_dir = result
337
+ if _is_root_garbage(indent, has_branch, name):
338
+ preview = stripped if len(stripped) <= 60 else stripped[:57] + '...'
339
+ warnings.append(get_string("core_warn_unrecognized_line", line=idx, content=preview))
340
+ continue
341
+ parsed.append((indent, name, is_dir, has_branch, heuristic_dir, idx))
342
+
343
+ if not parsed:
344
+ return [], warnings + [get_string("core_warn_empty_input")]
345
+
346
+ indents = [p[0] for p in parsed if p[0] > 0]
347
+ unit = indent_unit if indent_unit and indent_unit > 0 else detect_indent_unit(indents)
348
+
349
+ fix_warnings: list[str] = []
350
+ if auto_fix:
351
+ parsed, fix_warnings = auto_fix_indent(parsed, unit)
352
+ warnings.extend(fix_warnings)
353
+ if indent_unit is None:
354
+ indents = [p[0] for p in parsed if p[0] > 0]
355
+ if indents:
356
+ unit = detect_indent_unit(indents)
357
+
358
+ roots: list[dict] = []
359
+ stack: list[tuple[int, dict]] = []
360
+
361
+ for indent, name, is_dir, has_branch, heuristic_dir, line_no in parsed:
362
+ level = _compute_level(indent, unit, has_branch)
363
+
364
+ while len(stack) > level:
365
+ stack.pop()
366
+ while len(stack) < level:
367
+ if stack:
368
+ parent = stack[-1][1]
369
+ dummy = {'name': VIRTUAL_AUTO, 'is_dir': True, 'children': []}
370
+ parent['children'].append(dummy)
371
+ stack.append((len(stack), dummy))
372
+ warnings.append(get_string("core_warn_indent_jump", parent=parent['name']))
373
+ else:
374
+ virtual = {'name': VIRTUAL_ROOT, 'is_dir': True, 'children': []}
375
+ roots.append(virtual)
376
+ stack.append((0, virtual))
377
+ warnings.append(get_string("core_warn_virtual_root"))
378
+
379
+ node = {'name': name, 'is_dir': is_dir, 'children': []}
380
+ if name == DOT_ROOT:
381
+ warnings.append(get_string("core_warn_dot_root"))
382
+ if heuristic_dir:
383
+ warnings.append(get_string("core_warn_inferred_dir_heuristic", name=name))
384
+ if level == 0:
385
+ roots.append(node)
386
+ stack = [(0, node)]
387
+ else:
388
+ if stack:
389
+ parent = stack[-1][1]
390
+ parent['children'].append(node)
391
+ else:
392
+ roots.append(node)
393
+ warnings.append(get_string("core_warn_orphan", line=line_no, name=name))
394
+ stack.append((level, node))
395
+
396
+ _infer_directories(roots, warnings)
397
+
398
+ real_roots = [r for r in roots if r['name'] not in TRANSPARENT_NODE_NAMES]
399
+ multiple_roots = len(real_roots) > 1
400
+ if multiple_roots:
401
+ names = ', '.join(r['name'] for r in real_roots)
402
+ warnings.append(get_string("core_warn_multiple_roots", count=len(real_roots), names=names))
403
+
404
+ if indent_unit is not None and indent_unit > 0 and (fix_warnings or multiple_roots):
405
+ warnings.append(get_string("core_hint_indent_unit_check"))
406
+
407
+ return roots, warnings
@@ -0,0 +1,98 @@
1
+ """
2
+ treeing/core/preview.py
3
+
4
+ Defines preview-label formatting and ASCII tree rendering.
5
+ Provides `format_preview_label` and `render_text_tree`, shared by the GUI
6
+ Treeview and the CLI `--format tree` output, so both displays stay in sync.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ..config import get_string
12
+ from .constants import DOT_ROOT, VIRTUAL_AUTO, VIRTUAL_NODE_NAMES
13
+ from .generator import parse_nested_name, sanitize_filename
14
+
15
+
16
+ def _disk_label(name: str, is_dir: bool, *, allow_nested: bool) -> str:
17
+ """
18
+ Turn an on-disk name into the form shown in the preview.
19
+
20
+ When `--allow-nested-names` is on, a nested path like `foo/bar` is split,
21
+ each segment sanitised, then rejoined; otherwise the whole thing is
22
+ treated as one name.
23
+
24
+ If the name was changed (e.g. illegal characters replaced), the original
25
+ is appended in parentheses so the user can see "I changed this".
26
+ """
27
+ raw = name.rstrip('/')
28
+ trailing_slash = name.endswith('/') or is_dir
29
+ if allow_nested and ('/' in raw or '\\' in raw):
30
+ parts = parse_nested_name(raw)
31
+ if parts:
32
+ label = '/'.join(sanitize_filename(part) for part in parts)
33
+ else:
34
+ label = sanitize_filename(raw)
35
+ else:
36
+ label = sanitize_filename(raw)
37
+ if trailing_slash:
38
+ label += '/'
39
+ if label != name and not (name.endswith('/') and label == name.rstrip('/') + '/'):
40
+ return f"{label} ({name})"
41
+ return label
42
+
43
+
44
+ def format_preview_label(node: dict, *, allow_nested: bool) -> str:
45
+ """
46
+ Return the text a node should show in the preview.
47
+
48
+ Virtual nodes (<auto>, <virtual>) and the dot root (.) get special
49
+ treatment: they are prefixed to tell the user these were inserted by the
50
+ program, not written by them.
51
+
52
+ Ordinary nodes go through _disk_label: sanitised and/or expanded as needed.
53
+ """
54
+ name = node['name']
55
+ is_virtual = name in VIRTUAL_NODE_NAMES
56
+ is_dot_root = name == DOT_ROOT
57
+ is_dir = node.get('is_dir', False)
58
+
59
+ if is_virtual:
60
+ if name == VIRTUAL_AUTO:
61
+ display_name = get_string('preview_virtual_auto_display')
62
+ else:
63
+ display_name = get_string('preview_virtual_root_display')
64
+ elif is_dot_root:
65
+ display_name = get_string('preview_dot_root_display')
66
+ else:
67
+ display_name = _disk_label(name, is_dir, allow_nested=allow_nested)
68
+
69
+ if is_virtual or is_dot_root:
70
+ display_name = get_string('preview_virtual_prefix') + display_name
71
+ return display_name
72
+
73
+
74
+ def render_text_tree(tree: list[dict], *, allow_nested: bool) -> list[str]:
75
+ """
76
+ Render the parsed tree into ASCII text lines for the CLI `--format tree` output.
77
+
78
+ Root nodes carry no branch prefix; children use ├── / └── and │ as the
79
+ level connector. This matches the `tree` command output, which users are
80
+ used to.
81
+
82
+ MING deliberately shares format_preview_label with the GUI Treeview logic,
83
+ so a change in one place updates both and they never drift apart.
84
+ """
85
+ lines: list[str] = []
86
+
87
+ def walk_children(nodes: list[dict], prefix: str) -> None:
88
+ for i, node in enumerate(nodes):
89
+ is_last = i == len(nodes) - 1
90
+ branch = '└── ' if is_last else '├── '
91
+ contin = ' ' if is_last else '│ '
92
+ lines.append(prefix + branch + format_preview_label(node, allow_nested=allow_nested))
93
+ walk_children(node.get('children', []), prefix + contin)
94
+
95
+ for node in tree:
96
+ lines.append(format_preview_label(node, allow_nested=allow_nested))
97
+ walk_children(node.get('children', []), '')
98
+ return lines
@@ -0,0 +1,5 @@
1
+ """treeing/gui/__init__.py
2
+
3
+ GUI package entry point.
4
+ Submodules are imported lazily so the CLI entry point does not load tkinter.
5
+ """