illusion-code 0.1.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.
Files changed (214) hide show
  1. illusion/__init__.py +24 -0
  2. illusion/__main__.py +15 -0
  3. illusion/_frontend/dist/index.mjs +39208 -0
  4. illusion/_frontend/package.json +27 -0
  5. illusion/_frontend/src/App.tsx +624 -0
  6. illusion/_frontend/src/components/CommandPicker.tsx +98 -0
  7. illusion/_frontend/src/components/Composer.tsx +55 -0
  8. illusion/_frontend/src/components/ComposerController.tsx +128 -0
  9. illusion/_frontend/src/components/ConversationView.tsx +750 -0
  10. illusion/_frontend/src/components/Footer.tsx +25 -0
  11. illusion/_frontend/src/components/MarkdownContent.tsx +537 -0
  12. illusion/_frontend/src/components/MarkdownTable.tsx +245 -0
  13. illusion/_frontend/src/components/ModalHost.tsx +425 -0
  14. illusion/_frontend/src/components/MultilineTextInput.tsx +250 -0
  15. illusion/_frontend/src/components/PromptInput.tsx +64 -0
  16. illusion/_frontend/src/components/SelectModal.tsx +78 -0
  17. illusion/_frontend/src/components/SidePanel.tsx +175 -0
  18. illusion/_frontend/src/components/Spinner.tsx +77 -0
  19. illusion/_frontend/src/components/StatusBar.tsx +142 -0
  20. illusion/_frontend/src/components/SwarmPanel.tsx +141 -0
  21. illusion/_frontend/src/components/TodoPanel.tsx +126 -0
  22. illusion/_frontend/src/components/ToolCallDisplay.tsx +202 -0
  23. illusion/_frontend/src/components/TranscriptPane.tsx +79 -0
  24. illusion/_frontend/src/components/WelcomeBanner.tsx +37 -0
  25. illusion/_frontend/src/hooks/useBackendSession.ts +468 -0
  26. illusion/_frontend/src/hooks/useTerminalSize.ts +9 -0
  27. illusion/_frontend/src/i18n.ts +78 -0
  28. illusion/_frontend/src/index.tsx +42 -0
  29. illusion/_frontend/src/theme/ThemeContext.tsx +19 -0
  30. illusion/_frontend/src/theme/builtinThemes.ts +89 -0
  31. illusion/_frontend/src/types.ts +110 -0
  32. illusion/_frontend/src/utils/markdown.ts +33 -0
  33. illusion/_frontend/src/utils/thinking.ts +191 -0
  34. illusion/_frontend/tsconfig.json +13 -0
  35. illusion/_web_dist/assets/index-BseIw-ik.css +10 -0
  36. illusion/_web_dist/assets/index-C_0ZWMuW.js +82 -0
  37. illusion/_web_dist/index.html +16 -0
  38. illusion/api/__init__.py +36 -0
  39. illusion/api/client.py +568 -0
  40. illusion/api/codex_client.py +563 -0
  41. illusion/api/compat.py +138 -0
  42. illusion/api/effort.py +128 -0
  43. illusion/api/errors.py +57 -0
  44. illusion/api/openai_client.py +819 -0
  45. illusion/api/provider.py +148 -0
  46. illusion/api/registry.py +479 -0
  47. illusion/api/usage.py +45 -0
  48. illusion/auth/__init__.py +50 -0
  49. illusion/auth/copilot.py +419 -0
  50. illusion/auth/external.py +612 -0
  51. illusion/auth/flows.py +58 -0
  52. illusion/auth/manager.py +214 -0
  53. illusion/auth/storage.py +372 -0
  54. illusion/bridge/__init__.py +38 -0
  55. illusion/bridge/manager.py +190 -0
  56. illusion/bridge/session_runner.py +84 -0
  57. illusion/bridge/types.py +113 -0
  58. illusion/bridge/work_secret.py +131 -0
  59. illusion/cli.py +1228 -0
  60. illusion/commands/__init__.py +32 -0
  61. illusion/commands/registry.py +1934 -0
  62. illusion/config/__init__.py +39 -0
  63. illusion/config/i18n.py +522 -0
  64. illusion/config/paths.py +259 -0
  65. illusion/config/settings.py +564 -0
  66. illusion/coordinator/__init__.py +41 -0
  67. illusion/coordinator/agent_definitions.py +1093 -0
  68. illusion/coordinator/coordinator_mode.py +127 -0
  69. illusion/engine/__init__.py +95 -0
  70. illusion/engine/cost_tracker.py +55 -0
  71. illusion/engine/messages.py +369 -0
  72. illusion/engine/query.py +632 -0
  73. illusion/engine/query_engine.py +343 -0
  74. illusion/engine/stream_events.py +169 -0
  75. illusion/hooks/__init__.py +67 -0
  76. illusion/hooks/events.py +43 -0
  77. illusion/hooks/executor.py +397 -0
  78. illusion/hooks/hot_reload.py +74 -0
  79. illusion/hooks/loader.py +133 -0
  80. illusion/hooks/schemas.py +121 -0
  81. illusion/hooks/types.py +86 -0
  82. illusion/mcp/__init__.py +104 -0
  83. illusion/mcp/client.py +377 -0
  84. illusion/mcp/config.py +140 -0
  85. illusion/mcp/types.py +175 -0
  86. illusion/memory/__init__.py +36 -0
  87. illusion/memory/manager.py +94 -0
  88. illusion/memory/memdir.py +58 -0
  89. illusion/memory/paths.py +57 -0
  90. illusion/memory/scan.py +120 -0
  91. illusion/memory/search.py +83 -0
  92. illusion/memory/types.py +43 -0
  93. illusion/output_styles/__init__.py +15 -0
  94. illusion/output_styles/loader.py +64 -0
  95. illusion/permissions/__init__.py +39 -0
  96. illusion/permissions/checker.py +174 -0
  97. illusion/permissions/modes.py +38 -0
  98. illusion/platforms.py +148 -0
  99. illusion/plugins/__init__.py +71 -0
  100. illusion/plugins/bundled/__init__.py +0 -0
  101. illusion/plugins/installer.py +59 -0
  102. illusion/plugins/loader.py +301 -0
  103. illusion/plugins/schemas.py +51 -0
  104. illusion/plugins/types.py +56 -0
  105. illusion/prompts/__init__.py +29 -0
  106. illusion/prompts/claudemd.py +74 -0
  107. illusion/prompts/context.py +187 -0
  108. illusion/prompts/environment.py +189 -0
  109. illusion/prompts/system_prompt.py +155 -0
  110. illusion/py.typed +0 -0
  111. illusion/sandbox/__init__.py +29 -0
  112. illusion/sandbox/adapter.py +174 -0
  113. illusion/services/__init__.py +59 -0
  114. illusion/services/compact/__init__.py +1015 -0
  115. illusion/services/cron.py +338 -0
  116. illusion/services/cron_scheduler.py +715 -0
  117. illusion/services/file_history.py +258 -0
  118. illusion/services/lsp/__init__.py +455 -0
  119. illusion/services/session_storage.py +237 -0
  120. illusion/services/token_estimation.py +72 -0
  121. illusion/skills/__init__.py +60 -0
  122. illusion/skills/bundled/__init__.py +110 -0
  123. illusion/skills/bundled/content/batch.md +86 -0
  124. illusion/skills/bundled/content/coding-guidelines.md +70 -0
  125. illusion/skills/bundled/content/debug.md +38 -0
  126. illusion/skills/bundled/content/loop.md +82 -0
  127. illusion/skills/bundled/content/remember.md +105 -0
  128. illusion/skills/bundled/content/simplify.md +53 -0
  129. illusion/skills/bundled/content/skillify.md +113 -0
  130. illusion/skills/bundled/content/stuck.md +54 -0
  131. illusion/skills/bundled/content/update-config.md +329 -0
  132. illusion/skills/bundled/content/verify.md +74 -0
  133. illusion/skills/loader.py +219 -0
  134. illusion/skills/registry.py +40 -0
  135. illusion/skills/types.py +24 -0
  136. illusion/state/__init__.py +18 -0
  137. illusion/state/app_state.py +67 -0
  138. illusion/state/store.py +93 -0
  139. illusion/swarm/__init__.py +71 -0
  140. illusion/swarm/agent_executor.py +857 -0
  141. illusion/swarm/in_process.py +259 -0
  142. illusion/swarm/subprocess_backend.py +136 -0
  143. illusion/swarm/team_helpers.py +123 -0
  144. illusion/swarm/types.py +159 -0
  145. illusion/swarm/worktree.py +347 -0
  146. illusion/tasks/__init__.py +33 -0
  147. illusion/tasks/local_agent_task.py +42 -0
  148. illusion/tasks/local_shell_task.py +27 -0
  149. illusion/tasks/manager.py +377 -0
  150. illusion/tasks/stop_task.py +21 -0
  151. illusion/tasks/types.py +88 -0
  152. illusion/tools/__init__.py +126 -0
  153. illusion/tools/agent_tool.py +388 -0
  154. illusion/tools/ask_user_question_tool.py +186 -0
  155. illusion/tools/base.py +149 -0
  156. illusion/tools/bash_tool.py +413 -0
  157. illusion/tools/config_tool.py +90 -0
  158. illusion/tools/cron_tool.py +473 -0
  159. illusion/tools/enter_plan_mode_tool.py +147 -0
  160. illusion/tools/enter_worktree_tool.py +188 -0
  161. illusion/tools/exit_plan_mode_tool.py +69 -0
  162. illusion/tools/exit_worktree_tool.py +225 -0
  163. illusion/tools/file_edit_tool.py +283 -0
  164. illusion/tools/file_read_tool.py +294 -0
  165. illusion/tools/file_write_tool.py +184 -0
  166. illusion/tools/glob_tool.py +165 -0
  167. illusion/tools/grep_tool.py +190 -0
  168. illusion/tools/list_mcp_resources_tool.py +80 -0
  169. illusion/tools/lsp_tool.py +333 -0
  170. illusion/tools/mcp_auth_tool.py +100 -0
  171. illusion/tools/mcp_tool.py +75 -0
  172. illusion/tools/notebook_edit_tool.py +242 -0
  173. illusion/tools/powershell_tool.py +334 -0
  174. illusion/tools/read_mcp_resource_tool.py +63 -0
  175. illusion/tools/repl_tool.py +100 -0
  176. illusion/tools/send_message_tool.py +112 -0
  177. illusion/tools/shell_common.py +187 -0
  178. illusion/tools/skill_tool.py +86 -0
  179. illusion/tools/sleep_tool.py +62 -0
  180. illusion/tools/structured_output_tool.py +58 -0
  181. illusion/tools/task_create_tool.py +98 -0
  182. illusion/tools/task_get_tool.py +94 -0
  183. illusion/tools/task_list_tool.py +94 -0
  184. illusion/tools/task_output_tool.py +55 -0
  185. illusion/tools/task_stop_tool.py +52 -0
  186. illusion/tools/task_update_tool.py +224 -0
  187. illusion/tools/team_create_tool.py +236 -0
  188. illusion/tools/team_delete_tool.py +104 -0
  189. illusion/tools/todo_write_tool.py +198 -0
  190. illusion/tools/tool_search_tool.py +156 -0
  191. illusion/tools/web_fetch_tool.py +264 -0
  192. illusion/tools/web_search_tool.py +186 -0
  193. illusion/ui/__init__.py +23 -0
  194. illusion/ui/app.py +258 -0
  195. illusion/ui/backend_host.py +1180 -0
  196. illusion/ui/input.py +86 -0
  197. illusion/ui/output.py +363 -0
  198. illusion/ui/permission_dialog.py +47 -0
  199. illusion/ui/permission_store.py +99 -0
  200. illusion/ui/protocol.py +384 -0
  201. illusion/ui/react_launcher.py +280 -0
  202. illusion/ui/runtime.py +787 -0
  203. illusion/ui/textual_app.py +603 -0
  204. illusion/ui/web/__init__.py +10 -0
  205. illusion/ui/web/server.py +87 -0
  206. illusion/ui/web/ws_host.py +1197 -0
  207. illusion/utils/__init__.py +0 -0
  208. illusion/utils/ripgrep.py +299 -0
  209. illusion/utils/shell.py +248 -0
  210. illusion_code-0.1.0.dist-info/METADATA +1159 -0
  211. illusion_code-0.1.0.dist-info/RECORD +214 -0
  212. illusion_code-0.1.0.dist-info/WHEEL +4 -0
  213. illusion_code-0.1.0.dist-info/entry_points.txt +2 -0
  214. illusion_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,258 @@
