上周有人把一份四十多行的目录树丢给我,说是让 AI 帮忙设计的前端工程骨架,从src/components一路铺到tests/fixtures,中间还夹着├──、└──、│这些画图符号。他问我能不能半小时内让它变成真的文件夹。我看了眼他桌面上一堆叫“新建文件夹 (3)”的东西,就知道他已经在手动点了。手工建目录这件事,麻烦不在于数量,而在于一旦错一层,后面全得返工,而且你永远不知道自己漏了哪个。
这篇讲的就是把 AI 输出的项目结构图批量落成真实文件夹和空文件的整套做法。核心技术点很集中:解析树状文本、还原层级关系、做跨平台命名清洗、幂等落盘、再加一层 dry-run 保护。适合三类人看:手里正拿着一份 AI 生成的目录树不知道怎么办的新手;写过几个脚本但总被编码、非法字符、路径冲突绊住的中级开发者;以及想把“结构图 → 脚手架”做进团队流程的人。下面这套流程我在不同系统上跑过几十次,代码不长,但要踩的坑一个不少。
1. 别急着点鼠标:先搞清楚结构图到底是什么
1.1 目录树文本其实是一份压缩过的路径清单
很多人没意识到,AI 吐出来的那种树形图,本质上不是“图”,而是一份用缩进编码层级的扁平列表。每一行只携带两个信息:一个名字,和它与上一行的相对深度。├──和└──只是给人看的装饰,│只用来对齐视觉。真正决定结构的是行首那段图形字符总宽度。
想通这点,后面的实现就顺了:你不需要去“理解”这棵树,只需要逐行算出每行的缩进量,再拿它和上一行比大小,父子关系自然就出来了。把它当成一个有 40 行的日志文件来处理,比当成一个图形结构处理要简单得多。
1.2 AI 输出的三种常见形态与各自的坑
实际拿到的文本并不统一,我至少见过三类:
| 形态 | 典型样子 | 主要问题 |
|---|---|---|
| 标准 tree 输出 | ├── src加 4 字符缩进 | 最省事,可直接解析 |
| Markdown 嵌套列表 | - src/- main.js | 缩进宽度不稳定,2 空格和 4 空格混用 |
| 纯路径清单 | src/main.js一行一个 | 没有目录节点,得自己补全父目录 |
第一种最理想。第二种坑最多,因为 AI 经常写着写着把 4 空格缩进改成 2 空格,同一份输出里两种宽度并存,用“宽度除以 4”去算层级会直接错位。第三种反而最好处理,因为路径本身已经完整了,只需要parents=True建父目录。
我给的建议是:优先把结构图要成第一种或第三种,别要第二种。这在提示词层面一句话就能解决,比在后面写一堆启发式规则去猜划算太多。
1.3 三条技术路线怎么选
摆在你面前其实有三条路,我先把结论放这儿:
- 手写解析脚本:灵活度最高,能处理各种畸形输入,能加自定义清洗和模板填充。适合结构图超过 30 行、且以后还会反复用的人。缺点是要自己写,大约六十行。
- 模板脚手架工具:如果你要的结构是某种成熟框架的骨架(比如某类前端工程的标准分层),直接用现成的脚手架生成器一条命令就够了,根本不用解析文本。缺点是它只认预设模板,AI 给你的那套命名和分层它不认。
- 编辑器批量新建:部分编辑器支持选中多行文本批量创建文件,十几行以内可以凑合。超过二十行就明显不如脚本,因为你没法控制目录。
我选第一条,理由很实在:AI 给的结构图是一次性、非标准、随时会变的,模板脚手架覆盖不了;而批量新建又不解决目录问题。写一个六十行的脚本,以后每次拿到新结构图都能直接复用,投入产出比最高。
2. 清洗:把“看着像树”的文本变成“机器能算”的层级
2.1 结构字符的语义还原
第一步是把视觉符号彻底剥掉。我定义一个结构字符集合:竖线│、分支├└、横线─,再加上半角空格,以及某些 AI 会用的|、`、-、+。从行首开始逐字符扫,遇到不属于这个集合的字符就停,扫过的字符数就是这行的缩进宽度。
这个做法有个好处:不管 AI 用的是 2 空格、3 空格还是 4 空格缩进,不管你用的是全角空格还是半角空格,测出来的宽度都能正确反映相对深度,因为父子之间总会有宽度差。用“绝对宽度除以固定值”是错的,用“相对大小比较”才对,这是整个解析里最关键的一个设计决定,后面第 3 节会展开。
2.2 别急着剥离行内注释
AI 很喜欢在目录树里加注释,比如main.py # 程序入口。如果你直接用split('#')去切,文件名里真带#的情况就被误伤了。我的做法是加一条保守规则:只有#前面有空白、后面跟一个空格时,才认定它是注释起点。
import re COMMENT_RE = re.compile(r'\s+#\s+') def strip_comment(name: str) -> str: return COMMENT_RE.split(name, maxsplit=1)[0].strip()同样的思路适用于//注释。别嫌麻烦,这条规则能救回不少被误切的名字。
2.3 目录与文件的判定策略
这是整个流程里最容易出错的一环。你的脚本必须知道某一行是建文件夹还是建空文件。我按优先级排了三层判定:
- 名字以
/或\结尾 → 目录。这是最可靠的信号,前提是 AI 输出的格式带斜杠。 - 下一行的缩进宽度比当前行大 → 目录。因为它有子节点,不可能是文件。
- 以上都不满足的叶子节点,看名字里有没有
.。有点且不以点开头 → 文件;否则 → 目录。
第三条是启发式,会误判。比如v1.2这种目录名会被当成文件,Dockerfile这种没有点但确实是文件的会被当成目录。所以我的实际做法是:跑完 dry-run 之后人工扫一眼清单,把判错的几行在 raw.txt 里手动加上斜杠。通常只有两三行,改一下比重写一套规则便宜得多。
2.4 命名合法化:跨平台的那些雷
AI 生成的目录树大概率是“理想世界”的,丢到真实文件系统上会炸。我整理了一份必查清单:
| 风险项 | 具体表现 | 处理方式 |
|---|---|---|
| Windows 非法字符 | `< > : " / \ | ? *` |
| 控制字符 | ASCII 0-31 | 直接剔除 |
| Windows 保留名 | CONPRNAUXNULCOM1-COM9LPT1-LPT9 | 加前缀如_CON |
| 结尾空格或点 | Windows 会自动去掉,导致路径对不上 | 从尾部剥离 |
| 大小写冲突 | macOS 默认不区分大小写,Linux 区分 | 检查同名不同大小写 |
| 路径过长 | Windows 传统限制 260 字符 | 缩短层级或提示用户 |
| 中文与空格 | 后续 shell 引用需引号包裹 | 保留,但脚本一律用 pathlib |
大小写这条特别隐蔽。你在 Linux 上生成了Utils/和utils/两个目录,跑得好好的;打包发给同事,在 macOS 上解压,一个把另一个覆盖了,找半天找不出原因。我的做法是在清洗阶段做一次全局小写去重检查,发现有冲突就报出来,让人工决定保留哪个。
路径长度也值得算一下。假设你的项目根目录绝对路径是 40 字符,结构图里最深的路径拼接后是 220 字符,加起来 260,正好卡在 Windows 传统上限上。稳妥线是留 20 字符余量,超了就把中间那几层目录名改短。
3. 核心代码:一个六十行的解析器够用了
3.1 先用栈把父子关系算出来
这是全套逻辑的心脏。维护一个栈,栈里存(缩进宽度, 名字)。每读一行,先把栈顶所有缩进宽度大于等于当前行的元素弹掉——注意是大于等于,因为同级节点也要弹,弹完之后栈顶就是当前行的父目录。然后把当前行压栈。
from pathlib import Path def parse_tree(text: str): """把结构图文本解析成 [(Path, is_dir)] 列表""" rows = [] for raw in text.splitlines(): if not raw.strip(): continue indent = measure_indent(raw) name = strip_comment(raw[indent:].strip()) if not name: continue rows.append([indent, name]) stack = [] # [(indent, name)] result = [] for i, (indent, name) in enumerate(rows): while stack and stack[-1][0] >= indent: stack.pop() has_child = i + 1 < len(rows) and rows[i + 1][0] > indent trailing_slash = name.endswith(('/', '\\')) clean = name.rstrip('/\\') is_dir = trailing_slash or has_child or not looks_like_file(clean) parent = [s[1] for s in stack] path = Path(*parent, clean) if parent else Path(clean) result.append((path, is_dir)) stack.append((indent, name)) return result为什么用“相对比较”而不是“宽度除以 4”?因为 AI 输出的缩进宽度不统一。假设某段用了 2 空格缩进,src缩进 0,components缩进 2,Button.jsx缩进 4。如果除以 4 取整,三层全变成第 0 层,所有文件都堆在根目录;而用相对比较,2 > 0 所以是子级,4 > 2 所以是孙级,完全正确。这个设计让脚本对缩进宽度免疫,是我在踩过一次坑之后改的。
3.2 落盘:目录和文件分开处理
解析出来的列表是一串相对路径,写盘时要注意两件事:一是父目录必须先存在,二是重复路径不能互相踩。
def materialize(items, root: Path, dry_run=True): plan = [] for rel, is_dir in items: target = root / rel plan.append((target, is_dir)) if dry_run: for target, is_dir in plan: kind = 'DIR ' if is_dir else 'FILE' print(f'{kind} {target}') return for target, is_dir in plan: if is_dir: target.mkdir(parents=True, exist_ok=True) else: target.parent.mkdir(parents=True, exist_ok=True) if not target.exists(): target.touch()parents=True是关键参数,它保证哪怕顺序错乱、父目录排在后面,也不会报错。exist_ok=True保证重复运行不中断,脚本幂等,可以反复执行。
target.exists()那层判断是为了不覆盖已有文件。touch()本身不会清空内容,但如果某个文件已经被人工填了代码,你不会希望它被重新创建。加这层判断的代价几乎为零,收益是心安。
如果同一路径在结构图里既被判定为目录又被判定为文件,mkdir会抛FileExistsError。这是好事,说明结构图本身有问题,让脚本炸出来比默默生成一个错结构强。
3.3 dry-run 与日志,别一上来就写盘
我坚持一条规矩:任何批量写文件系统的脚本,第一次跑必须只打印不执行。心理上你可能觉得“就几十个文件而已”,但真写到 40 个目录里发现第 3 行解析错了,你得手动删 40 个东西,删的时候还可能误伤原本就有的文件。
dry-run 输出建议带上绝对路径,这样你能一眼看出根目录拼对没有。我第一次跑的时候根目录写成了当前目录,结果几百个文件直接铺在了桌面,那画面挺壮观的。现在我会在输出里加一行ROOT: /xxx/yyy,扫一眼就能发现不对。
另外建议把 dry-run 结果重定向到文件,python gen.py --dry-run > plan.txt,然后打开plan.txt从头到尾过一遍。重点是看两处:根目录对不对、最深层的那几个文件路径是不是符合预期。这两处没问题,中间的通常也没问题。
4. 完整实操:从一段 AI 输出到能跑的工程
4.1 第一步:把原文存成 raw.txt
别直接在命令里粘贴,也别用编辑器另存成带 BOM 的格式。新建一个纯文本文件,把 AI 输出的树原封不动粘进去,保存为 UTF-8 无 BOM。Windows 上尤其注意,记事本默认可能是 ANSI 或 UTF-8 带 BOM,带 BOM 会让第一行解析出幺蛾子,第一个字符变成不可见字符,缩进测量直接错。
读文件时显式指定编码:
text = Path('raw.txt').read_text(encoding='utf-8')不指定的话,Windows 上默认走 GBK,遇到特殊符号会抛UnicodeDecodeError。这个问题我第一次遇到时盯着报错看了十分钟,最后发现只是没写encoding。
4.2 第二步:跑 dry-run 看清单
命令大概是python gen.py --src raw.txt --dst ./myproject --dry-run。输出会长这样:
ROOT: /home/user/myproject DIR /home/user/myproject/src DIR /home/user/myproject/src/components FILE /home/user/myproject/src/components/Button.jsx FILE /home/user/myproject/src/main.js DIR /home/user/myproject/tests FILE /home/user/myproject/tests/smoke.test.js重点检查三类问题:本该是 DIR 的显示成 FILE、路径里出现了不该有的层级、出现了..这种会跳到根目录外的片段。第三类最危险,如果 AI 输出的名字里有..,你的脚本会真的写到父目录去。清洗阶段必须拦掉,凡是名字是..或以../开头的,一律报错退出。
4.3 第三步:真正落盘并验证
去掉--dry-run再跑一次。跑完立刻验证,别急着往下做。验证方法很简单,用系统的tree命令(没有就装一个,或者用find . -type f)比对一下输出行数和结构图的行数。
行数对不上通常是两个原因:结构图里有重复路径被去重了,或者某些行被判成注释整行跳过了。前者是正常的,后者要去 raw.txt 里看看是不是被注释规则误伤。
我习惯在脚本里加一行统计:
print(f'共 {dir_count} 个目录, {file_count} 个文件')数字对上了,心里才踏实。
4.4 第四步:填充占位内容与初始化仓库
空文件有个问题:某些编辑器打开一片空白,某些构建工具会认为文件损坏(比如.json空文件解析失败)。所以我会给常见的扩展名塞一行占位内容:
TEMPLATES = { '.py': '# TODO\n', '.js': '// TODO\nexport default {};\n', '.ts': '// TODO\nexport {};\n', '.css': '/* TODO */\n', '.md': '# TODO\n', '.json': '{{}}\n', # 注意 format 里的花括号要转义 '.html': '<!DOCTYPE html>\n<html></html>\n', } def fill(target: Path): tpl = TEMPLATES.get(target.suffix.lower()) if tpl and target.stat().st_size == 0: target.write_text(tpl, encoding='utf-8')target.stat().st_size == 0这个判断很重要,只填空文件,不覆盖已经写了内容的。{{}}那种转义也很容易忘,忘了就直接KeyError。
填完内容再git init加一次初始提交,后面改结构就有对照了。
5. 常见问题排查与几个踩过的坑
5.1 症状对照表
| 症状 | 大概率原因 | 处理方式 |
|---|---|---|
| 所有文件都堆在根目录 | 用了绝对宽度除法算层级 | 改成相对比较的栈算法 |
| 层级整体错一层 | 第一行是根节点名,被当成了目录 | 手工删掉根节点那行再解析 |
报UnicodeDecodeError | 读文件没指定 UTF-8 | 显式加encoding='utf-8' |
报FileExistsError | 同一路径被同时判为文件和目录 | 检查结构图重复,或命名清洗后撞名 |
| 目录全成了文件 | 判定规则只看扩展名 | 加has_child判断 |
| 中文目录乱码 | Windows 控制台编码是 GBK | 脚本里统一PYTHONIOENCODING=utf-8 |
| 文件建到了上一层目录 | 名字含.. | 清洗阶段直接拒绝 |
| 第二次跑就报错 | 没加exist_ok | 补上幂等参数 |
5.2 三个真实踩坑记录
第一个坑是全角符号。有次 AI 输出的树用的是全角空格和全角竖线,我的结构字符集合里只有半角版本,结果每行缩进都测成 0,所有东西全平铺在根目录。后来我在清洗阶段加了一步unicodedata.normalize('NFKC', text),把全角统一转半角,问题解决。这个坑很隐蔽,因为你肉眼看不出半角空格和全角空格的区别,得把字体换成等宽或者打开显示空白字符才能发现。
第二个坑是同名目录文件。结构图里既有config/目录,又在同一层列了config文件(本意是配置文件)。脚本先建了目录,再想建文件时直接冲突。我的处理是把这类冲突报出来,人工作决策,而不是脚本自作主张选一个。自动化的边界在于:能自动的部分自动,有歧义的部分一律交给人。
第三个坑是尾部空格。AI 输出的某些行末尾带了看不见的空格,解析出来的名字是src而不是src。在 Linux 上这俩是不同的目录,会默默生成两个看起来一样的文件夹。所以我加了strip(),并且在 dry-run 输出里用方括号包住路径,[src ]这种一眼就能看出来。
5.3 反过来调教 AI:让它一次输出可解析格式
前面折腾了这么多清洗逻辑,其实最省力的做法是把问题前置到提示词。我现在会加一段固定要求:
请用缩进 2 空格的纯文本树输出,不要用 Markdown 列表;目录名后加
/;每行只写名字,不要写注释;不要输出根节点行。
这三句话把大部分解析问题都挡住了:带斜杠解决了目录判定,去掉注释省了剥离逻辑,不要根节点避免了层级偏移,固定 2 空格缩进让宽度测量更稳。跟 AI 协作的正确姿势不是拿到什么处理什么,而是先把输出格式的契约谈好。这一点在生成目录树、配置文件、SQL 建表语句上全都适用。
6. 一些延伸玩法
6.1 从目录树反推文档
这套解析逻辑反过来用也很有价值。你手里有个跑了三年的老项目,目录乱成一团,想整理成文档给新人看。用同样的栈算法遍历现有目录,输出成标准树形文本,再贴给 AI 让它写一段结构说明。比人工画图快得多,而且不会漏。
反向生成时有个细节:排序。默认按字母序排,目录在前文件在后,这样输出的树比较符合阅读习惯。另外记得跳过node_modules、.git、dist、__pycache__这类目录,不然输出几百上千行,AI 也读不完。
6.2 把结构图做成团队规范模板
既然解析器已经写好,稍微改一下就能做成“结构模板校验”:团队约定一套标准目录,每次有人新建项目就跑一遍比对,缺哪个目录、多哪个文件、命名不符合规范,全部报出来。这件事的价值不在于技术难度,而在于把口头约定变成了可执行的检查。
我见过不少团队在文档里写“组件统一放在src/components下”,然后半年后项目里出现了四种不同的组件目录。有脚本兜着,这种漂移会慢很多。
我个人的体会是,这类小工具的价值从来不在于它多复杂,而在于它把一件重复的、容易出错的手工活彻底变成了一次性的投入。写它的两个小时,会在后面每一次新建项目时被赚回来。最后分享一个小习惯:我给自己写的所有批量生成脚本都默认--dry-run开启,想真跑必须显式加--execute。这个反直觉的默认值救过我至少三次。