Files
xiaoli-cli/plugins/code_editor.py
T
shuiyu67 9a133dcf88 feat: py_detect 纯本地 Python 智能检测插件 + code_editor 写 .py 后自动检测
- 零第三方依赖 (仅标准库 ast/tokenize),低配电脑无压力
- 三层检测: 语法错误精确定位 / 作用域语义检测(未定义变量/重复定义/未使用导入) / 反模式扫描(可变默认参数/裸except/==None/迭代中修改/覆盖内置/import*/除零/缺self/死代码)
- 每条诊断带中文解释 + 修复建议 (规则模板, 零 AI)
- code_editor 所有写文件操作 (edit/multi_edit/insert/delete_lines/create/write/append) 改完 .py 自动调用检测, 仅有问题时输出前6条
- 40 项测试 (含 5 项 code_editor 自动检测集成)
2026-08-07 01:41:38 +08:00

1558 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
代码工具 - 统一的代码编辑与搜索能力
合并原 code_editor + code_search,消除重复
"""
import os
import re
import ast
import difflib
import py_compile
import json
import subprocess
import sys
import tempfile
import platform
import threading
from typing import Optional, Tuple, List
class Liugin:
"""代码编辑与搜索 - 精准编辑 + 代码理解"""
def __init__(self):
self.usage = """代码编辑与搜索工具
编辑操作 (修改后自动检查语法):
edit <文件> <old> <<<>>> <new> - 精准替换代码片段
multi <文件> <JSON数组> - 批量修改
insert <文件> <行号> <内容> - 在指定行插入
delete_lines <文件> <起始行> <结束行> - 删除指定行
create <文件路径> [内容] - 创建新文件
write <文件路径> <内容> - 写入/覆盖文件
append <文件路径> <内容> - 追加内容到文件
语法检查:
syntax_check <文件路径> - 主动检查文件语法 (别名: check)
支持: Python/JS/TS/JSON/YAML/TOML/HTML/XML/CSS/Shell/SQL
编辑操作后自动执行,无需手动调用
Diff 弹窗:
diff_popup [on|off|popup|inline] - 开关/切换 diff 显示模式 (别名: popup)
popup/弹窗 → 弹窗模式(新终端窗口)
inline/ssh → 主终端内显示(SSH 兼容)
on/开 → 开启 | off/关 → 关闭 | 无参数 → 切换
查看操作:
read_range <文件> <起始行> [结束行] - 读取指定行范围
diff <文件1> <文件2> - 比较两个文件差异
diff_text <old> <<<>>> <new> - 比较两段文本差异
ast_info <文件> - Python AST 摘要 (类/函数/导入)
搜索操作:
find <目录> <关键词> [文件模式] - 关键词搜索
regex <目录> <正则表达式> [文件模式] - 正则搜索
symbols <目录> [文件模式] - 提取所有符号 (类/函数)
imports <文件> - 分析导入依赖
callers <目录> <函数名> - 查找函数调用位置
todo <目录> [文件模式] - 搜索 TODO/FIXME
structure <目录> [深度] - 项目目录结构
context <文件> [行号] [范围] - 获取某行上下文
stats <目录> - 代码统计
"""
self.cli = None
self.diff_popup_enabled = True # diff 弹窗开关
self.diff_popup_mode = None # None=启动时询问, True=弹窗, False=主终端内显示
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "code_editor",
"description": "代码编辑与搜索 — 精准替换、批量编辑、代码搜索、符号提取、依赖分析、diff对比、自动语法检查",
"keywords": ["编辑", "修改", "代码", "文件", "edit", "replace", "search", "diff",
"find", "grep", "符号", "structure", "imports", "todo", "callers",
"语法", "syntax", "check", "lint", "检查"],
"usage": self.usage
}
def get_mcp_definition(self):
return {
"name": "code_editor",
"description": "代码编辑与搜索工具(含自动语法检查)",
"inputSchema": {
"type": "object",
"properties": {
"operation": {"type": "string", "description": "操作类型"},
"path": {"type": "string", "description": "文件路径"},
"old_text": {"type": "string"},
"new_text": {"type": "string"},
"content": {"type": "string"},
"line_number": {"type": "integer"},
"start_line": {"type": "integer"},
"end_line": {"type": "integer"},
"pattern": {"type": "string"},
"file_pattern": {"type": "string"},
"directory": {"type": "string"},
"operations": {"type": "array"}
},
"required": ["operation"]
}
}
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
path = arguments.get("path", "")
return f"{op} {path}"
# ── 路由 ──
def handle(self, args: str) -> str:
try:
# 先提取操作名
first_space = args.strip().find(' ')
if first_space == -1:
operation = args.strip().lower()
arg1, arg2 = "", ""
else:
operation = args.strip()[:first_space].lower()
rest = args.strip()[first_space + 1:]
# diff_text 特殊处理:用 <<<>>> 分割
if operation == "diff_text" and " <<<>>> " in rest:
arg1, arg2 = rest.split(" <<<>>> ", 1)
else:
parts = rest.split(maxsplit=1)
arg1 = parts[0] if parts else ""
arg2 = parts[1] if len(parts) > 1 else ""
if not operation:
return "错误:请提供操作类型"
handlers = {
# 编辑
"edit": self._op_edit,
"multi": self._op_multi_edit,
"insert": self._op_insert,
"delete_lines": self._op_delete_lines,
"create": self._op_create,
"write": self._op_write,
"append": self._op_append,
# 查看
"read_range": self._op_read_range,
"diff": self._op_diff,
"diff_text": self._op_diff_text,
"ast_info": self._op_ast_info,
# 搜索
"find": self._op_find,
"regex": self._op_regex,
"symbols": self._op_symbols,
"imports": self._op_imports,
"callers": self._op_callers,
"todo": self._op_todo,
"structure": self._op_structure,
"context": self._op_context,
"stats": self._op_stats,
# 语法检查
"syntax_check": self._op_syntax_check,
"check": self._op_syntax_check, # 简写别名
# Diff 弹窗
"diff_popup": self._op_diff_popup,
"popup": self._op_diff_popup, # 简写别名
}
handler = handlers.get(operation)
if not handler:
return f"错误:不支持的操作 '{operation}'"
return handler(arg1, arg2)
except Exception as e:
return f"代码工具错误: {str(e)}"
# ── 工具方法 ──
def _norm(self, path: str) -> str:
path = path.strip().strip('"').strip("'")
return os.path.normpath(os.path.expanduser(path))
def _walk_files(self, directory: str, pattern: str = "*.py",
max_files: int = 200) -> List[str]:
if pattern.startswith("*."):
ext = pattern[1:]
filt = lambda f: f.endswith(ext)
elif pattern == "*":
filt = lambda f: True
else:
filt = lambda f: pattern in f
files = []
skip = {'.git', 'node_modules', '__pycache__', '.venv', 'venv',
'env', '.tox', '.mypy_cache', '.pytest_cache'}
for root, dirs, fnames in os.walk(directory):
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in skip]
for f in fnames:
if filt(f):
files.append(os.path.join(root, f))
if len(files) >= max_files:
return files
return files
def _make_diff(self, old: str, new: str, label: str = "修改") -> str:
diff = difflib.unified_diff(
old.splitlines(keepends=True), new.splitlines(keepends=True),
fromfile="原始", tofile="修改后", lineterm='')
text = '\n'.join(diff)
return f" 变更预览:\n{text}" if text else "内容无变化"
# ══════════════════════════════════════
# 语法检查系统
# ══════════════════════════════════════
# 支持语法检查的语言映射
_CODE_EXTENSIONS = {
'.py': 'python',
'.pyw': 'python',
'.pyi': 'python',
'.js': 'javascript',
'.mjs': 'javascript',
'.cjs': 'javascript',
'.jsx': 'javascript',
'.ts': 'typescript',
'.tsx': 'typescript',
'.mts': 'typescript',
'.json': 'json',
'.jsonl': 'json',
'.jsonc': 'json',
'.yaml': 'yaml',
'.yml': 'yaml',
'.toml': 'toml',
'.html': 'html',
'.htm': 'html',
'.xml': 'xml',
'.css': 'css',
'.sh': 'shell',
'.bash': 'shell',
'.zsh': 'shell',
'.sql': 'sql',
'.md': None, # 不检查
'.txt': None,
'.csv': None,
'.log': None,
'.gitignore': None,
'.env': None,
}
def _get_lang(self, filepath: str) -> Optional[str]:
"""根据文件扩展名获取语言类型,None 表示跳过检查"""
_, ext = os.path.splitext(filepath.lower())
return self._CODE_EXTENSIONS.get(ext)
def _is_code_file(self, filepath: str) -> bool:
"""判断是否是需要语法检查的代码文件"""
lang = self._get_lang(filepath)
return lang is not None
def _check_syntax(self, filepath: str) -> str:
"""
检查文件语法,返回结果字符串。
- 通过: 返回空字符串 ""
- 失败: 返回详细的错误信息
- 跳过: 返回 None
"""
lang = self._get_lang(filepath)
if lang is None:
return None # 非代码文件,跳过
if not os.path.exists(filepath):
return None
try:
with open(filepath, 'r', encoding='utf-8') as f:
source = f.read()
except (UnicodeDecodeError, PermissionError):
return None
if not source.strip():
return None # 空文件跳过
if lang == 'python':
return self._check_python_syntax(filepath, source)
elif lang == 'javascript':
return self._check_js_syntax(filepath, source)
elif lang == 'typescript':
return self._check_ts_syntax(filepath, source)
elif lang == 'json':
return self._check_json_syntax(filepath, source)
elif lang == 'yaml':
return self._check_yaml_syntax(filepath, source)
elif lang == 'toml':
return self._check_toml_syntax(filepath, source)
elif lang == 'html':
return self._check_html_syntax(filepath, source)
elif lang == 'xml':
return self._check_xml_syntax(filepath, source)
elif lang == 'css':
return self._check_css_syntax(filepath, source)
elif lang == 'shell':
return self._check_shell_syntax(filepath, source)
elif lang == 'sql':
return self._check_sql_syntax(filepath, source)
return None
def _check_python_syntax(self, filepath: str, source: str) -> str:
"""Python 语法检查:ast.parse + py_compile 双重验证"""
errors = []
# 方法1: ast.parse(更精确的错误定位)
try:
tree = ast.parse(source, filename=filepath)
except SyntaxError as e:
lineno = e.lineno or 0
offset = e.offset or 0
text = (e.text or '').rstrip()
msg = e.msg or 'syntax error'
pointer = ' ' * (offset - 1) + '^' if offset > 0 else ''
errors.append(
f" 第 {lineno} 行, 列 {offset}: {msg}\n"
f" {text}\n"
f" {pointer}"
)
# 方法2: py_compile(捕获 ast.parse 可能遗漏的问题)
if not errors:
try:
py_compile.compile(filepath, doraise=True)
except py_compile.PyCompileError as e:
errors.append(f" 编译错误: {e}")
if errors:
return f" Python 语法检查失败 ({filepath}):\n" + "\n".join(errors)
return ""
def _check_js_syntax(self, filepath: str, source: str) -> str:
"""JavaScript 语法检查:使用 Node.js --check"""
try:
result = subprocess.run(
[sys.executable, '-c', f'''
import json, sys
# 基础 JS 语法检查:括号匹配
source = {json.dumps(source)}
errors = []
stack = []
pairs = {{'(': ')', '[': ']', '{{': '}}'}}
close_to_open = {{v: k for k, v in pairs.items()}}
line = 1
col = 0
in_string = None
in_comment = False
in_block_comment = False
prev = ''
for i, ch in enumerate(source):
col += 1
if ch == '\\n':
line += 1
col = 0
in_comment = False
continue
if in_block_comment:
if prev == '*' and ch == '/':
in_block_comment = False
prev = ch
continue
if in_comment:
prev = ch
continue
if prev == '/' and ch == '/':
in_comment = True
prev = ch
continue
if prev == '/' and ch == '*':
in_block_comment = True
prev = ch
continue
if ch in ('"', "'", '`'):
if in_string == ch:
in_string = None
elif not in_string:
in_string = ch
prev = ch
continue
if in_string:
prev = ch
continue
if ch in pairs:
stack.append((ch, line, col))
elif ch in close_to_open:
if stack and stack[-1][0] == close_to_open[ch]:
stack.pop()
else:
errors.append(f" 第 {{line}} 行, 列 {{col}}: 不匹配的闭合括号 '{{ch}}'")
prev = ch
for open_ch, line, col in stack:
errors.append(f" 第 {{line}} 行, 列 {{col}}: 未闭合的括号 '{{open_ch}}'")
if errors:
print("ERROR:" + "\\n".join(errors))
else:
print("OK")
'''],
capture_output=True, text=True, timeout=5
)
output = result.stdout.strip()
if output.startswith("ERROR:"):
return f" JavaScript 语法检查失败 ({filepath}):\n" + output[6:]
except Exception:
pass
return ""
def _check_ts_syntax(self, filepath: str, source: str) -> str:
"""TypeScript 语法检查:尝试 tsc --noEmit,回退到 JS 检查"""
try:
result = subprocess.run(
['npx', 'tsc', '--noEmit', '--pretty', 'false', filepath],
capture_output=True, text=True, timeout=10
)
if result.returncode != 0 and result.stderr.strip():
errors = []
for line in result.stderr.strip().split('\n')[:10]:
if 'error TS' in line:
errors.append(f" {line.strip()}")
if errors:
return f" TypeScript 语法检查失败 ({filepath}):\n" + "\n".join(errors)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# 回退到 JS 括号检查
return self._check_js_syntax(filepath, source)
def _check_json_syntax(self, filepath: str, source: str) -> str:
"""JSON 语法检查"""
try:
json.loads(source)
except json.JSONDecodeError as e:
lineno = e.lineno or 0
colno = e.colno or 0
return (
f" JSON 语法检查失败 ({filepath}):\n"
f" 第 {lineno} 行, 列 {colno}: {e.msg}\n"
f" 文档位置: 字符 {e.pos}"
)
return ""
def _check_yaml_syntax(self, filepath: str, source: str) -> str:
"""YAML 语法检查"""
try:
import yaml
list(yaml.safe_load_all(source))
except ImportError:
# 无 PyYAML,用基础检查
if source.strip().startswith('{') or source.strip().startswith('['):
return self._check_json_syntax(filepath, source)
except yaml.YAMLError as e:
mark = getattr(e, 'problem_mark', None)
if mark:
return (
f" YAML 语法检查失败 ({filepath}):\n"
f" 第 {mark.line + 1} 行, 列 {mark.column + 1}: {getattr(e, 'problem', str(e))}"
)
return f" YAML 语法检查失败 ({filepath}):\n {e}"
return ""
def _check_toml_syntax(self, filepath: str, source: str) -> str:
"""TOML 语法检查"""
try:
import tomllib
tomllib.loads(source)
except ImportError:
pass # Python < 3.11 无 tomllib
except Exception as e:
return f" TOML 语法检查失败 ({filepath}):\n {e}"
return ""
def _check_html_syntax(self, filepath: str, source: str) -> str:
"""HTML 基础检查:标签匹配"""
errors = []
import re as _re
# 匹配开闭标签
tag_pattern = _re.compile(r'<(/?)(\w+)[^>]*>')
void_tags = {'br', 'hr', 'img', 'input', 'meta', 'link', 'area',
'base', 'col', 'embed', 'source', 'track', 'wbr'}
stack = []
for m in tag_pattern.finditer(source):
is_close = m.group(1) == '/'
tag_name = m.group(2).lower()
if tag_name in void_tags:
continue
pos = m.start()
line_num = source[:pos].count('\n') + 1
if is_close:
if stack and stack[-1][0] == tag_name:
stack.pop()
elif stack:
errors.append(
f" 第 {line_num} 行: </{tag_name}> 与 <{stack[-1][0]}> 不匹配"
)
else:
stack.append((tag_name, line_num))
for tag_name, line_num in stack:
errors.append(f" 第 {line_num} 行: <{tag_name}> 未闭合")
if errors and len(errors) <= 10:
return f" HTML 标签检查 ({filepath}):\n" + "\n".join(errors)
return ""
def _check_xml_syntax(self, filepath: str, source: str) -> str:
"""XML 语法检查:使用 xml.etree"""
import xml.etree.ElementTree as ET
try:
ET.fromstring(source)
except ET.ParseError as e:
return f" XML 语法检查失败 ({filepath}):\n {e}"
return ""
def _check_css_syntax(self, filepath: str, source: str) -> str:
"""CSS 基础检查:花括号匹配"""
errors = []
depth = 0
in_string = False
string_char = None
line_num = 1
for i, ch in enumerate(source):
if ch == '\n':
line_num += 1
continue
if in_string:
if ch == string_char and (i == 0 or source[i-1] != '\\'):
in_string = False
continue
if ch in ('"', "'"):
in_string = True
string_char = ch
continue
if ch == '{':
depth += 1
elif ch == '}':
if depth > 0:
depth -= 1
else:
errors.append(f" 第 {line_num} 行: 多余的闭合花括号 '}}'")
if depth > 0:
errors.append(f" 文件末尾: {depth} 个未闭合的花括号 '{{' ")
if errors:
return f" CSS 括号检查 ({filepath}):\n" + "\n".join(errors)
return ""
def _check_shell_syntax(self, filepath: str, source: str) -> str:
"""Shell 脚本语法检查:使用 bash -n"""
try:
result = subprocess.run(
['bash', '-n', filepath],
capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
stderr = result.stderr.strip()
lines = stderr.split('\n')[:5]
return f" Shell 语法检查失败 ({filepath}):\n" + "\n".join(f" {l}" for l in lines)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return ""
def _check_sql_syntax(self, filepath: str, source: str) -> str:
"""SQL 基础检查:关键字匹配"""
# 简单检查未闭合的引号和括号
errors = []
single_q = double_q = 0
paren_depth = 0
line_num = 1
for ch in source:
if ch == '\n':
line_num += 1
continue
if ch == "'":
single_q += 1
elif ch == '"':
double_q += 1
elif ch == '(':
paren_depth += 1
elif ch == ')':
paren_depth -= 1
if single_q % 2 != 0:
errors.append(" 存在未闭合的单引号")
if double_q % 2 != 0:
errors.append(" 存在未闭合的双引号")
if paren_depth > 0:
errors.append(f" 存在 {paren_depth} 个未闭合的括号")
if errors:
return f" SQL 检查 ({filepath}):\n" + "\n".join(errors)
return ""
def _auto_syntax_check(self, filepath: str) -> str:
"""
编辑操作后自动语法检查。
返回格式化的结果字符串,供拼接到操作结果末尾。
- 通过: 返回简短成功提示
- 失败: 返回详细错误信息
- 跳过: 返回空字符串
"""
result = self._check_syntax(filepath)
if result is None:
return "" # 非代码文件,不检查
if result == "":
return f"\n 语法检查通过 ✓"
else:
return f"\n{result}"
def _auto_lsp_check(self, filepath: str) -> str:
"""写/改文件后自动让已连接的语言服务器(pylsp 等)重新诊断该文件。
诊断结果会被缓存进 LSP 管理器的 _workspace_diag,由 get_lsp_context()
在下一轮静默注入模型上下文;同时仅在确有问题时给一行可见提示。
无对应 server / 文件干净时静默跳过,绝不影响主流程。
"""
manager = None
try:
cli = self.cli
utm = getattr(cli, 'liugin_manager', None)
if utm is not None:
manager = getattr(utm, '_lsp_manager', None)
except Exception:
manager = None
if manager is None:
return ""
try:
diag_text = manager.diagnostics(filepath, wait=1.5)
except Exception:
return ""
if not diag_text or "没有为" in diag_text:
return "" # 该扩展名未配置 LSP server,跳过
if "无诊断" in diag_text:
return "" # 干净,无需呈现(缓存也为空)
first = diag_text.splitlines()[0] if diag_text else ""
return f"\n LSP 诊断: {first}"
def _load_py_detect(self):
"""延迟加载 py_detect 核心(plugins/py_detect.py),失败返回 None 并缓存"""
cached = getattr(self, "_py_detect_mod", "unset")
if cached != "unset":
return cached
try:
import importlib.util
plugin_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "py_detect.py")
if not os.path.exists(plugin_path):
self._py_detect_mod = None
return None
spec = importlib.util.spec_from_file_location("py_detect_core", plugin_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
self._py_detect_mod = mod
return mod
except Exception:
self._py_detect_mod = None
return None
def _auto_py_detect(self, filepath: str) -> str:
"""写/改 .py 文件后自动智能检测(py_detect,纯本地零依赖)。
仅在 .py 文件且检测到问题时输出(截断为前 6 条,避免刷屏),
干净或非 py 文件静默返回空串——与 _auto_lsp_check 同风格。
"""
if not filepath.lower().endswith(".py"):
return ""
mod = self._load_py_detect()
if mod is None:
return ""
try:
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
source = f.read()
issues = mod.analyze(source, filepath)
if not issues:
return ""
order = {"错误": 0, "警告": 1, "提示": 2}
issues = sorted(issues, key=lambda d: (order.get(d["severity"], 9), d["line"], d["col"]))
n = len(issues)
n_e = sum(1 for d in issues if d["severity"] == "错误")
n_w = sum(1 for d in issues if d["severity"] == "警告")
n_i = sum(1 for d in issues if d["severity"] == "提示")
lines = [f"\n [py_detect] 检测到 {n} 个问题 (错误 {n_e} · 警告 {n_w} · 提示 {n_i}):"]
for d in issues[:6]:
lines.append(f" [{d['code']}] {d['severity']} @{d['line']}:{d['col']} {d['title']}"
+ (f" ({d['extra']})" if d.get("extra") else ""))
if n > 6:
lines.append(f" … 还有 {n - 6} 条,py_detect check {filepath} 查看全部")
return "\n".join(lines)
except Exception:
return ""
# ══════════════════════════════════════
# Diff 弹窗系统
# ══════════════════════════════════════
def _read_file_safe(self, filepath: str) -> str:
"""安全读取文件,失败返回空字符串"""
try:
with open(filepath, 'r', encoding='utf-8') as f:
return f.read()
except Exception:
return ""
def _spawn_diff_popup(self, filepath: str, before: str, after: str):
"""
显示修改前后的对比。
根据 diff_popup_mode 决定弹窗还是主终端内显示。
"""
if not self.diff_popup_enabled:
return
if before == after:
return # 内容无变化,不弹窗
# 启动时未选择模式,默认为主终端内显示(SSH 兼容)
popup = self.diff_popup_mode if self.diff_popup_mode is not None else False
if popup:
thread = threading.Thread(
target=self._do_spawn_diff_popup,
args=(filepath, before, after),
daemon=True
)
thread.start()
else:
self._show_inline_diff(filepath, before, after)
def _do_spawn_diff_popup(self, filepath: str, before: str, after: str):
"""实际执行弹窗创建(在子线程中运行)"""
basename = os.path.basename(filepath)
# 生成带行号的 before/after
before_numbered = self._number_lines(before)
after_numbered = self._number_lines(after)
# 生成 diff
diff_lines = list(difflib.unified_diff(
before.splitlines(keepends=True),
after.splitlines(keepends=True),
fromfile=f"修改前: {basename}",
tofile=f"修改后: {basename}",
lineterm=''
))
# 统计变更
added = sum(1 for l in diff_lines if l.startswith('+') and not l.startswith('+++'))
removed = sum(1 for l in diff_lines if l.startswith('-') and not l.startswith('---'))
before_count = len(before.splitlines())
after_count = len(after.splitlines())
# 构建弹窗显示内容
display = self._build_diff_display(
basename, filepath,
before_numbered, after_numbered,
diff_lines, added, removed,
before_count, after_count
)
# 写入临时脚本并弹出
self._launch_popup_window(display, basename)
def _number_lines(self, content: str) -> str:
"""给内容加上行号"""
lines = content.splitlines()
width = len(str(len(lines)))
return '\n'.join(f"{i+1:>{width}} | {line}" for i, line in enumerate(lines))
def _build_diff_display(self, basename, filepath,
before_numbered, after_numbered,
diff_lines, added, removed,
before_count, after_count):
"""构建完整的 diff 显示文本(带 ANSI 颜色)"""
sep = '═' * 70
thin_sep = '─' * 70
# ANSI 颜色
R = '\033[31m' # 红字
G = '\033[32m' # 绿字
C = '\033[36m' # 青
B = '\033[1m' # 粗体
D = '\033[2m' # 暗
W = '\033[37m' # 白
N = '\033[0m' # 重置
RBG = '\033[41m\033[97m' # 红底白字
GBG = '\033[42m\033[97m' # 绿底白字
# 构建带颜色的 diff
diff_colored_lines = []
for line in diff_lines:
if line.startswith('@@'):
diff_colored_lines.append(f"{C}{line}{N}")
elif line.startswith('+++') or line.startswith('---'):
diff_colored_lines.append(f"{B}{line}{N}")
elif line.startswith('+'):
diff_colored_lines.append(f"{GBG} {line} {N}")
elif line.startswith('-'):
diff_colored_lines.append(f"{RBG} {line} {N}")
else:
diff_colored_lines.append(line)
diff_colored = '\n'.join(diff_colored_lines) if diff_colored_lines else ' (无差异)'
display = f"""
{W}{sep}{N}
{B} 文件变更对比: {basename}{N}
{D} 路径: {filepath}{N}
{W}{sep}{N}
统计: {before_count} 行 → {after_count} 行 | {G}+{added} 新增{N} {R}-{removed} 删除{N}
{W}{thin_sep}{N}
修改前 ({before_count} 行):
{W}{thin_sep}{N}
{before_numbered}
{W}{thin_sep}{N}
修改后 ({after_count} 行):
{W}{thin_sep}{N}
{after_numbered}
{W}{thin_sep}{N}
差异 (Diff):
{W}{thin_sep}{N}
{diff_colored}
{W}{sep}{N}
按任意键关闭此窗口...
{W}{sep}{N}
"""
return display
def _launch_popup_window(self, display_text: str, title: str):
"""根据平台弹出新终端窗口"""
# 写入临时显示脚本
try:
script_fd, script_path = tempfile.mkstemp(suffix='.py', prefix='xiaoli_diff_')
with os.fdopen(script_fd, 'w', encoding='utf-8') as f:
# 写入显示内容到脚本
escaped = display_text.replace('\\', '\\\\').replace("'", "\\'")
f.write(f"#!/usr/bin/env python3\n")
f.write(f"# -*- coding: utf-8 -*-\n")
f.write(f"import sys\n")
f.write(f"text = '''{escaped}'''\n")
f.write(f"try:\n")
f.write(f" sys.stdout.reconfigure(encoding='utf-8')\n")
f.write(f"except Exception:\n")
f.write(f" pass\n")
f.write(f"print(text)\n")
f.write(f"try:\n")
f.write(f" input()\n")
f.write(f"except (EOFError, KeyboardInterrupt):\n")
f.write(f" pass\n")
system = platform.system()
if system == 'Windows':
# Windows: 新开 cmd 窗口
subprocess.Popen(
['cmd', '/c', 'start', 'cmd', '/k', sys.executable, script_path],
creationflags=subprocess.CREATE_NEW_CONSOLE,
shell=False
)
elif system == 'Darwin':
# macOS: 用 osascript 打开 Terminal.app
apple_script = (
f'tell application "Terminal"\n'
f' activate\n'
f' do script "{sys.executable} {script_path}"\n'
f'end tell'
)
subprocess.Popen(['osascript', '-e', apple_script])
else:
# Linux: 尝试多个终端模拟器
terminals = [
['gnome-terminal', '--', sys.executable, script_path],
['xterm', '-e', sys.executable, script_path],
['konsole', '-e', sys.executable, script_path],
['xfce4-terminal', '-e', f'{sys.executable} {script_path}'],
['lxterminal', '-e', sys.executable, script_path],
['mate-terminal', '-e', sys.executable, script_path],
]
launched = False
for cmd in terminals:
try:
subprocess.Popen(cmd, start_new_session=True)
launched = True
break
except FileNotFoundError:
continue
if not launched:
# 兜底: 用 xdg-open 不太行,直接打印到当前终端
print(f"\n[Diff 弹窗] 无可用终端模拟器,直接输出:\n")
print(display_text)
try:
os.unlink(script_path)
except Exception:
pass
except Exception as e:
print(f"[Diff 弹窗] 创建失败: {e}")
def _show_inline_diff(self, filepath: str, before: str, after: str):
"""在主终端内显示 diff(SSH 兼容模式),带颜色"""
basename = os.path.basename(filepath)
is_tui = self.cli and getattr(self.cli, 'tui_output_callback', None)
# 生成 diff
diff_lines = list(difflib.unified_diff(
before.splitlines(keepends=True),
after.splitlines(keepends=True),
fromfile=f"修改前: {basename}",
tofile=f"修改后: {basename}",
lineterm=''
))
# 统计
added = sum(1 for l in diff_lines if l.startswith('+') and not l.startswith('+++'))
removed = sum(1 for l in diff_lines if l.startswith('-') and not l.startswith('---'))
before_count = len(before.splitlines())
after_count = len(after.splitlines())
sep = '═' * 60
thin = '─' * 60
if is_tui:
# TUI 模式:使用 Rich 标记
output = self.cli._output
output(f"[dim]{sep}[/]")
output(f"[bold cyan] 文件变更: {basename}[/] [dim]{filepath}[/]")
output(f"[dim]{sep}[/]")
output(f"[bold] 统计: {before_count} 行 → {after_count} 行 | "
f"[green]+{added} 新增[/] [red]-{removed} 删除[/]")
output(f"[dim]{thin}[/]")
for line in diff_lines:
if line.startswith('@@'):
output(f"[cyan]{line}[/]")
elif line.startswith('+++') or line.startswith('---'):
output(f"[bold]{line}[/]")
elif line.startswith('+'):
output(f"[white on green] {line} [/]")
elif line.startswith('-'):
output(f"[white on red] {line} [/]")
else:
output(line)
output(f"[dim]{sep}[/]")
else:
# CLI 模式:使用 ANSI 颜色
from colorama import Fore, Style
_out = print
_out(f"{Fore.WHITE}{sep}{Style.RESET_ALL}")
_out(f"{Fore.CYAN} 文件变更: {basename}{Style.RESET_ALL} {filepath}")
_out(f"{Fore.WHITE}{sep}{Style.RESET_ALL}")
_out(f" 统计: {before_count} 行 → {after_count} 行 | "
f"{Fore.GREEN}+{added} 新增{Style.RESET_ALL} "
f"{Fore.RED}-{removed} 删除{Style.RESET_ALL}")
_out(f"{Fore.WHITE}{thin}{Style.RESET_ALL}")
for line in diff_lines:
if line.startswith('@@'):
_out(f"{Fore.CYAN}{line}{Style.RESET_ALL}")
elif line.startswith('+++') or line.startswith('---'):
_out(f"{Style.BRIGHT}{line}{Style.RESET_ALL}")
elif line.startswith('+'):
_out(f"\033[42m\033[97m {line} {Style.RESET_ALL}")
elif line.startswith('-'):
_out(f"\033[41m\033[97m {line} {Style.RESET_ALL}")
else:
_out(line)
_out(f"{Fore.WHITE}{sep}{Style.RESET_ALL}")
def _op_diff_popup(self, path: str, rest: str) -> str:
"""切换 diff 弹窗开关 / 设置显示模式"""
arg = (path + ' ' + rest).strip().lower()
if arg in ('on', '开', '开启', 'enable', '1', 'true'):
self.diff_popup_enabled = True
return " Diff 弹窗已开启"
elif arg in ('off', '关', '关闭', 'disable', '0', 'false'):
self.diff_popup_enabled = False
return " Diff 弹窗已关闭"
elif arg in ('popup', '弹窗', '弹窗模式'):
self.diff_popup_enabled = True
self.diff_popup_mode = True
return " Diff 模式: 弹窗(新终端窗口)"
elif arg in ('inline', '内联', 'ssh', '终端', '主终端'):
self.diff_popup_enabled = True
self.diff_popup_mode = False
return " Diff 模式: 主终端内显示(SSH 兼容)"
else:
# 无参数:切换状态
self.diff_popup_enabled = not self.diff_popup_enabled
state = "开启" if self.diff_popup_enabled else "关闭"
return f" Diff 弹窗: {state}"
def _op_syntax_check(self, path: str, rest: str) -> str:
"""主动语法检查操作"""
if not path:
return "错误:请提供文件路径。用法: syntax_check <文件路径>"
fp = self._norm(path)
if not os.path.exists(fp):
return f"错误:文件不存在: {fp}"
lang = self._get_lang(fp)
if lang is None:
return f"⏭ 跳过: {fp} (不支持的文件类型,无需语法检查)"
result = self._check_syntax(fp)
if result is None:
return f"⏭ 跳过: {fp} (无法读取文件)"
if result == "":
return f" 语法检查通过 ✓ ({fp})\n 语言: {lang}"
else:
return result
# ══════════════════════════════════════
# 编辑操作
# ══════════════════════════════════════
def _op_edit(self, path: str, rest: str) -> str:
if not path:
return "错误:请提供文件路径"
fp = self._norm(path)
if not os.path.exists(fp):
return f"错误:文件不存在: {fp}"
sep = " <<<>>> "
if sep not in rest:
return "错误:格式: edit <文件> <old_text> <<<>>> <new_text>"
old_text, new_text = rest.split(sep, 1)
old_text = old_text.strip()
new_text = new_text.strip()
if not old_text:
return "错误:old_text 不能为空"
with open(fp, 'r', encoding='utf-8') as f:
content = f.read()
if old_text not in content:
# 模糊匹配提示
lines = content.split('\n')
old_lines = old_text.split('\n')
best_ratio, best_pos = 0, None
for i in range(len(lines) - len(old_lines) + 1):
candidate = lines[i:i+len(old_lines)]
ratio = difflib.SequenceMatcher(
None, ''.join(candidate), ''.join(old_lines)).ratio()
if ratio > best_ratio:
best_ratio = ratio
best_pos = i + 1
if best_ratio > 0.6:
return f"错误:未找到精确匹配。最接近位置在第 {best_pos} 行 (相似度 {best_ratio:.0%}),请检查缩进。"
return "错误:未找到要替换的内容,请确保文本完全匹配(包括缩进)。"
count = content.count(old_text)
if count > 1:
return f"警告:找到 {count} 处匹配,请提供更多上下文以确保唯一。"
before_content = content # 保存修改前内容
new_content = content.replace(old_text, new_text, 1)
with open(fp, 'w', encoding='utf-8') as f:
f.write(new_content)
self._spawn_diff_popup(fp, before_content, new_content)
result = f" 已编辑: {fp}\n\n{self._make_diff(old_text, new_text)}"
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
def _op_multi_edit(self, path: str, rest: str) -> str:
import json
if not path:
return "错误:请提供文件路径"
fp = self._norm(path)
if not os.path.exists(fp):
return f"错误:文件不存在: {fp}"
try:
ops = json.loads(rest)
except json.JSONDecodeError:
return "错误:JSON 格式: [{\"old\": \"...\", \"new\": \"...\"}]"
if not isinstance(ops, list):
return "错误:operations 必须是数组"
with open(fp, 'r', encoding='utf-8') as f:
content = f.read()
before_content = content # 保存修改前内容
results = []
for i, op in enumerate(ops):
old = op.get("old", "").strip()
new = op.get("new", "").strip()
if not old:
results.append(f" 操作 {i+1}: old 为空")
elif old not in content:
results.append(f" 操作 {i+1}: 未找到匹配")
else:
content = content.replace(old, new, 1)
results.append(f" 操作 {i+1}: 已替换")
with open(fp, 'w', encoding='utf-8') as f:
f.write(content)
self._spawn_diff_popup(fp, before_content, content)
result = f" 批量编辑: {fp}\n" + "\n".join(results)
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
def _op_insert(self, path: str, rest: str) -> str:
if not path:
return "错误:请提供文件路径"
fp = self._norm(path)
if not os.path.exists(fp):
return f"错误:文件不存在: {fp}"
parts = rest.split(maxsplit=1)
if len(parts) < 2:
return "错误:格式: insert <文件> <行号> <内容>"
try:
line_num = int(parts[0])
except ValueError:
return "错误:行号必须是整数"
with open(fp, 'r', encoding='utf-8') as f:
lines = f.readlines()
if line_num < 1 or line_num > len(lines) + 1:
return f"错误:行号超出范围 (1-{len(lines)+1})"
before_content = ''.join(lines) # 保存修改前内容
insert_lines = [l + '\n' for l in parts[1].strip().split('\n')]
lines[line_num-1:line_num-1] = insert_lines
with open(fp, 'w', encoding='utf-8') as f:
f.writelines(lines)
after_content = ''.join(lines)
self._spawn_diff_popup(fp, before_content, after_content)
result = f" 已在第 {line_num} 行插入 {len(insert_lines)} 行"
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
def _op_delete_lines(self, path: str, rest: str) -> str:
if not path:
return "错误:请提供文件路径"
fp = self._norm(path)
if not os.path.exists(fp):
return f"错误:文件不存在: {fp}"
parts = rest.split()
if len(parts) < 2:
return "错误:格式: delete_lines <文件> <起始行> <结束行>"
try:
start, end = int(parts[0]), int(parts[1])
except ValueError:
return "错误:行号必须是整数"
with open(fp, 'r', encoding='utf-8') as f:
lines = f.readlines()
if start < 1 or end > len(lines) or start > end:
return f"错误:行号范围无效 (1-{len(lines)})"
before_content = ''.join(lines) # 保存修改前内容
deleted = lines[start-1:end]
lines[start-1:end] = []
with open(fp, 'w', encoding='utf-8') as f:
f.writelines(lines)
after_content = ''.join(lines)
self._spawn_diff_popup(fp, before_content, after_content)
result = f" 已删除第 {start}-{end} 行 ({len(deleted)} 行)"
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
def _op_create(self, path: str, rest: str) -> str:
if not path:
return "错误:请提供文件路径"
fp = self._norm(path)
if os.path.exists(fp):
return f"错误:文件已存在: {fp}"
dir_path = os.path.dirname(fp)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
with open(fp, 'w', encoding='utf-8') as f:
f.write(rest or "")
self._spawn_diff_popup(fp, "", rest or "")
result = f" 已创建: {fp} ({len(rest or '')} 字符)"
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
def _op_write(self, path: str, rest: str) -> str:
if not path:
return "错误:请提供文件路径"
fp = self._norm(path)
dir_path = os.path.dirname(fp)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
before_content = self._read_file_safe(fp) # 保存修改前内容
with open(fp, 'w', encoding='utf-8') as f:
f.write(rest)
self._spawn_diff_popup(fp, before_content, rest)
result = f" 已写入: {fp} ({len(rest)} 字符)"
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
def _op_append(self, path: str, rest: str) -> str:
if not path:
return "错误:请提供文件路径"
fp = self._norm(path)
if not os.path.exists(fp):
return f"错误:文件不存在: {fp}"
before_content = self._read_file_safe(fp) # 保存修改前内容
with open(fp, 'a', encoding='utf-8') as f:
f.write(rest)
after_content = self._read_file_safe(fp)
self._spawn_diff_popup(fp, before_content, after_content)
result = f" 已追加: {fp} ({len(rest)} 字符)"
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
# ══════════════════════════════════════
# 查看操作
# ══════════════════════════════════════
def _op_read_range(self, path: str, rest: str) -> str:
if not path:
return "错误:请提供文件路径"
fp = self._norm(path)
if not os.path.exists(fp):
return f"错误:文件不存在: {fp}"
parts = rest.split()
start = int(parts[0]) if parts else 1
end = int(parts[1]) if len(parts) > 1 else None
with open(fp, 'r', encoding='utf-8') as f:
lines = f.readlines()
total = len(lines)
end = end or total
start = max(1, min(start, total))
end = max(start, min(end, total))
selected = lines[start-1:end]
numbered = [f"{start+i:4d} | {line.rstrip()}" for i, line in enumerate(selected)]
return f" {fp} (第 {start}-{end} 行,共 {total} 行)\n{'─'*60}\n" + '\n'.join(numbered)
def _op_diff(self, path: str, rest: str) -> str:
if not path:
return "错误:请提供第一个文件路径"
f1 = self._norm(path)
f2 = self._norm(rest.split()[0] if rest.strip() else "")
if not os.path.exists(f1):
return f"错误:文件不存在: {f1}"
if not os.path.exists(f2):
return f"错误:文件不存在: {f2}"
with open(f1, 'r', encoding='utf-8') as f:
l1 = f.readlines()
with open(f2, 'r', encoding='utf-8') as f:
l2 = f.readlines()
diff = '\n'.join(difflib.unified_diff(l1, l2, fromfile=f1, tofile=f2, lineterm=''))
return diff or "两个文件完全相同"
def _op_diff_text(self, old: str, new: str) -> str:
if not old and not new:
return "错误:格式: diff_text <old> <<<>>> <new>"
return self._make_diff(old.strip(), new.strip())
def _op_ast_info(self, path: str, rest: str) -> str:
if not path:
return "错误:请提供文件路径"
fp = self._norm(path)
if not os.path.exists(fp):
return f"错误:文件不存在: {fp}"
with open(fp, 'r', encoding='utf-8') as f:
source = f.read()
try:
tree = ast.parse(source)
except SyntaxError as e:
return f"错误:语法错误 - {e}"
info = [f" {fp} AST 摘要:"]
for node in ast.iter_child_nodes(tree):
if isinstance(node, ast.ClassDef):
methods = [n.name for n in ast.iter_child_nodes(node)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
info.append(f" class {node.name} (行 {node.lineno})")
for m in methods:
info.append(f" └─ {m}()")
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
info.append(f" def {node.name}() (行 {node.lineno})")
elif isinstance(node, ast.Import):
info.append(f" import {', '.join(a.name for a in node.names)}")
elif isinstance(node, ast.ImportFrom):
info.append(f" from {node.module} import {', '.join(a.name for a in node.names)}")
return '\n'.join(info) if len(info) > 1 else f" {fp}: 文件为空"
# ══════════════════════════════════════
# 搜索操作
# ══════════════════════════════════════
def _op_find(self, directory: str, rest: str) -> str:
if not directory:
return "错误:请提供搜索目录"
search_dir = self._norm(directory)
parts = rest.split(maxsplit=1)
keyword = parts[0] if parts else ""
file_pattern = parts[1] if len(parts) > 1 else "*.py"
if not keyword:
return "错误:请提供搜索关键词"
if not os.path.isdir(search_dir):
return f"错误:目录不存在: {search_dir}"
files = self._walk_files(search_dir, file_pattern)
results = []
for fp in files:
try:
with open(fp, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
if keyword.lower() in line.lower():
rel = os.path.relpath(fp, search_dir)
results.append(f" {rel}:{line_num}: {line.rstrip()}")
if len(results) >= 80:
break
except (UnicodeDecodeError, PermissionError):
continue
if len(results) >= 80:
break
if not results:
return f"未找到 '{keyword}' 的匹配"
return f" 搜索 '{keyword}' ({len(results)}{'+' if len(results) >= 80 else ''} 匹配):\n" + '\n'.join(results)
def _op_regex(self, directory: str, rest: str) -> str:
if not directory:
return "错误:请提供搜索目录"
search_dir = self._norm(directory)
parts = rest.split(maxsplit=1)
pattern = parts[0] if parts else ""
file_pattern = parts[1] if len(parts) > 1 else "*.py"
if not pattern:
return "错误:请提供正则表达式"
try:
regex = re.compile(pattern)
except re.error as e:
return f"错误:无效的正则: {e}"
files = self._walk_files(search_dir, file_pattern)
results = []
for fp in files:
try:
with open(fp, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
if regex.search(line):
rel = os.path.relpath(fp, search_dir)
results.append(f" {rel}:{line_num}: {line.rstrip()}")
if len(results) >= 50:
break
except (UnicodeDecodeError, PermissionError):
continue
if len(results) >= 50:
break
if not results:
return f"未找到匹配 '{pattern}'"
return f" 正则搜索 ({len(results)}{'+' if len(results) >= 50 else ''} 匹配):\n" + '\n'.join(results)
def _op_symbols(self, directory: str, rest: str) -> str:
if not directory:
return "错误:请提供目录路径"
search_dir = self._norm(directory)
file_pattern = rest.strip() or "*.py"
files = self._walk_files(search_dir, file_pattern)
all_symbols = []
for fp in files:
if not fp.endswith('.py'):
continue
try:
with open(fp, 'r', encoding='utf-8') as f:
tree = ast.parse(f.read())
rel = os.path.relpath(fp, search_dir)
symbols = []
for node in ast.iter_child_nodes(tree):
if isinstance(node, ast.ClassDef):
methods = [n.name for n in ast.iter_child_nodes(node)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
symbols.append(f" class {node.name} (行 {node.lineno})")
for m in methods[:10]:
symbols.append(f" └─ {m}()")
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
symbols.append(f" def {node.name}() (行 {node.lineno})")
if symbols:
all_symbols.append(f"\n {rel}:")
all_symbols.extend(symbols)
except (SyntaxError, UnicodeDecodeError):
continue
return " 代码符号:\n" + '\n'.join(all_symbols) if all_symbols else "未找到 Python 符号"
def _op_imports(self, file_path: str, rest: str) -> str:
if not file_path:
return "错误:请提供文件路径"
path = self._norm(file_path)
if not os.path.exists(path):
return f"错误:文件不存在: {path}"
with open(path, 'r', encoding='utf-8') as f:
tree = ast.parse(f.read())
imports = []
for node in ast.iter_child_nodes(tree):
if isinstance(node, ast.Import):
imports.append(f" import {', '.join(a.name for a in node.names)} (行 {node.lineno})")
elif isinstance(node, ast.ImportFrom):
names = ', '.join(a.name for a in node.names)
imports.append(f" from {node.module} import {names} (行 {node.lineno})")
return f" {file_path} 导入:\n" + '\n'.join(imports) if imports else f" {file_path}: 没有导入"
def _op_callers(self, directory: str, rest: str) -> str:
if not directory:
return "错误:请提供搜索目录"
search_dir = self._norm(directory)
parts = rest.split(maxsplit=1)
func_name = parts[0] if parts else ""
file_pattern = parts[1] if len(parts) > 1 else "*.py"
if not func_name:
return "错误:请提供函数名"
patterns = [
re.compile(rf'\b{re.escape(func_name)}\s*\('),
re.compile(rf'\.{re.escape(func_name)}\s*\('),
]
files = self._walk_files(search_dir, file_pattern)
results = []
for fp in files:
try:
with open(fp, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
if any(p.search(line) for p in patterns):
rel = os.path.relpath(fp, search_dir)
results.append(f" {rel}:{line_num}: {line.rstrip()}")
except (UnicodeDecodeError, PermissionError):
continue
return f" '{func_name}' 的调用 ({len(results)}):\n" + '\n'.join(results) if results else f"未找到调用 '{func_name}'"
def _op_todo(self, directory: str, rest: str) -> str:
if not directory:
return "错误:请提供目录路径"
search_dir = self._norm(directory)
file_pattern = rest.strip() or "*.py"
pattern = re.compile(r'#\s*(TODO|FIXME|HACK|XXX|BUG|NOTE)\b[:\s]*(.*)', re.IGNORECASE)
files = self._walk_files(search_dir, file_pattern)
results = []
for fp in files:
try:
with open(fp, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
m = pattern.search(line)
if m:
rel = os.path.relpath(fp, search_dir)
results.append(f" {rel}:{line_num} [{m.group(1).upper()}] {m.group(2).strip()}")
except (UnicodeDecodeError, PermissionError):
continue
return f" TODO/FIXME ({len(results)}):\n" + '\n'.join(results) if results else "没有 TODO/FIXME"
def _op_structure(self, directory: str, rest: str) -> str:
if not directory:
return "错误:请提供目录路径"
search_dir = self._norm(directory)
if not os.path.isdir(search_dir):
return f"错误:目录不存在: {search_dir}"
max_depth = int(rest) if rest.strip().isdigit() else 3
lines = [f" {os.path.basename(search_dir)}/"]
skip = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'env', '.tox'}
def _tree(path, prefix="", depth=0):
if depth >= max_depth:
return
try:
entries = sorted(os.listdir(path))
except PermissionError:
return
dirs = [e for e in entries if os.path.isdir(os.path.join(path, e))
and e not in skip and not e.startswith('.')]
files = [e for e in entries if os.path.isfile(os.path.join(path, e))]
if len(files) > 20:
files = files[:20] + [f"... ({len(files)-20} more)"]
for i, d in enumerate(dirs):
is_last = (i == len(dirs) - 1) and not files
lines.append(f"{prefix}{'└── ' if is_last else '├── '}{d}/")
_tree(os.path.join(path, d), prefix + (" " if is_last else "│ "), depth + 1)
for i, f in enumerate(files):
is_last = i == len(files) - 1
size = os.path.getsize(os.path.join(path, f))
size_str = f" ({size//1024}KB)" if size > 1024 else f" ({size}B)"
lines.append(f"{prefix}{'└── ' if is_last else '├── '}{f}{size_str}")
_tree(search_dir)
return '\n'.join(lines)
def _op_context(self, file_path: str, rest: str) -> str:
if not file_path:
return "错误:请提供文件路径"
path = self._norm(file_path)
if not os.path.exists(path):
return f"错误:文件不存在: {path}"
parts = rest.split()
line_num = int(parts[0]) if parts else 1
ctx = int(parts[1]) if len(parts) > 1 else 5
with open(path, 'r', encoding='utf-8') as f:
lines = f.readlines()
total = len(lines)
start = max(1, line_num - ctx)
end = min(total, line_num + ctx)
result = [f" {path} 第 {start}-{end} 行 (目标: 第 {line_num} 行):", "─" * 60]
for i in range(start - 1, end):
marker = ">>>" if i + 1 == line_num else " "
result.append(f"{marker} {i+1:4d} | {lines[i].rstrip()}")
return '\n'.join(result)
def _op_stats(self, directory: str, rest: str) -> str:
if not directory:
return "错误:请提供目录路径"
search_dir = self._norm(directory)
files = self._walk_files(search_dir, "*.py")
total = blank = comment = code = 0
for fp in files:
try:
with open(fp, 'r', encoding='utf-8') as f:
for line in f:
total += 1
s = line.strip()
if not s:
blank += 1
elif s.startswith('#'):
comment += 1
else:
code += 1
except (UnicodeDecodeError, PermissionError):
continue
return (f" 代码统计 ({search_dir}):\n"
f" 文件: {len(files)}\n"
f" 总行: {total}\n"
f" 代码: {code}\n"
f" 注释: {comment}\n"
f" 空行: {blank}\n"
f" 注释率: {comment/max(code,1)*100:.1f}%")