1
+ """文件历史快照模块
2
+ ================
3
+
4
+ 本模块提供基于文件复制的快照管理,用于支持 /rewind 指令的文件回退。
5
+
6
+ 参考 Claude Code 的 copy-on-write 方案:在工具修改文件前备份其内容,
7
+ rewind 时从备份恢复。不依赖 git,可跟踪任意路径的文件。
8
+
9
+ 存储位置:~/.illusion/data/file-history/{session_id}/{sha256(path)[:16]}@v{N}
10
+
11
+ 主要函数:
12
+ - track_edit: 在工具修改文件前备份(copy-on-write)
13
+ - make_snapshot: 创建快照边界(每条用户消息一次)
14
+ - rewind_to: 回退到指定快照,恢复文件
15
+
16
+ 使用示例:
17
+ >>> from illusion.services.file_history import FileHistoryState
18
+ >>> state = FileHistoryState(session_id="abc123", cwd="/project")
19
+ >>> track_edit(state, "/project/file.py")
20
+ >>> make_snapshot(state, "msg-uuid-1")
21
+ >>> rewind_to(state, "msg-uuid-1")
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import shutil
27
+ from dataclasses import dataclass, field
28
+ from hashlib import sha256
29
+ from pathlib import Path
30
+
31
+ from illusion.config.paths import get_config_dir
32
+
33
+
34
+ @dataclass
35
+ class FileBackup:
36
+ """单个文件的备份记录。"""
37
+ backup_name: str | None # 备份文件名,None 表示文件当时不存在
38
+ version: int
39
+
40
+
41
+ @dataclass
42
+ class FileSnapshot:
43
+ """一个快照:关联到一条用户消息,包含所有跟踪文件的备份映射。"""
44
+ message_id: str
45
+ turn_index: int = 0 # 轮次索引(0-based)
46
+ tracked_backups: dict[str, FileBackup] = field(default_factory=dict)
47
+
48
+
49
+ @dataclass
50
+ class FileHistoryState:
51
+ """文件历史状态。"""
52
+ session_id: str
53
+ cwd: str
54
+ snapshots: list[FileSnapshot] = field(default_factory=list)
55
+ tracked_files: set[str] = field(default_factory=set)
56
+ _turn_counter: int = 0 # 内部轮次计数器
57
+
58
+
59
+ def _backup_dir(session_id: str) -> Path:
60
+ """返回备份存储目录。"""
61
+ return get_config_dir() / "file-history" / session_id
62
+
63
+
64
+ def _backup_name(file_path: str, version: int = 1) -> str:
65
+ """生成备份文件名:sha256(路径@vN)[:16]。不同版本生成不同文件名。"""
66
+ return sha256(f"{file_path}@v{version}".encode("utf-8")).hexdigest()[:16]
67
+
68
+
69
+ def _backup_path(session_id: str, backup_name: str) -> Path:
70
+ """返回备份文件的完整路径。"""
71
+ return _backup_dir(session_id) / backup_name
72
+
73
+
74
+ def _resolve_path(file_path: str, cwd: str) -> str:
75
+ """将路径转为绝对路径。"""
76
+ p = Path(file_path)
77
+ if p.is_absolute():
78
+ return str(p)
79
+ return str(Path(cwd) / file_path)
80
+
81
+
82
+ def track_edit(state: FileHistoryState, file_path: str) -> None:
83
+ """在工具修改文件前备份(copy-on-write)。
84
+
85
+ 如果文件在当前快照中已被跟踪,跳过。否则:
86
+ - 文件存在:复制到备份目录
87
+ - 文件不存在:记录 None(标记为"文件当时不存在")
88
+
89
+ Args:
90
+ state: 文件历史状态
91
+ file_path: 即将被修改的文件路径
92
+ """
93
+ abs_path = _resolve_path(file_path, state.cwd)
94
+ tracking_key = abs_path # 使用绝对路径作为 key
95
+
96
+ # 检查是否已在当前快照中跟踪
97
+ if state.snapshots:
98
+ current = state.snapshots[-1]
99
+ if tracking_key in current.tracked_backups:
100
+ return # 已跟踪,跳过
101
+
102
+ # 确定版本号:查找最近快照中此文件的备份版本
103
+ version = 1
104
+ for snap in reversed(state.snapshots):
105
+ if tracking_key in snap.tracked_backups:
106
+ version = snap.tracked_backups[tracking_key].version + 1
107
+ break
108
+
109
+ # 创建备份
110
+ bname = _backup_name(abs_path, version)
111
+ bpath = _backup_path(state.session_id, bname)
112
+
113
+ if Path(abs_path).exists():
114
+ # 文件存在:复制备份
115
+ bpath.parent.mkdir(parents=True, exist_ok=True)
116
+ shutil.copy2(abs_path, bpath)
117
+ backup = FileBackup(backup_name=bname, version=version)
118
+ else:
119
+ # 文件不存在(新文件)
120
+ backup = FileBackup(backup_name=None, version=version)
121
+
122
+ # 记录到当前快照
123
+ if state.snapshots:
124
+ state.snapshots[-1].tracked_backups[tracking_key] = backup
125
+
126
+ state.tracked_files.add(tracking_key)
127
+
128
+
129
+ def make_snapshot(state: FileHistoryState, message_id: str) -> None:
130
+ """创建快照边界。
131
+
132
+ 在用户发送消息时调用,为后续的工具编辑创建新的跟踪空间。
133
+
134
+ Args:
135
+ state: 文件历史状态
136
+ message_id: 关联的消息 ID
137
+ """
138
+ snapshot = FileSnapshot(message_id=message_id, turn_index=state._turn_counter)
139
+ state._turn_counter += 1
140
+ state.snapshots.append(snapshot)
141
+ # 最多保留 50 个快照
142
+ if len(state.snapshots) > 50:
143
+ evicted = state.snapshots[:-50]
144
+ state.snapshots = state.snapshots[-50:]
145
+ # 清理被驱逐快照的备份文件
146
+ _cleanup_evicted(state, evicted)
147
+
148
+
149
+ def rewind_to(state: FileHistoryState, turn_index: int) -> list[str]:
150
+ """撤销指定轮次的所有文件修改,并移除该轮及之后的快照。
151
+
152
+ 快照的备份记录的是工具执行前的文件状态,所以用目标快照自身的备份
153
+ 来恢复文件,就能撤销该轮工具对文件的修改。
154
+
155
+ Args:
156
+ state: 文件历史状态
157
+ turn_index: 要撤销的轮次索引(0-based)
158
+
159
+ Returns:
160
+ list[str]: 被恢复的文件路径列表
161
+ """
162
+ # 找到目标快照(turn_index 匹配的最后一个)
163
+ target = None
164
+ target_idx = -1
165
+ for i, snap in enumerate(state.snapshots):
166
+ if snap.turn_index == turn_index:
167
+ target = snap
168
+ target_idx = i
169
+ if target is None:
170
+ return []
171
+
172
+ changed: list[str] = []
173
+ for tracking_key in state.tracked_files:
174
+ backup = target.tracked_backups.get(tracking_key)
175
+ if backup is None:
176
+ # 目标快照没有此文件的备份,找最早的备份
177
+ backup = _find_first_backup(state, tracking_key)
178
+ if backup is None:
179
+ continue
180
+
181
+ if backup.backup_name is None:
182
+ # 文件当时不存在:删除
183
+ p = Path(tracking_key)
184
+ if p.exists():
185
+ p.unlink()
186
+ changed.append(tracking_key)
187
+ else:
188
+ # 从备份恢复(备份内容 = 工具执行前的状态)
189
+ bpath = _backup_path(state.session_id, backup.backup_name)
190
+ if bpath.exists():
191
+ p = Path(tracking_key)
192
+ current_content = p.read_bytes() if p.exists() else None
193
+ backup_content = bpath.read_bytes()
194
+ if current_content != backup_content:
195
+ p.parent.mkdir(parents=True, exist_ok=True)
196
+ shutil.copy2(bpath, tracking_key)
197
+ changed.append(tracking_key)
198
+
199
+ # 移除目标快照及之后的所有快照
200
+ evicted = state.snapshots[target_idx:]
201
+ state.snapshots = state.snapshots[:target_idx]
202
+ _cleanup_evicted(state, evicted)
203
+
204
+ return changed
205
+
206
+
207
+ def _find_first_backup(state: FileHistoryState, tracking_key: str) -> FileBackup | None:
208
+ """找到文件的最早备份。"""
209
+ for snap in state.snapshots:
210
+ backup = snap.tracked_backups.get(tracking_key)
211
+ if backup is not None:
212
+ return backup
213
+ return None
214
+
215
+
216
+ def cleanup_file_history(session_id: str) -> None:
217
+ """删除指定会话的文件历史目录。"""
218
+ d = _backup_dir(session_id)
219
+ if d.exists():
220
+ shutil.rmtree(d, ignore_errors=True)
221
+
222
+
223
+ def cleanup_all_file_histories() -> int:
224
+ """删除所有文件历史目录,返回删除的目录数。"""
225
+ base = get_config_dir() / "file-history"
226
+ if not base.exists():
227
+ return 0
228
+ count = 0
229
+ for child in base.iterdir():
230
+ if child.is_dir():
231
+ shutil.rmtree(child, ignore_errors=True)
232
+ count += 1
233
+ # 如果 base 为空,删除它
234
+ try:
235
+ base.rmdir()
236
+ except OSError:
237
+ pass
238
+ return count
239
+
240
+
241
+ def _cleanup_evicted(state: FileHistoryState, evicted: list[FileSnapshot]) -> None:
242
+ """清理被驱逐快照的孤立备份文件。"""
243
+ # 收集仍被引用的备份名
244
+ still_referenced: set[str] = set()
245
+ for snap in state.snapshots:
246
+ for backup in snap.tracked_backups.values():
247
+ if backup.backup_name:
248
+ still_referenced.add(backup.backup_name)
249
+
250
+ # 删除不再被引用的备份
251
+ for snap in evicted:
252
+ for backup in snap.tracked_backups.values():
253
+ if backup.backup_name and backup.backup_name not in still_referenced:
254
+ bpath = _backup_path(state.session_id, backup.backup_name)
255
+ try:
256
+ bpath.unlink(missing_ok=True)
257
+ except OSError:
258
+ pass
@@ -0,0 +1,455 @@
1
+ """
2
+ 轻量级代码智能辅助模块 — 用于 LSP 工具
3
+ ====================================
4
+
5
+ 本模块实现轻量级代码智能功能,比完整的语言服务器集成更小。
6
+ 为 Python 源文件提供稳定的只读操作,使模型能够执行类似 Claude Code 工作流程中的定义、引用、悬停和符号查询。
7
+
8
+ 主要功能:
9
+ - 列出文档符号
10
+ - 工作区符号搜索
11
+ - 跳转到定义
12
+ - 查找引用
13
+ - 悬停信息
14
+
15
+ 类说明:
16
+ - SymbolLocation: 符号位置数据类
17
+ - list_document_symbols: 列出文档符号
18
+ - workspace_symbol_search: 工作区符号搜索
19
+ - go_to_definition: 跳转到定义
20
+ - find_references: 查找引用
21
+ - hover: 悬停信息
22
+
23
+ 使用示例:
24
+ >>> from illusion.services.lsp import list_document_symbols, go_to_definition
25
+ >>> # 列出文件中的符号
26
+ >>> symbols = list_document_symbols(Path("src/main.py"))
27
+ >>> # 跳转到定义
28
+ >>> defs = go_to_definition(root=Path("."), file_path=Path("src/main.py"), symbol="my_function")
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import ast
34
+ import re
35
+ from dataclasses import dataclass
36
+ from pathlib import Path
37
+
38
+
39
+ # Python 文件模式
40
+ _PYTHON_GLOB = "*.py"
41
+ # 跳过的目录
42
+ _SKIP_PARTS = {".git", ".hg", ".svn", ".venv", "venv", "__pycache__", "node_modules"}
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class SymbolLocation:
47
+ """工作区内的解析符号位置。"""
48
+
49
+ name: str
50
+ kind: str
51
+ path: Path
52
+ line: int
53
+ character: int
54
+ signature: str = ""
55
+ docstring: str = ""
56
+
57
+
58
+ def list_document_symbols(path: Path) -> list[SymbolLocation]:
59
+ """从 Python 源文件中返回顶层和嵌套符号。"""
60
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
61
+ symbols: list[SymbolLocation] = []
62
+ _collect_symbols(tree, path, symbols, parent=None)
63
+ return symbols
64
+
65
+
66
+ def workspace_symbol_search(root: Path, query: str) -> list[SymbolLocation]:
67
+ """返回名称包含 query 的符号。"""
68
+ needle = query.lower().strip()
69
+ if not needle:
70
+ return []
71
+ matches: list[SymbolLocation] = []
72
+ for file_path in iter_python_files(root):
73
+ for symbol in list_document_symbols(file_path):
74
+ if needle in symbol.name.lower():
75
+ matches.append(symbol)
76
+ return matches
77
+
78
+
79
+ def go_to_definition(
80
+ *,
81
+ root: Path,
82
+ file_path: Path,
83
+ symbol: str | None = None,
84
+ line: int | None = None,
85
+ character: int | None = None,
86
+ ) -> list[SymbolLocation]:
87
+ """解析符号的可能定义。"""
88
+ target = symbol or extract_symbol_at_position(file_path, line=line, character=character)
89
+ if not target:
90
+ return []
91
+ matches: list[SymbolLocation] = []
92
+ for candidate in iter_python_files(root):
93
+ for item in list_document_symbols(candidate):
94
+ if item.name == target:
95
+ matches.append(item)
96
+ return matches
97
+
98
+
99
+ def find_references(
100
+ *,
101
+ root: Path,
102
+ file_path: Path,
103
+ symbol: str | None = None,
104
+ line: int | None = None,
105
+ character: int | None = None,
106
+ ) -> list[tuple[Path, int, str]]:
107
+ """返回符号的行方向引用。"""
108
+ target = symbol or extract_symbol_at_position(file_path, line=line, character=character)
109
+ if not target:
110
+ return []
111
+ pattern = re.compile(rf"\b{re.escape(target)}\b")
112
+ matches: list[tuple[Path, int, str]] = []
113
+ for candidate in iter_python_files(root):
114
+ for lineno, raw_line in enumerate(candidate.read_text(encoding="utf-8").splitlines(), start=1):
115
+ if pattern.search(raw_line):
116
+ matches.append((candidate, lineno, raw_line.strip()))
117
+ return matches
118
+
119
+
120
+ def hover(
121
+ *,
122
+ root: Path,
123
+ file_path: Path,
124
+ symbol: str | None = None,
125
+ line: int | None = None,
126
+ character: int | None = None,
127
+ ) -> SymbolLocation | None:
128
+ """返回符号的最佳悬停目标。"""
129
+ matches = go_to_definition(
130
+ root=root,
131
+ file_path=file_path,
132
+ symbol=symbol,
133
+ line=line,
134
+ character=character,
135
+ )
136
+ return matches[0] if matches else None
137
+
138
+
139
+ def extract_symbol_at_position(
140
+ file_path: Path,
141
+ *,
142
+ line: int | None,
143
+ character: int | None,
144
+ ) -> str | None:
145
+ """从 1 基数的行/字符位置提取可能的标识符。"""
146
+ if line is None:
147
+ return None
148
+ lines = file_path.read_text(encoding="utf-8").splitlines()
149
+ if line < 1 or line > len(lines):
150
+ return None
151
+ text = lines[line - 1]
152
+ if not text:
153
+ return None
154
+ index = max(0, min((character or 1) - 1, len(text) - 1))
155
+ for match in re.finditer(r"[A-Za-z_][A-Za-z0-9_]*", text):
156
+ if match.start() <= index < match.end():
157
+ return match.group(0)
158
+ for match in re.finditer(r"[A-Za-z_][A-Za-z0-9_]*", text):
159
+ return match.group(0)
160
+ return None
161
+
162
+
163
+ def go_to_implementation(
164
+ *,
165
+ root: Path,
166
+ file_path: Path,
167
+ symbol: str | None = None,
168
+ line: int | None = None,
169
+ character: int | None = None,
170
+ ) -> list[SymbolLocation]:
171
+ """查找类或方法的子类实现。
172
+
173
+ 对于类:查找继承该类的子类。
174
+ 对于方法:查找子类中重写该方法的位置。
175
+ """
176
+ target = symbol or extract_symbol_at_position(file_path, line=line, character=character)
177
+ if not target:
178
+ return []
179
+
180
+ is_method = "." in target
181
+ base_class = target.split(".")[0] if is_method else target
182
+ method_name = target.split(".")[-1] if is_method else None
183
+
184
+ matches: list[SymbolLocation] = []
185
+ for candidate in iter_python_files(root):
186
+ tree = ast.parse(candidate.read_text(encoding="utf-8"), filename=str(candidate))
187
+ for node in ast.walk(tree):
188
+ if isinstance(node, ast.ClassDef):
189
+ for base in node.bases:
190
+ base_name = _get_ast_name(base)
191
+ if base_name and (base_name == base_class or base_name.endswith(f".{base_class}")):
192
+ if method_name:
193
+ for child in ast.iter_child_nodes(node):
194
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name == method_name:
195
+ matches.append(SymbolLocation(
196
+ name=f"{node.name}.{child.name}",
197
+ kind="method",
198
+ path=candidate,
199
+ line=child.lineno,
200
+ character=child.col_offset + 1,
201
+ signature=f"def {child.name}(...)",
202
+ docstring=ast.get_docstring(child) or "",
203
+ ))
204
+ else:
205
+ matches.append(SymbolLocation(
206
+ name=node.name,
207
+ kind="class",
208
+ path=candidate,
209
+ line=node.lineno,
210
+ character=node.col_offset + 1,
211
+ signature=f"class {node.name}",
212
+ docstring=ast.get_docstring(node) or "",
213
+ ))
214
+ return matches
215
+
216
+
217
+ def prepare_call_hierarchy(
218
+ *,
219
+ root: Path,
220
+ file_path: Path,
221
+ symbol: str | None = None,
222
+ line: int | None = None,
223
+ character: int | None = None,
224
+ ) -> SymbolLocation | None:
225
+ """获取指定位置的调用层次节点。"""
226
+ target = symbol or extract_symbol_at_position(file_path, line=line, character=character)
227
+ if not target:
228
+ return None
229
+ matches = go_to_definition(root=root, file_path=file_path, symbol=target)
230
+ return matches[0] if matches else None
231
+
232
+
233
+ def incoming_calls(
234
+ *,
235
+ root: Path,
236
+ file_path: Path,
237
+ symbol: str | None = None,
238
+ line: int | None = None,
239
+ character: int | None = None,
240
+ ) -> list[tuple[Path, int, str, str]]:
241
+ """查找所有调用指定函数/方法的位置。
242
+
243
+ 返回 (调用文件, 行号, 调用者函数名, 行文本) 列表。
244
+ """
245
+ target = symbol or extract_symbol_at_position(file_path, line=line, character=character)
246
+ if not target:
247
+ return []
248
+
249
+ # 方法名称可能包含类前缀,调用时只用方法名
250
+ call_name = target.split(".")[-1]
251
+
252
+ results: list[tuple[Path, int, str, str]] = []
253
+ for candidate in iter_python_files(root):
254
+ try:
255
+ tree = ast.parse(candidate.read_text(encoding="utf-8"), filename=str(candidate))
256
+ except SyntaxError:
257
+ continue
258
+ for node in ast.walk(tree):
259
+ if isinstance(node, ast.Call):
260
+ callee = _get_ast_name(node.func)
261
+ if callee == target or callee == call_name:
262
+ caller = _find_enclosing_def(tree, node.lineno)
263
+ results.append((candidate, node.lineno, caller or "(module level)", _get_source_line(candidate, node.lineno)))
264
+ return results
265
+
266
+
267
+ def outgoing_calls(
268
+ *,
269
+ root: Path,
270
+ file_path: Path,
271
+ symbol: str | None = None,
272
+ line: int | None = None,
273
+ character: int | None = None,
274
+ ) -> list[tuple[str, Path, int]]:
275
+ """查找指定函数/方法内部调用的所有函数。
276
+
277
+ 返回 (被调用名, 定义文件, 行号) 列表。
278
+ """
279
+ target = symbol or extract_symbol_at_position(file_path, line=line, character=character)
280
+ if not target:
281
+ return []
282
+
283
+ tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
284
+ func_node = _find_func_node(tree, target)
285
+ if func_node is None:
286
+ return []
287
+
288
+ seen: set[str] = set()
289
+ results: list[tuple[str, Path, int]] = []
290
+ for node in ast.walk(func_node):
291
+ if node is func_node:
292
+ continue
293
+ if isinstance(node, ast.Call):
294
+ callee = _get_ast_name(node.func)
295
+ if callee and callee not in seen:
296
+ seen.add(callee)
297
+ # 尝试找到被调用函数的定义位置
298
+ defs = go_to_definition(root=root, file_path=file_path, symbol=callee)
299
+ if defs:
300
+ results.append((callee, defs[0].path, defs[0].line))
301
+ else:
302
+ results.append((callee, Path(), 0))
303
+
304
+ return results
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # 内部辅助函数
309
+ # ---------------------------------------------------------------------------
310
+
311
+
312
+ def _get_ast_name(node: ast.AST) -> str | None:
313
+ """从 AST 节点提取名称字符串(支持 a.b.c 形式的属性访问)。"""
314
+ if isinstance(node, ast.Name):
315
+ return node.id
316
+ if isinstance(node, ast.Attribute):
317
+ inner = _get_ast_name(node.value)
318
+ if inner is not None:
319
+ return f"{inner}.{node.attr}"
320
+ return node.attr
321
+ return None
322
+
323
+
324
+ def _find_enclosing_def(tree: ast.AST, lineno: int) -> str | None:
325
+ """按行号查找所在的函数/类定义名。"""
326
+ best: str | None = None
327
+ best_end: int = -1
328
+ for node in ast.walk(tree):
329
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
330
+ end = node.end_lineno or node.lineno
331
+ if node.lineno <= lineno <= end and end > best_end:
332
+ best_end = end
333
+ best = node.name
334
+ return best
335
+
336
+
337
+ def _find_func_node(tree: ast.AST, target: str) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
338
+ """在 AST 树中按名称或 ClassName.method 形式查找函数节点。"""
339
+ parts = target.split(".")
340
+ func_name = parts[-1]
341
+ class_name = parts[0] if len(parts) >= 2 else None
342
+
343
+ for node in ast.walk(tree):
344
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
345
+ if node.name == func_name:
346
+ if class_name:
347
+ # 检查是否在正确的类内
348
+ for parent in ast.walk(tree):
349
+ if isinstance(parent, ast.ClassDef) and parent.name == class_name:
350
+ # 检查 node 是否是 parent 的子节点
351
+ for child in ast.walk(parent):
352
+ if child is node:
353
+ return node
354
+ else:
355
+ return node
356
+ return None
357
+
358
+
359
+ def _get_source_line(file_path: Path, lineno: int) -> str:
360
+ """读取文件指定行的文本。"""
361
+ try:
362
+ lines = file_path.read_text(encoding="utf-8").splitlines()
363
+ if 1 <= lineno <= len(lines):
364
+ return lines[lineno - 1].strip()
365
+ except OSError:
366
+ pass
367
+ return ""
368
+
369
+
370
+ def iter_python_files(root: Path) -> list[Path]:
371
+ """按稳定顺序返回 Python 源文件列表。"""
372
+ files: list[Path] = []
373
+ for path in root.rglob(_PYTHON_GLOB):
374
+ if any(part in _SKIP_PARTS for part in path.parts):
375
+ continue
376
+ if path.is_file():
377
+ files.append(path)
378
+ files.sort()
379
+ return files
380
+
381
+
382
+ def _collect_symbols(
383
+ node: ast.AST,
384
+ path: Path,
385
+ bucket: list[SymbolLocation],
386
+ *,
387
+ parent: str | None,
388
+ ) -> None:
389
+ """递归收集 AST 中的符号。"""
390
+ for child in ast.iter_child_nodes(node):
391
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
392
+ # 函数定义
393
+ name = f"{parent}.{child.name}" if parent else child.name
394
+ args = [arg.arg for arg in child.args.args]
395
+ signature = f"def {child.name}({', '.join(args)})"
396
+ bucket.append(
397
+ SymbolLocation(
398
+ name=name,
399
+ kind="function",
400
+ path=path,
401
+ line=child.lineno,
402
+ character=child.col_offset + 1,
403
+ signature=signature,
404
+ docstring=ast.get_docstring(child) or "",
405
+ )
406
+ )
407
+ _collect_symbols(child, path, bucket, parent=name)
408
+ elif isinstance(child, ast.ClassDef):
409
+ # 类定义
410
+ name = f"{parent}.{child.name}" if parent else child.name
411
+ bucket.append(
412
+ SymbolLocation(
413
+ name=name,
414
+ kind="class",
415
+ path=path,
416
+ line=child.lineno,
417
+ character=child.col_offset + 1,
418
+ signature=f"class {child.name}",
419
+ docstring=ast.get_docstring(child) or "",
420
+ )
421
+ )
422
+ _collect_symbols(child, path, bucket, parent=name)
423
+ elif isinstance(child, ast.Assign):
424
+ # 变量赋值
425
+ for target in child.targets:
426
+ if isinstance(target, ast.Name):
427
+ name = f"{parent}.{target.id}" if parent else target.id
428
+ bucket.append(
429
+ SymbolLocation(
430
+ name=name,
431
+ kind="variable",
432
+ path=path,
433
+ line=target.lineno,
434
+ character=target.col_offset + 1,
435
+ signature=f"{target.id} = ...",
436
+ )
437
+ )
438
+ else:
439
+ _collect_symbols(child, path, bucket, parent=parent)
440
+
441
+
442
+ __all__ = [
443
+ "SymbolLocation",
444
+ "extract_symbol_at_position",
445
+ "find_references",
446
+ "go_to_definition",
447
+ "go_to_implementation",
448
+ "hover",
449
+ "incoming_calls",
450
+ "iter_python_files",
451
+ "list_document_symbols",
452
+ "outgoing_calls",
453
+ "prepare_call_hierarchy",
454
+ "workspace_symbol_search",
455
+ ]