maker-lua-lsp 0.2.0__py3-none-win_amd64.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.
@@ -0,0 +1,2573 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Lua LSP 服务器
5
+ 监视脚本目录,使用 EmmyLua Language Server 进行语法检测
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import json
11
+ import time
12
+ import re
13
+ import copy
14
+ import itertools
15
+ import subprocess
16
+ import threading
17
+ import logging
18
+ import argparse
19
+ import atexit
20
+ import signal
21
+ import traceback
22
+ from pathlib import Path
23
+ from typing import Dict, List, Optional
24
+ from urllib.parse import unquote, urlparse
25
+
26
+ # watchdog 延迟加载:仅 watch 模式需要,首次使用时自动安装
27
+ WATCHDOG_AVAILABLE = False
28
+ Observer = None
29
+ class FileSystemEventHandler:
30
+ pass
31
+
32
+ def _ensure_watchdog():
33
+ global WATCHDOG_AVAILABLE, Observer, FileSystemEventHandler
34
+ if WATCHDOG_AVAILABLE:
35
+ return True
36
+ try:
37
+ from watchdog.observers import Observer as _Obs
38
+ from watchdog.events import FileSystemEventHandler as _Fseh
39
+ WATCHDOG_AVAILABLE = True
40
+ Observer = _Obs
41
+ FileSystemEventHandler = _Fseh
42
+ return True
43
+ except ImportError:
44
+ logger.info("watchdog 未安装,正在自动安装...")
45
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "watchdog>=3.0.0", "-q"])
46
+ from watchdog.observers import Observer as _Obs
47
+ from watchdog.events import FileSystemEventHandler as _Fseh
48
+ WATCHDOG_AVAILABLE = True
49
+ Observer = _Obs
50
+ FileSystemEventHandler = _Fseh
51
+ return True
52
+
53
+ from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
54
+
55
+ import math
56
+
57
+ # ========== EmmyLuaLS 默认配置 ==========
58
+
59
+ EMMYLUA_OVERRIDE_CONFIG = {
60
+ "runtime": {
61
+ "version": "Lua5.4",
62
+ "requireLikeFunction": ["tolua"],
63
+ "special": {
64
+ "tolua": "require",
65
+ },
66
+ },
67
+ "workspace": {},
68
+ "diagnostics": {
69
+ "enable": True,
70
+ "globals": ["tolua"],
71
+ "severity": {
72
+ # --- 跟默认一致,不 override ---
73
+ # "undefined-global": "error", # 默认 error
74
+ # "unused": "hint", # 默认 hint
75
+ # "unbalanced-assignments": "warning", # 默认 warning
76
+ # "cast-type-mismatch": "warning", # 默认 warning
77
+
78
+ # --- 提升到 error(默认 warning)---
79
+ "undefined-field": "error",
80
+ "missing-parameter": "error",
81
+ "param-type-mismatch": "error",
82
+ "assign-type-mismatch": "error",
83
+ "return-type-mismatch": "error",
84
+ "type-not-found": "error",
85
+ "undefined-doc-param": "error",
86
+ "circle-doc-class": "error",
87
+ "inject-field": "error",
88
+ "access-invisible": "error",
89
+ "enum-value-mismatch": "error",
90
+ "duplicate-index": "error",
91
+ "require-module-not-visible": "error",
92
+ "attribute-param-type-mismatch": "error",
93
+ "generic-constraint-mismatch": "error",
94
+
95
+ # --- 待提升 ---
96
+ # "unresolved-require": "error", # 默认 warning
97
+ # "redundant-parameter": "error", # 默认 warning
98
+ # "read-only": "error",
99
+
100
+
101
+ # --- 调整级别 ---
102
+ "duplicate-set-field": "hint", # 默认 warning → hint
103
+ "redefined-local": "warning", # 默认 hint → warning
104
+ "call-non-callable": "warning", # ScriptObject() 有 __call 但类型未声明,待强化声明后改 error
105
+ },
106
+ },
107
+ "completion": {
108
+ "autoRequire": False,
109
+ },
110
+ "hint": {
111
+ "enable": True,
112
+ },
113
+ }
114
+
115
+ # ========== 分页工具函数 ==========
116
+
117
+ def paginate_result(result: dict, page: int = 1, page_size: int = 20,
118
+ page_max_bytes: int = 16000) -> dict:
119
+ """对 LSP 结果进行分页处理
120
+
121
+ 同时使用数量限制和字节限制,取页数更大的(更安全,每页内容更少)
122
+
123
+ Args:
124
+ result: LSP 返回结果 {"ok": True, "result": ...}
125
+ page: 页码(从 1 开始)
126
+ page_size: 每页数量上限(1-100,默认 20)
127
+ page_max_bytes: 每页最大字节数(默认 16000,约 4000 tokens)
128
+
129
+ Returns:
130
+ 添加分页元数据的结果
131
+ """
132
+ if not result.get('ok'):
133
+ return result
134
+
135
+ lsp_result = result.get('result')
136
+ if lsp_result is None:
137
+ return result
138
+
139
+ # 处理不同的结果格式
140
+ items = None
141
+ items_key = None
142
+
143
+ # completion 结果格式: {"items": [...], "isIncomplete": bool}
144
+ if isinstance(lsp_result, dict) and 'items' in lsp_result:
145
+ items = lsp_result['items']
146
+ items_key = 'items'
147
+ # 其他结果格式: 直接是数组
148
+ elif isinstance(lsp_result, list):
149
+ items = lsp_result
150
+ items_key = None
151
+ else:
152
+ # 非数组结果(如 hover),不分页
153
+ return result
154
+
155
+ if not items:
156
+ return result
157
+
158
+ total = len(items)
159
+ page_size = max(1, min(page_size, 100))
160
+
161
+ # 计算两种分页方式的页数
162
+ total_pages_by_count = math.ceil(total / page_size)
163
+ total_pages_by_bytes = _calc_total_pages_by_bytes(items, page_max_bytes)
164
+
165
+ # 取更大的页数(更安全,每页内容更少)
166
+ if total_pages_by_bytes >= total_pages_by_count:
167
+ use_bytes_limit = True
168
+ total_pages = total_pages_by_bytes
169
+ else:
170
+ use_bytes_limit = False
171
+ total_pages = total_pages_by_count
172
+
173
+ page = max(1, min(page, max(1, total_pages)))
174
+
175
+ if use_bytes_limit:
176
+ paginated_items = _get_page_by_bytes(items, page, page_max_bytes)
177
+ else:
178
+ start_idx = (page - 1) * page_size
179
+ end_idx = start_idx + page_size
180
+ paginated_items = items[start_idx:end_idx]
181
+
182
+ displayed = len(paginated_items)
183
+ hidden = total - displayed
184
+
185
+ pagination_info = {
186
+ 'total': total,
187
+ 'displayed': displayed,
188
+ 'hidden': hidden,
189
+ 'page': page,
190
+ 'total_pages': total_pages
191
+ }
192
+
193
+ # 只有需要分页时才添加 pagination
194
+ if hidden > 0:
195
+ if page < total_pages:
196
+ pagination_info['hint'] = f'使用 --page {page + 1} 查看下一页(共 {total_pages} 页),或 --page-size 调整每页数量'
197
+ else:
198
+ pagination_info['hint'] = '这是最后一页,使用 --page 1 返回第一页,或 --page-size 调整每页数量'
199
+ else:
200
+ pagination_info = None
201
+
202
+ # pagination 放最前面
203
+ response = {'ok': True}
204
+ if pagination_info:
205
+ response['pagination'] = pagination_info
206
+
207
+ if items_key:
208
+ new_result = dict(lsp_result)
209
+ new_result[items_key] = paginated_items
210
+ response['result'] = new_result
211
+ else:
212
+ response['result'] = paginated_items
213
+
214
+ return response
215
+
216
+
217
+ def _get_page_by_bytes(items: list, page: int, max_bytes: int) -> list:
218
+ """按字节数分页,返回指定页的 items"""
219
+ if not items:
220
+ return []
221
+
222
+ pages = []
223
+ current_page_start = 0
224
+ current_page_bytes = 0
225
+
226
+ for i, item in enumerate(items):
227
+ size = len(json.dumps(item, ensure_ascii=False).encode('utf-8'))
228
+ if current_page_bytes + size > max_bytes and i > current_page_start:
229
+ pages.append((current_page_start, i))
230
+ current_page_start = i
231
+ current_page_bytes = size
232
+ else:
233
+ current_page_bytes += size
234
+
235
+ if current_page_start < len(items):
236
+ pages.append((current_page_start, len(items)))
237
+
238
+ page_idx = max(0, min(page - 1, len(pages) - 1))
239
+ if pages:
240
+ start_idx, end_idx = pages[page_idx]
241
+ return items[start_idx:end_idx]
242
+ return []
243
+
244
+
245
+ def _calc_total_pages_by_bytes(items: list, max_bytes: int) -> int:
246
+ """计算按字节数分页的总页数"""
247
+ if not items:
248
+ return 1
249
+
250
+ pages = 1
251
+ current_page_bytes = 0
252
+
253
+ for item in items:
254
+ size = len(json.dumps(item, ensure_ascii=False).encode('utf-8'))
255
+ if current_page_bytes + size > max_bytes and current_page_bytes > 0:
256
+ pages += 1
257
+ current_page_bytes = size
258
+ else:
259
+ current_page_bytes += size
260
+
261
+ return pages
262
+
263
+
264
+ # LSP Notification 方法列表(不需要响应)
265
+ LSP_NOTIFICATION_METHODS = {
266
+ 'initialized',
267
+ 'exit',
268
+ 'textDocument/didOpen',
269
+ 'textDocument/didClose',
270
+ 'textDocument/didChange',
271
+ 'textDocument/didSave',
272
+ 'textDocument/willSave',
273
+ 'workspace/didChangeConfiguration',
274
+ 'workspace/didChangeWatchedFiles',
275
+ 'workspace/didChangeWorkspaceFolders',
276
+ '$/cancelRequest',
277
+ '$/setTrace',
278
+ }
279
+
280
+ # 配置日志
281
+ logging.basicConfig(
282
+ level=logging.INFO,
283
+ format='%(asctime)s - %(levelname)s - %(message)s',
284
+ handlers=[
285
+ logging.StreamHandler(sys.stdout)
286
+ ]
287
+ )
288
+
289
+ logger = logging.getLogger(__name__)
290
+
291
+
292
+ class Color:
293
+ """终端颜色"""
294
+ RED = '\033[91m'
295
+ YELLOW = '\033[93m'
296
+ GREEN = '\033[92m'
297
+ BLUE = '\033[94m'
298
+ MAGENTA = '\033[95m'
299
+ CYAN = '\033[96m'
300
+ WHITE = '\033[97m'
301
+ RESET = '\033[0m'
302
+ BOLD = '\033[1m'
303
+
304
+
305
+ class DiagnosticFormatter:
306
+ """诊断信息格式化器"""
307
+
308
+ SEVERITY_NAMES = {
309
+ 1: "错误",
310
+ 2: "警告",
311
+ 3: "信息",
312
+ 4: "提示"
313
+ }
314
+
315
+ SEVERITY_COLORS = {
316
+ 1: Color.RED,
317
+ 2: Color.YELLOW,
318
+ 3: Color.BLUE,
319
+ 4: Color.CYAN
320
+ }
321
+
322
+ @staticmethod
323
+ def format_diagnostic(file_path: str, diagnostic: dict, use_color: bool = True) -> str:
324
+ """格式化单个诊断信息"""
325
+ severity = diagnostic.get('severity', 1)
326
+ message = diagnostic.get('message', '')
327
+ range_info = diagnostic.get('range', {})
328
+ start = range_info.get('start', {})
329
+ line = start.get('line', 0) + 1 # LSP 行号从 0 开始
330
+ character = start.get('character', 0) + 1
331
+
332
+ severity_name = DiagnosticFormatter.SEVERITY_NAMES.get(severity, "未知")
333
+
334
+ # 获取文件名(而不是完整路径)
335
+ file_name = os.path.basename(file_path)
336
+
337
+ if use_color:
338
+ color = DiagnosticFormatter.SEVERITY_COLORS.get(severity, Color.WHITE)
339
+ output = f"{Color.BOLD}{file_name}{Color.RESET}:{line}:{character}: "
340
+ output += f"{color}{severity_name}{Color.RESET}: {message}"
341
+ else:
342
+ output = f"{file_name}:{line}:{character}: {severity_name}: {message}"
343
+
344
+ return output
345
+
346
+ @staticmethod
347
+ def format_summary(diagnostics: Dict[str, List], use_color: bool = True) -> str:
348
+ """格式化摘要信息"""
349
+ total_files = len(diagnostics)
350
+ error_count = 0
351
+ warning_count = 0
352
+ info_count = 0
353
+ hint_count = 0
354
+
355
+ for diags in diagnostics.values():
356
+ for diag in diags:
357
+ severity = diag.get('severity', 1)
358
+ if severity == 1:
359
+ error_count += 1
360
+ elif severity == 2:
361
+ warning_count += 1
362
+ elif severity == 3:
363
+ info_count += 1
364
+ elif severity == 4:
365
+ hint_count += 1
366
+
367
+ if use_color:
368
+ summary = f"\n{Color.BOLD}{'='*60}{Color.RESET}\n"
369
+ summary += f"{Color.BOLD}诊断摘要{Color.RESET}\n"
370
+ summary += f"{'='*60}\n"
371
+ summary += f"检查文件数: {Color.CYAN}{total_files}{Color.RESET}\n"
372
+ summary += f"{Color.RED}错误: {error_count}{Color.RESET} | "
373
+ summary += f"{Color.YELLOW}警告: {warning_count}{Color.RESET} | "
374
+ summary += f"{Color.BLUE}信息: {info_count}{Color.RESET} | "
375
+ summary += f"{Color.CYAN}提示: {hint_count}{Color.RESET}\n"
376
+ summary += f"{'='*60}\n"
377
+ else:
378
+ summary = f"\n{'='*60}\n"
379
+ summary += f"诊断摘要\n"
380
+ summary += f"{'='*60}\n"
381
+ summary += f"检查文件数: {total_files}\n"
382
+ summary += f"错误: {error_count} | 警告: {warning_count} | "
383
+ summary += f"信息: {info_count} | 提示: {hint_count}\n"
384
+ summary += f"{'='*60}\n"
385
+
386
+ return summary
387
+
388
+
389
+ class LSPProtocol:
390
+ """LSP 协议处理"""
391
+
392
+ @staticmethod
393
+ def encode_message(content: dict) -> bytes:
394
+ """编码 LSP 消息"""
395
+ body = json.dumps(content, ensure_ascii=False)
396
+ content_bytes = body.encode('utf-8')
397
+ header = f"Content-Length: {len(content_bytes)}\r\n\r\n"
398
+ return header.encode('utf-8') + content_bytes
399
+
400
+ @staticmethod
401
+ def decode_message(data: bytes) -> Optional[dict]:
402
+ """解码 LSP 消息"""
403
+ try:
404
+ # 查找头部结束位置
405
+ header_end = data.find(b'\r\n\r\n')
406
+ if header_end == -1:
407
+ return None
408
+
409
+ # 解析 Content-Length
410
+ header = data[:header_end].decode('utf-8')
411
+ content_length = None
412
+ for line in header.split('\r\n'):
413
+ if line.startswith('Content-Length:'):
414
+ content_length = int(line.split(':')[1].strip())
415
+ break
416
+
417
+ if content_length is None:
418
+ return None
419
+
420
+ # 提取消息体
421
+ body_start = header_end + 4
422
+ body = data[body_start:body_start + content_length]
423
+
424
+ if len(body) < content_length:
425
+ return None
426
+
427
+ return json.loads(body.decode('utf-8'))
428
+ except Exception as e:
429
+ logger.error(f"解码消息失败: {e}")
430
+ return None
431
+
432
+
433
+ class LuaFileWatcher(FileSystemEventHandler):
434
+ """Lua 文件监视器"""
435
+
436
+ def __init__(self, server):
437
+ self.server = server
438
+ self.last_check = {}
439
+ self.debounce_time = 1.0 # 防抖时间(秒)
440
+ self.file_settle_time = 0.5 # 文件稳定等待时间(秒)
441
+
442
+ def _wait_for_file_ready(self, file_path: str, max_retries: int = 3) -> bool:
443
+ """等待文件写入完成并可读"""
444
+ for i in range(max_retries):
445
+ try:
446
+ # 尝试以独占模式打开文件,检查是否被占用
447
+ with open(file_path, 'r', encoding='utf-8') as f:
448
+ # 读取一个字节确认可读
449
+ f.read(1)
450
+ return True
451
+ except (PermissionError, IOError):
452
+ if i < max_retries - 1:
453
+ time.sleep(self.file_settle_time)
454
+ continue
455
+ return False
456
+ return False
457
+
458
+ def _cleanup_old_entries(self):
459
+ """清理旧的防抖记录,保留最近 100 个"""
460
+ if len(self.last_check) > 100:
461
+ # 按时间排序,保留最新的 100 个
462
+ sorted_items = sorted(self.last_check.items(), key=lambda x: x[1], reverse=True)
463
+ self.last_check = dict(sorted_items[:100])
464
+
465
+ def _matches_watch_pattern(self, path: str) -> bool:
466
+ """检查文件是否匹配 emmylua_ls 注册的监视模式"""
467
+ fname = os.path.basename(path)
468
+ for pattern in self.server._watch_patterns:
469
+ # "**/*.lua" → 匹配 .lua 后缀
470
+ if pattern.startswith('**/*.') and fname.endswith(pattern[4:]):
471
+ return True
472
+ # "**/.luarc.json" → 匹配文件名
473
+ if pattern.startswith('**/') and not pattern.startswith('**/*.') and fname == pattern[3:]:
474
+ return True
475
+ # registerCapability 还没收到时兜底匹配 .lua
476
+ if not self.server._watch_patterns:
477
+ return fname.endswith('.lua')
478
+ return False
479
+
480
+ def _notify_watched_file(self, file_path: str, change_type: int):
481
+ """通过 didChangeWatchedFiles 通知 emmylua_ls"""
482
+ uri = Path(file_path).resolve().as_uri()
483
+ self.server._send_message({
484
+ "jsonrpc": "2.0",
485
+ "method": "workspace/didChangeWatchedFiles",
486
+ "params": {
487
+ "changes": [{"uri": uri, "type": change_type}]
488
+ }
489
+ })
490
+
491
+ def on_modified(self, event):
492
+ if event.is_directory or not self._matches_watch_pattern(event.src_path):
493
+ return
494
+
495
+ current_time = time.time()
496
+ if event.src_path in self.last_check:
497
+ if current_time - self.last_check[event.src_path] < self.debounce_time:
498
+ return
499
+ self.last_check[event.src_path] = current_time
500
+ self._cleanup_old_entries()
501
+ logger.info(f"检测到文件变化: {event.src_path}")
502
+
503
+ if self._wait_for_file_ready(event.src_path):
504
+ if event.src_path.endswith('.lua'):
505
+ self.server.check_file(event.src_path)
506
+ self._notify_watched_file(event.src_path, 2) # Changed
507
+ else:
508
+ logger.warning(f"文件可能正在被占用,跳过检查: {event.src_path}")
509
+
510
+ def on_created(self, event):
511
+ if event.is_directory or not self._matches_watch_pattern(event.src_path):
512
+ return
513
+
514
+ logger.info(f"检测到新文件: {event.src_path}")
515
+ time.sleep(self.file_settle_time)
516
+
517
+ if self._wait_for_file_ready(event.src_path):
518
+ if event.src_path.endswith('.lua'):
519
+ self.server.check_file(event.src_path)
520
+ self._notify_watched_file(event.src_path, 1) # Created
521
+ self._cleanup_old_entries()
522
+ else:
523
+ logger.warning(f"文件可能正在被复制,跳过检查: {event.src_path}")
524
+
525
+ def on_deleted(self, event):
526
+ if event.is_directory or not self._matches_watch_pattern(event.src_path):
527
+ return
528
+
529
+ logger.info(f"检测到文件删除: {event.src_path}")
530
+ if event.src_path.endswith('.lua'):
531
+ self.server.close_file(event.src_path)
532
+ self._notify_watched_file(event.src_path, 3) # Deleted
533
+
534
+
535
+ class LuaLSPServer:
536
+ """Lua LSP 服务器"""
537
+
538
+ def __init__(self, lua_scripts_dir: str, output_file: str = "lua_diagnostics.log",
539
+ errors_only_file: str = "lua_errors.log", mode: str = "watch",
540
+ config_path: str = None, lsp_path: str = None):
541
+ self.lua_scripts_dir = Path(lua_scripts_dir).resolve()
542
+ self.config_path = Path(config_path).resolve() if config_path else None
543
+ self.output_file = output_file
544
+ self.errors_only_file = errors_only_file
545
+ self.mode = mode # 运行模式: "watch" 或 "check"
546
+ self._lsp_path = lsp_path
547
+ self.diagnostics: Dict[str, List] = {}
548
+ self.lsp_process: Optional[subprocess.Popen] = None
549
+ self._id_counter = itertools.count(1)
550
+ self.running = False
551
+ self.lock = threading.Lock()
552
+ self.opened_files: Dict[str, int] = {}
553
+ self.use_color = sys.stdout.isatty()
554
+ # emmylua_ls 通过 client/registerCapability 注册的文件监视模式
555
+ self._watch_patterns: List[str] = []
556
+ self._restart_count = 0
557
+ self._write_lock = threading.Lock()
558
+ self._observer = None
559
+
560
+ # HTTP RPC 代理相关
561
+ self._pending_requests: Dict[int, dict] = {}
562
+ self._pending_lock = threading.Lock()
563
+ self.http_port = 0
564
+
565
+ # 从 .luarc.json / .emmyrc.json 读取 .emmylua 路径
566
+ self.emmylua_dir = self._find_emmylua_dir()
567
+
568
+ # 查找可执行文件
569
+ self.lsp_executable = self._find_executable('emmylua_ls')
570
+ self.check_executable = self._find_executable('emmylua_check')
571
+
572
+ if self.mode == 'check' and not self.check_executable:
573
+ logger.error("未找到 emmylua_check,请确保已安装")
574
+ sys.exit(1)
575
+ if self.mode == 'watch' and not self.lsp_executable:
576
+ logger.error("未找到 emmylua_ls,请确保已安装")
577
+ sys.exit(1)
578
+
579
+ exe = self.check_executable if self.mode == 'check' else self.lsp_executable
580
+ logger.info(f"使用 LSP: {exe}")
581
+ logger.info(f"监视目录: {self.lua_scripts_dir}")
582
+ logger.info(f"EmmyLua 类型: {self.emmylua_dir}")
583
+
584
+ def _find_emmylua_dir(self) -> Path:
585
+ """从配置文件读取 .emmylua 路径"""
586
+ # 如果指定了配置文件路径,优先使用
587
+ if self.config_path and self.config_path.exists():
588
+ luarc_path = self.config_path
589
+ logger.info(f"使用指定的配置文件: {luarc_path}")
590
+ else:
591
+ luarc_path = self.lua_scripts_dir / ".luarc.json"
592
+ if not luarc_path.exists():
593
+ luarc_path = self.lua_scripts_dir / ".emmyrc.json"
594
+
595
+ if luarc_path.exists():
596
+ try:
597
+ # 使用 utf-8-sig 自动处理 BOM
598
+ with open(luarc_path, 'r', encoding='utf-8-sig') as f:
599
+ luarc = json.load(f)
600
+
601
+ # 优先读取顶层自定义字段 emmyluaDir
602
+ emmylua_dir = luarc.get('emmyluaDir')
603
+ if emmylua_dir:
604
+ if not os.path.isabs(emmylua_dir):
605
+ # 相对路径从配置文件所在目录计算
606
+ config_dir = luarc_path.parent
607
+ return (config_dir / emmylua_dir).resolve()
608
+ else:
609
+ return Path(emmylua_dir).resolve()
610
+
611
+ # 兜底:从 workspace.library 中查找
612
+ library = luarc.get('workspace', {}).get('library', [])
613
+ for lib_path in library:
614
+ if not os.path.isabs(lib_path):
615
+ # 相对路径从配置文件所在目录计算
616
+ config_dir = luarc_path.parent
617
+ abs_path = (config_dir / lib_path).resolve()
618
+ else:
619
+ abs_path = Path(lib_path).resolve()
620
+
621
+ if abs_path.name == '.emmylua' or '.emmylua' in str(abs_path):
622
+ return abs_path
623
+
624
+ logger.warning(f".luarc.json 中未找到 emmyluaDir 或 .emmylua 配置")
625
+ except Exception as e:
626
+ logger.error(f"读取 .luarc.json 失败: {e}")
627
+ else:
628
+ logger.warning(f".luarc.json 不存在: {luarc_path}")
629
+
630
+ # fallback:向上查找 .emmylua 目录
631
+ for candidate in [self.lua_scripts_dir / '.emmylua',
632
+ self.lua_scripts_dir.parent / '.emmylua',
633
+ self.lua_scripts_dir.parent.parent / '.emmylua']:
634
+ if candidate.exists():
635
+ return candidate.resolve()
636
+ return self.lua_scripts_dir / ".emmylua"
637
+
638
+ def _load_override_config(self) -> dict:
639
+ """加载 override 配置:优先读脚本同目录的 .emmyrc.override.json,否则用内联"""
640
+ override_file = Path(__file__).resolve().parent / '.emmyrc.override.json'
641
+ if override_file.exists():
642
+ try:
643
+ with open(override_file, 'r', encoding='utf-8-sig') as f:
644
+ config = json.load(f)
645
+ logger.info(f"使用外部 override 配置: {override_file}")
646
+ return config
647
+ except Exception as e:
648
+ logger.warning(f"读取 override 配置失败,回退到内联: {e}")
649
+ return copy.deepcopy(EMMYLUA_OVERRIDE_CONFIG)
650
+
651
+ def _normalize_project_severity(self, override: dict):
652
+ """读项目 .luarc.json/.emmyrc.json 的 severity,转小写合入 override"""
653
+ for name in ['.luarc.json', '.emmyrc.json']:
654
+ config_file = self.lua_scripts_dir / name
655
+ if config_file.exists():
656
+ try:
657
+ with open(config_file, 'r', encoding='utf-8-sig') as f:
658
+ project = json.load(f)
659
+ severity = project.get('diagnostics', {}).get('severity', {})
660
+ if severity:
661
+ override_sev = override.setdefault('diagnostics', {}).setdefault('severity', {})
662
+ for code, level in severity.items():
663
+ if code not in override_sev and isinstance(level, str):
664
+ override_sev[code] = level.lower()
665
+ except Exception:
666
+ pass
667
+ break
668
+
669
+ def _resolve_workspace_paths(self) -> dict:
670
+ """基于 lua_scripts_dir 解析 library/packages 路径(相对路径)"""
671
+ ws = self.lua_scripts_dir
672
+ result = {}
673
+
674
+ # library: .emmylua 声明目录(绝对路径,../相对路径在emmylua_check中不可靠)
675
+ library = []
676
+ for candidate in [ws / '.emmylua', ws.parent / '.emmylua', ws.parent.parent / '.emmylua']:
677
+ if candidate.exists():
678
+ library.append(str(candidate.resolve()))
679
+ break
680
+ if library:
681
+ result['library'] = library
682
+
683
+ # packages: urhox-libs 库目录(绝对路径,../相对路径在emmylua_check中有bug)
684
+ packages = []
685
+ for candidate in [ws / 'urhox-libs', ws.parent / 'urhox-libs', ws.parent.parent / 'urhox-libs']:
686
+ if candidate.exists():
687
+ packages.append(str(candidate.resolve()))
688
+ break
689
+ if packages:
690
+ result['packages'] = packages
691
+
692
+ return result
693
+
694
+ def _find_executable(self, name: str) -> Optional[str]:
695
+ """查找可执行文件 (emmylua_ls / emmylua_check)"""
696
+ exe_name = f'{name}.exe' if os.name == 'nt' else name
697
+
698
+ # 1. 用户指定路径(仅当文件名匹配时使用)
699
+ if self._lsp_path:
700
+ lsp_path = Path(self._lsp_path)
701
+ if lsp_path.exists() and lsp_path.stem == name:
702
+ return str(lsp_path.resolve())
703
+
704
+ # 2. 本地安装目录
705
+ script_dir = Path(__file__).resolve().parent
706
+ local_bin = script_dir / 'bin' / exe_name
707
+ if local_bin.exists():
708
+ return str(local_bin)
709
+
710
+ # 3. 从 PATH 中查找
711
+ try:
712
+ cmd = 'which' if os.name != 'nt' else 'where'
713
+ result = subprocess.run([cmd, exe_name],
714
+ capture_output=True, text=True, shell=False)
715
+ if result.returncode == 0:
716
+ return result.stdout.strip().split('\n')[0].strip()
717
+ except Exception:
718
+ pass
719
+
720
+ return None
721
+
722
+ def start(self):
723
+ """启动 LSP 服务器(根据模式选择)"""
724
+ if self.mode == "check":
725
+ self.start_check_mode()
726
+ elif self.mode == "bridge":
727
+ self.start_bridge_mode()
728
+ else:
729
+ self.start_watch_mode()
730
+
731
+ def start_check_mode(self):
732
+ """单次检查模式(使用 emmylua_check)"""
733
+ if self.use_color:
734
+ logger.info(f"\n{Color.CYAN}{'='*60}{Color.RESET}")
735
+ logger.info(f"{Color.BOLD}🔍 执行单次检查...{Color.RESET}")
736
+ logger.info(f"{Color.CYAN}{'='*60}{Color.RESET}")
737
+ else:
738
+ logger.info(f"\n{'='*60}")
739
+ logger.info(f"🔍 执行单次检查...")
740
+ logger.info(f"{'='*60}")
741
+
742
+ try:
743
+ if os.name == 'nt':
744
+ config_cache_dir = os.path.join(os.environ.get('TEMP', 'C:/Temp'), 'emmylua-check-cache')
745
+ else:
746
+ config_cache_dir = '/tmp/emmylua-check-cache'
747
+ os.makedirs(config_cache_dir, exist_ok=True)
748
+
749
+ ts = int(time.time() * 1000)
750
+ check_json_path = Path(config_cache_dir) / f'check-{ts}.json'
751
+
752
+ override = self._load_override_config()
753
+ override['workspace'] = self._resolve_workspace_paths()
754
+ self._normalize_project_severity(override)
755
+ override = {k: v for k, v in override.items() if not k.startswith('$')}
756
+
757
+ override_config_path = os.path.join(config_cache_dir, f'override-{ts}.json')
758
+ with open(override_config_path, 'w', encoding='utf-8') as f:
759
+ json.dump(override, f, indent=2)
760
+
761
+ # 构建 --config 参数:项目配置在前(base),override 在后(覆盖)
762
+ # emmylua_check 按顺序 deep merge 多个配置文件
763
+ config_files = []
764
+ if self.config_path and self.config_path.exists():
765
+ config_files.append(str(self.config_path))
766
+ else:
767
+ for name in ['.emmyrc.json', '.luarc.json']:
768
+ candidate = self.lua_scripts_dir / name
769
+ if candidate.exists():
770
+ config_files.append(str(candidate))
771
+ break
772
+ config_files.append(override_config_path)
773
+
774
+ # 构建 emmylua_check 命令
775
+ cmd = [
776
+ self.check_executable,
777
+ str(self.lua_scripts_dir),
778
+ '--output-format', 'json',
779
+ '--output', str(check_json_path),
780
+ '--config', ','.join(config_files),
781
+ ]
782
+
783
+ if logger.isEnabledFor(logging.DEBUG):
784
+ cmd.append('--verbose')
785
+
786
+ logger.info(f"执行命令: {' '.join(cmd)}")
787
+
788
+ result = subprocess.run(
789
+ cmd,
790
+ capture_output=True,
791
+ text=True,
792
+ encoding='utf-8',
793
+ timeout=300
794
+ )
795
+
796
+ logger.debug(f"命令退出码: {result.returncode}")
797
+
798
+ # 清理临时 override 配置
799
+ Path(override_config_path).unlink(missing_ok=True)
800
+
801
+ if check_json_path.exists():
802
+ with open(check_json_path, 'r', encoding='utf-8') as f:
803
+ json_content = f.read()
804
+ check_json_path.unlink(missing_ok=True)
805
+
806
+ logger.debug(f"JSON 内容长度: {len(json_content)}")
807
+ self._parse_json_output(json_content)
808
+ elif result.stdout or result.stderr:
809
+ logger.info("JSON 输出文件未生成,回退到文本格式解析")
810
+ self._parse_check_output(result.stdout, result.stderr)
811
+
812
+ # 保存诊断结果
813
+ with self.lock:
814
+ self._save_diagnostics()
815
+ self._save_errors_only()
816
+
817
+ # 输出摘要
818
+ with self.lock:
819
+ summary = DiagnosticFormatter.format_summary(self.diagnostics, use_color=self.use_color)
820
+ logger.info(summary)
821
+
822
+ if self.use_color:
823
+ logger.info(f"{Color.GREEN}✓ 检查完成{Color.RESET}")
824
+ else:
825
+ logger.info("✓ 检查完成")
826
+
827
+ except subprocess.TimeoutExpired:
828
+ logger.error("检查超时(超过5分钟)")
829
+ sys.exit(1)
830
+ except Exception as e:
831
+ logger.error(f"检查失败: {e}")
832
+
833
+ logger.error(traceback.format_exc())
834
+ sys.exit(1)
835
+
836
+ def _parse_check_output(self, stdout: str, stderr: str):
837
+ """解析 check 模式的输出并转换为 LSP 诊断格式"""
838
+ output = stdout + stderr
839
+
840
+ if self.use_color:
841
+ logger.info(f"\n{Color.CYAN}{'─'*60}{Color.RESET}")
842
+ logger.info(f"{Color.BOLD}正在解析诊断输出...{Color.RESET}")
843
+ logger.info(f"{Color.CYAN}{'─'*60}{Color.RESET}\n")
844
+
845
+ logger.debug(f"输出总长度: {len(output)} 字符")
846
+
847
+ # 检查是否为空输出
848
+ if not output.strip():
849
+ logger.warning("emmylua_check 返回空输出")
850
+ logger.info("提示:这可能意味着:")
851
+ logger.info(" 1. 没有找到任何 Lua 文件")
852
+ logger.info(" 2. 配置文件有误")
853
+ logger.info(" 3. emmylua_check 版本不兼容")
854
+ return
855
+
856
+ # 显示前几行原始输出用于调试(仅在 DEBUG 模式)
857
+ lines = output.split('\n')
858
+ logger.debug(f"输出行数: {len(lines)}")
859
+ if logger.isEnabledFor(logging.DEBUG):
860
+ logger.debug(f"前 30 行原始输出:")
861
+ for i, line in enumerate(lines[:30]):
862
+ logger.debug(f" [{i:2d}] {repr(line)}")
863
+
864
+ # 尝试 JSON 格式解析
865
+ if output.strip().startswith('{') or output.strip().startswith('['):
866
+ logger.info("检测到 JSON 格式输出,尝试解析...")
867
+ try:
868
+ json_data = json.loads(output)
869
+ self._parse_json_output(output)
870
+ return
871
+ except json.JSONDecodeError as e:
872
+ logger.warning(f"JSON 解析失败: {e}")
873
+
874
+ # 文本格式解析
875
+ parsed_count = 0
876
+ failed_lines = []
877
+
878
+ for i, line in enumerate(lines):
879
+ line = line.strip()
880
+ if not line:
881
+ continue
882
+
883
+ # 跳过进度/状态信息和空行
884
+ line_lower = line.lower()
885
+ if (not line or
886
+ line.startswith('>') or # 进度条
887
+ 'initializing' in line_lower or
888
+ 'diagnosis complete' in line_lower or
889
+ 'found' in line_lower and 'problems' in line_lower or
890
+ len(line) < 10 or # 太短的行
891
+ line.replace(' ', '').replace('^', '') == ''): # 只包含空格和 ^ 的行
892
+ logger.debug(f"跳过: {line[:80]}")
893
+ continue
894
+
895
+ # 解析诊断行(emmylua_check 文本输出格式)
896
+ diagnostic = self._parse_check_line(line)
897
+ if diagnostic:
898
+ logger.debug(f"成功解析 [{i}]: {diagnostic}")
899
+ file_path = diagnostic['file']
900
+
901
+ # 转换为与 LSP publishDiagnostics 兼容的格式
902
+ lsp_diagnostic = {
903
+ 'severity': diagnostic['severity'],
904
+ 'range': {
905
+ 'start': {
906
+ 'line': diagnostic['line'] - 1, # 转为 0-based
907
+ 'character': diagnostic['column'] - 1
908
+ }
909
+ },
910
+ 'message': diagnostic['message'],
911
+ 'code': diagnostic.get('code', '')
912
+ }
913
+
914
+ if file_path not in self.diagnostics:
915
+ self.diagnostics[file_path] = []
916
+ self.diagnostics[file_path].append(lsp_diagnostic)
917
+
918
+ # 实时输出(复用现有格式)
919
+ formatted = DiagnosticFormatter.format_diagnostic(
920
+ file_path, lsp_diagnostic, use_color=self.use_color)
921
+ logger.info(formatted)
922
+ parsed_count += 1
923
+ else:
924
+ # 记录无法解析的行
925
+ if len(line) > 10: # 只记录有意义的行
926
+ failed_lines.append((i, line))
927
+
928
+ if parsed_count > 0:
929
+ logger.info(f"成功解析了 {parsed_count} 条诊断信息")
930
+ else:
931
+ logger.warning(f"未能解析任何诊断信息")
932
+
933
+ if failed_lines and parsed_count == 0:
934
+ if logger.isEnabledFor(logging.DEBUG):
935
+ logger.debug(f"有 {len(failed_lines)} 行无法解析,显示前 10 行:")
936
+ for i, line in failed_lines[:10]:
937
+ logger.debug(f" 行 [{i}]: {line[:100]}")
938
+
939
+ def _parse_json_output(self, json_content: str):
940
+ """解析 JSON 格式的 check 输出
941
+
942
+ emmylua_check 格式: [ {"file": "path", "diagnostics": [{LSP Diagnostic}]} ]
943
+ """
944
+ try:
945
+ data = json.loads(json_content)
946
+
947
+ # emmylua_check: 数组格式 [{file, diagnostics}]
948
+ if isinstance(data, list):
949
+ for entry in data:
950
+ file_path_raw = entry.get('file', '')
951
+ diagnostics = entry.get('diagnostics', [])
952
+ if not diagnostics:
953
+ continue
954
+ # 标准化路径
955
+ try:
956
+ file_path = str(Path(file_path_raw).resolve())
957
+ except Exception:
958
+ file_path = file_path_raw
959
+ logger.info(f"文件: {os.path.basename(file_path)}, 诊断数: {len(diagnostics)}")
960
+ for diag in diagnostics:
961
+ self._process_json_diagnostic(file_path, diag)
962
+
963
+ else:
964
+ logger.warning(f"意外的 JSON 格式: {type(data)}")
965
+ return
966
+
967
+ total_issues = sum(len(diags) for diags in self.diagnostics.values())
968
+ logger.info(f"解析完成: {len(self.diagnostics)} 个文件, {total_issues} 个问题")
969
+
970
+ except json.JSONDecodeError as e:
971
+ logger.error(f"JSON 解析失败: {e}")
972
+ if logger.isEnabledFor(logging.DEBUG):
973
+ logger.debug(f"JSON 内容:\n{json_content[:500]}")
974
+ except Exception as e:
975
+ logger.error(f"处理 JSON 时出错: {e}")
976
+ if logger.isEnabledFor(logging.DEBUG):
977
+
978
+ logger.debug(traceback.format_exc())
979
+
980
+ def _uri_to_path(self, uri: str) -> str:
981
+ """转换 URI 到本地文件路径(标准化格式)"""
982
+ parsed = urlparse(uri)
983
+ path = unquote(parsed.path)
984
+
985
+ # Windows: /g:/path -> g:\path 或 /G:/path -> G:\path
986
+ if os.name == 'nt':
987
+ if path.startswith('/') and len(path) > 2 and path[2] == ':':
988
+ path = path[1:] # 去掉开头的 /
989
+ path = path.replace('/', '\\')
990
+
991
+ # 标准化路径(解决大小写不一致问题)
992
+ # Windows 上 Path.resolve() 会统一驱动器字母大小写
993
+ try:
994
+ path = str(Path(path).resolve())
995
+ except Exception:
996
+ pass # 保持原路径
997
+
998
+ return path
999
+
1000
+ def _add_diagnostic_to_store(self, file_path: str, diagnostic: dict, show_output: bool = True):
1001
+ """统一的诊断添加方法(供 check 和 watch 模式复用)
1002
+
1003
+ Args:
1004
+ file_path: 文件路径
1005
+ diagnostic: LSP 格式的诊断对象
1006
+ show_output: 是否显示实时输出
1007
+ """
1008
+ if file_path not in self.diagnostics:
1009
+ self.diagnostics[file_path] = []
1010
+ self.diagnostics[file_path].append(diagnostic)
1011
+
1012
+ if show_output:
1013
+ formatted = DiagnosticFormatter.format_diagnostic(
1014
+ file_path, diagnostic, use_color=self.use_color)
1015
+ logger.info(formatted)
1016
+
1017
+ def _process_json_diagnostic(self, file_path: str, diag: dict):
1018
+ """处理单个 JSON 诊断项(check 模式)"""
1019
+ try:
1020
+ # LSP 标准格式
1021
+ # 只需要确保字段完整性
1022
+ lsp_diagnostic = {
1023
+ 'severity': diag.get('severity', 1),
1024
+ 'range': diag.get('range', {'start': {'line': 0, 'character': 0}}),
1025
+ 'message': diag.get('message', '').strip(),
1026
+ 'code': diag.get('code', '')
1027
+ }
1028
+
1029
+ logger.debug(f"添加诊断: {os.path.basename(file_path)} -> {lsp_diagnostic['message'][:50]}")
1030
+
1031
+ # 使用统一的添加方法
1032
+ self._add_diagnostic_to_store(file_path, lsp_diagnostic, show_output=True)
1033
+
1034
+ except Exception as e:
1035
+ logger.error(f"处理诊断项失败: {e}")
1036
+ logger.debug(f"诊断数据: {diag}")
1037
+
1038
+ logger.debug(traceback.format_exc())
1039
+
1040
+ def _parse_check_line(self, line: str) -> dict:
1041
+ """解析 check 输出的单行诊断
1042
+
1043
+ 支持的格式:
1044
+ 1. 带 ANSI 颜色: \x1b[34mfile.lua:22:37\x1b[0m [\x1b[31mError\x1b[0m] message
1045
+ 2. 无颜色: file.lua:22:37 [Error] message
1046
+ 3. 传统格式: file.lua:22:37: error: message
1047
+ """
1048
+ try:
1049
+ # 步骤1: 去除 ANSI 颜色代码(如果有)
1050
+ # ANSI 格式: \x1b[数字;数字m 或 \x1b[数字m
1051
+ clean_line = re.sub(r'\x1b\[[0-9;]*m', '', line)
1052
+ logger.debug(f"原始: {line[:100]}")
1053
+ logger.debug(f"清理后: {clean_line[:150]}")
1054
+
1055
+ # 步骤2: 查找 .lua: 模式
1056
+ lua_pos = clean_line.find('.lua:')
1057
+ if lua_pos == -1:
1058
+ logger.debug(f"未找到 .lua: 模式")
1059
+ return None
1060
+
1061
+ # 步骤3: 提取文件路径
1062
+ file_path = clean_line[:lua_pos + 4].strip()
1063
+ rest_of_line = clean_line[lua_pos + 5:] # .lua: 之后的部分
1064
+
1065
+ logger.debug(f"文件路径: {file_path}")
1066
+ logger.debug(f"剩余: {rest_of_line[:120]}")
1067
+
1068
+ # 步骤4: 解析两种可能的格式
1069
+ # 格式A (新版): "22:37 [Error] Undefined global `X`. (code)"
1070
+ # 格式B (传统): "22:37: error: Undefined global `X`"
1071
+
1072
+ # 尝试格式A: 行:列 [级别] 消息
1073
+ match = re.match(r'(\d+):(\d+)\s*\[(\w+)\]\s*(.+)', rest_of_line)
1074
+ if match:
1075
+ line_num = int(match.group(1))
1076
+ col_num = int(match.group(2))
1077
+ level = match.group(3).strip()
1078
+ message = match.group(4).strip()
1079
+ logger.debug(f"[格式A] 行={line_num}, 列={col_num}, 级别={level}")
1080
+ else:
1081
+ # 尝试格式B: 行:列: 级别: 消息
1082
+ match = re.match(r'(\d+):(\d+):\s*(\w+):\s*(.+)', rest_of_line)
1083
+ if match:
1084
+ line_num = int(match.group(1))
1085
+ col_num = int(match.group(2))
1086
+ level = match.group(3).strip()
1087
+ message = match.group(4).strip()
1088
+ logger.debug(f"[格式B] 行={line_num}, 列={col_num}, 级别={level}")
1089
+ else:
1090
+ logger.debug(f"格式不匹配: {rest_of_line[:100]}")
1091
+ return None
1092
+
1093
+ # 步骤5: 映射严重级别
1094
+ level_lower = level.lower()
1095
+ if level_lower == 'error':
1096
+ severity = 1
1097
+ elif level_lower == 'warning':
1098
+ severity = 2
1099
+ elif level_lower == 'info' or level_lower == 'information':
1100
+ severity = 3
1101
+ elif level_lower == 'hint':
1102
+ severity = 4
1103
+ else:
1104
+ severity = 1 # 默认错误
1105
+
1106
+ # 步骤6: 提取错误码(圆括号或方括号中的内容)
1107
+ code = ''
1108
+ code_match = re.search(r'\(([^)]+)\)$', message)
1109
+ if not code_match:
1110
+ code_match = re.search(r'\[([^\]]+)\]$', message)
1111
+ if code_match:
1112
+ code = code_match.group(1)
1113
+ message = message[:code_match.start()].strip()
1114
+
1115
+ result = {
1116
+ 'file': file_path,
1117
+ 'line': line_num,
1118
+ 'column': col_num,
1119
+ 'severity': severity,
1120
+ 'message': message,
1121
+ 'code': code
1122
+ }
1123
+ logger.debug(f"✓ 成功解析: {result}")
1124
+ return result
1125
+
1126
+ except Exception as e:
1127
+ logger.debug(f"✗ 解析异常: {line[:100]} - {e}")
1128
+
1129
+ logger.debug(traceback.format_exc())
1130
+ return None
1131
+
1132
+ def start_bridge_mode(self):
1133
+ """桥接模式:stdio 透传,拦截配置注入"""
1134
+ logger.info("正在启动桥接模式...")
1135
+ self.running = True
1136
+
1137
+ try:
1138
+ self._start_lsp_process(start_readers=False)
1139
+ except Exception as e:
1140
+ logger.error(f"启动 emmylua_ls 失败: {e}")
1141
+ sys.exit(1)
1142
+
1143
+ # 下行:emmylua_ls stdout → sys.stdout(拦截 server→client 请求)
1144
+ def downstream():
1145
+ stdout_bin = sys.stdout.buffer
1146
+ buffer = b''
1147
+ while self.running and self.lsp_process:
1148
+ try:
1149
+ chunk = self.lsp_process.stdout.read(4096)
1150
+ if not chunk:
1151
+ break
1152
+ buffer += chunk
1153
+ while True:
1154
+ header_end = buffer.find(b'\r\n\r\n')
1155
+ if header_end == -1:
1156
+ break
1157
+ header = buffer[:header_end].decode('utf-8')
1158
+ content_length = None
1159
+ for line in header.split('\r\n'):
1160
+ if line.startswith('Content-Length:'):
1161
+ content_length = int(line.split(':')[1].strip())
1162
+ break
1163
+ if content_length is None:
1164
+ buffer = buffer[header_end + 4:]
1165
+ continue
1166
+ body_start = header_end + 4
1167
+ if len(buffer) < body_start + content_length:
1168
+ break
1169
+ body = buffer[body_start:body_start + content_length]
1170
+ buffer = buffer[body_start + content_length:]
1171
+ message = json.loads(body.decode('utf-8'))
1172
+
1173
+ method = message.get('method')
1174
+ msg_id = message.get('id')
1175
+
1176
+ # 拦截 workspace/configuration 请求
1177
+ if method == 'workspace/configuration' and msg_id is not None:
1178
+ items = message.get('params', {}).get('items', [])
1179
+ override = self._load_override_config()
1180
+ override['workspace'] = self._resolve_workspace_paths()
1181
+ self._normalize_project_severity(override)
1182
+ override = {k: v for k, v in override.items() if not k.startswith('$')}
1183
+ result = [override] * len(items) if items else [override]
1184
+ self._send_message({
1185
+ "jsonrpc": "2.0",
1186
+ "id": msg_id,
1187
+ "result": result
1188
+ })
1189
+ logger.info(f"bridge: workspace/configuration 已拦截响应 ({len(items)} items)")
1190
+ continue
1191
+
1192
+ # 拦截 client/registerCapability 请求
1193
+ if method == 'client/registerCapability' and msg_id is not None:
1194
+ self._send_message({
1195
+ "jsonrpc": "2.0",
1196
+ "id": msg_id,
1197
+ "result": None
1198
+ })
1199
+ logger.info("bridge: client/registerCapability 已拦截确认")
1200
+ continue
1201
+
1202
+ # 其他消息透传给上游客户端
1203
+ encoded = LSPProtocol.encode_message(message)
1204
+ stdout_bin.write(encoded)
1205
+ stdout_bin.flush()
1206
+ except Exception as e:
1207
+ logger.error(f"bridge 下行出错: {e}")
1208
+ break
1209
+
1210
+ # 上行:sys.stdin → emmylua_ls stdin(拦截 client→server 请求)
1211
+ def upstream():
1212
+ stdin_bin = sys.stdin.buffer
1213
+ buffer = b''
1214
+ while self.running and self.lsp_process:
1215
+ try:
1216
+ chunk = stdin_bin.read1(4096) if hasattr(stdin_bin, 'read1') else stdin_bin.read(4096)
1217
+ if not chunk:
1218
+ break
1219
+ buffer += chunk
1220
+ while True:
1221
+ header_end = buffer.find(b'\r\n\r\n')
1222
+ if header_end == -1:
1223
+ break
1224
+ header = buffer[:header_end].decode('utf-8')
1225
+ content_length = None
1226
+ for line in header.split('\r\n'):
1227
+ if line.startswith('Content-Length:'):
1228
+ content_length = int(line.split(':')[1].strip())
1229
+ break
1230
+ if content_length is None:
1231
+ buffer = buffer[header_end + 4:]
1232
+ continue
1233
+ body_start = header_end + 4
1234
+ if len(buffer) < body_start + content_length:
1235
+ break
1236
+ body = buffer[body_start:body_start + content_length]
1237
+ buffer = buffer[body_start + content_length:]
1238
+ message = json.loads(body.decode('utf-8'))
1239
+
1240
+ # 拦截 initialize 请求:注入 override capabilities 和动态字段
1241
+ if message.get('method') == 'initialize':
1242
+ params = message.get('params', {})
1243
+ override = self._load_override_config()
1244
+ override_params = override.get('$initParams')
1245
+ if override_params:
1246
+ params['capabilities'] = override_params.get('capabilities', params.get('capabilities', {}))
1247
+ params['rootUri'] = self.lua_scripts_dir.as_uri()
1248
+ params['workspaceFolders'] = [{
1249
+ "uri": self.lua_scripts_dir.as_uri(),
1250
+ "name": "LuaScripts"
1251
+ }]
1252
+ # 注入 configuration + dynamicRegistration
1253
+ caps = params.setdefault('capabilities', {})
1254
+ caps.setdefault('workspace', {})['configuration'] = True
1255
+ caps['workspace'].setdefault('didChangeWatchedFiles', {})['dynamicRegistration'] = True
1256
+ message['params'] = params
1257
+ logger.info("bridge: initialize 请求已注入 override")
1258
+
1259
+ # 转发给 emmylua_ls
1260
+ self._send_message(message)
1261
+ except Exception as e:
1262
+ logger.error(f"bridge 上行出错: {e}")
1263
+ break
1264
+
1265
+ # 启动线程
1266
+ threading.Thread(target=downstream, daemon=True, name="bridge-downstream").start()
1267
+ threading.Thread(target=self._read_lsp_stderr, daemon=True, name="bridge-stderr").start()
1268
+
1269
+ # 上行在主线程跑(stdin 阻塞)
1270
+ upstream()
1271
+
1272
+ # stdin 关闭 → 退出
1273
+ self.stop()
1274
+
1275
+ def start_watch_mode(self):
1276
+ """持续监视模式(原有实现)"""
1277
+ mcp_mode = not sys.stdin.isatty()
1278
+
1279
+ try:
1280
+ _ensure_watchdog()
1281
+ except Exception as e:
1282
+ logger.error(f"watchdog 安装失败: {e}")
1283
+ logger.error("请手动安装: pip install watchdog>=3.0.0")
1284
+ sys.exit(1)
1285
+
1286
+ self.running = True
1287
+ self._lsp_ready = threading.Event()
1288
+
1289
+ if mcp_mode:
1290
+ # MCP 模式:先在后台线程启动 MCP 前端(立即响应握手),
1291
+ # LSP 在主线程初始化,tools/call 会等 _lsp_ready
1292
+ threading.Thread(target=self.start_mcp_frontend, daemon=True, name="MCP-Frontend").start()
1293
+
1294
+ logger.info("正在启动 Lua LSP 服务器...")
1295
+
1296
+ # 启动 emmylua_ls 进程(首次启动失败则退出)
1297
+ try:
1298
+ self._start_lsp_process()
1299
+ except Exception as e:
1300
+ logger.error(f"首次启动 emmylua_ls 失败: {e}")
1301
+ sys.exit(1)
1302
+
1303
+ # 初始化 LSP 连接
1304
+ self._initialize_lsp()
1305
+
1306
+ # 启动 HTTP RPC 代理(如果配置了端口)
1307
+ if self.http_port > 0:
1308
+ self.start_http_server(self.http_port)
1309
+
1310
+ # 执行初始检查
1311
+ self._check_all_files()
1312
+
1313
+ # LSP 就绪,解除 MCP tools/call 的等待
1314
+ self._lsp_ready.set()
1315
+
1316
+ # 启动文件监视器(MCP 模式下不阻塞)
1317
+ self._start_file_watcher(block=not mcp_mode)
1318
+
1319
+ logger.info("Lua LSP 服务器已启动")
1320
+
1321
+ if mcp_mode:
1322
+ # 主线程等 MCP 前端退出(stdin 关闭)
1323
+ while self.running:
1324
+ time.sleep(1)
1325
+ self.stop()
1326
+
1327
+ def _kill_process_tree(self, pid: int):
1328
+ """杀死进程及其子进程"""
1329
+ try:
1330
+ if os.name == 'nt':
1331
+ # Windows: taskkill /T 杀死进程树
1332
+ subprocess.run(
1333
+ ['taskkill', '/F', '/T', '/PID', str(pid)],
1334
+ capture_output=True,
1335
+ timeout=5
1336
+ )
1337
+ else:
1338
+ # Linux/Mac: 使用进程组 ID 杀死整个进程树
1339
+ # 因为我们用 start_new_session=True 启动,pid 就是 pgid
1340
+ import signal as sig
1341
+ try:
1342
+ os.killpg(pid, sig.SIGTERM) # 先尝试优雅终止
1343
+ time.sleep(0.3)
1344
+ os.killpg(pid, sig.SIGKILL) # 强制杀死
1345
+ except ProcessLookupError:
1346
+ pass # 进程已退出
1347
+ except Exception as e:
1348
+ logger.debug(f"杀死进程 {pid} 时出错: {e}")
1349
+
1350
+ def _start_lsp_process(self, start_readers=True):
1351
+ """启动 emmylua_ls 进程"""
1352
+ if self.lsp_process and self.lsp_process.poll() is None:
1353
+ logger.info(f"清理旧的 LSP 进程 (PID: {self.lsp_process.pid})")
1354
+ self._kill_process_tree(self.lsp_process.pid)
1355
+ self.lsp_process = None
1356
+ time.sleep(0.3)
1357
+
1358
+ try:
1359
+ lsp_args = [
1360
+ self.lsp_executable,
1361
+ '--communication', 'stdio',
1362
+ '--log-level', 'warn',
1363
+ '--log-path', 'none',
1364
+ '--resources-path', 'none',
1365
+ ]
1366
+ logger.info(f"LSP 启动参数: {' '.join(lsp_args)}")
1367
+
1368
+ popen_kwargs = {
1369
+ 'stdin': subprocess.PIPE,
1370
+ 'stdout': subprocess.PIPE,
1371
+ 'stderr': subprocess.PIPE,
1372
+ 'bufsize': 0,
1373
+ }
1374
+ if os.name != 'nt':
1375
+ popen_kwargs['start_new_session'] = True
1376
+
1377
+ self.lsp_process = subprocess.Popen(lsp_args, **popen_kwargs)
1378
+
1379
+ if start_readers:
1380
+ threading.Thread(target=self._read_lsp_output, daemon=True).start()
1381
+ threading.Thread(target=self._read_lsp_stderr, daemon=True).start()
1382
+
1383
+ logger.info("emmylua_ls 进程已启动")
1384
+
1385
+ # 等待一小段时间,检查进程是否立即退出
1386
+ time.sleep(0.5)
1387
+ if self.lsp_process.poll() is not None:
1388
+ raise RuntimeError(f"emmylua_ls 进程立即退出,退出码: {self.lsp_process.returncode}")
1389
+ except Exception as e:
1390
+ logger.error(f"启动 emmylua_ls 失败: {e}")
1391
+ raise
1392
+
1393
+ def _read_lsp_stderr(self):
1394
+ """读取 LSP stderr 输出"""
1395
+ while self.running and self.lsp_process:
1396
+ try:
1397
+ line = self.lsp_process.stderr.readline()
1398
+ if not line:
1399
+ break
1400
+ stderr_text = line.decode('utf-8', errors='replace').strip()
1401
+ if stderr_text:
1402
+ logger.info(f"emmylua_ls: {stderr_text}")
1403
+ except Exception as e:
1404
+ logger.error(f"读取 LSP stderr 失败: {e}")
1405
+ break
1406
+
1407
+ def _initialize_lsp(self):
1408
+ """初始化 LSP 连接"""
1409
+ init_params = {
1410
+ "processId": os.getpid(),
1411
+ "rootUri": self.lua_scripts_dir.as_uri(),
1412
+ "capabilities": {
1413
+ "textDocument": {
1414
+ "publishDiagnostics": {}
1415
+ },
1416
+ "workspace": {
1417
+ "configuration": True,
1418
+ "didChangeWatchedFiles": {
1419
+ "dynamicRegistration": True
1420
+ }
1421
+ }
1422
+ },
1423
+ "workspaceFolders": [{
1424
+ "uri": self.lua_scripts_dir.as_uri(),
1425
+ "name": "LuaScripts"
1426
+ }]
1427
+ }
1428
+
1429
+ # 允许外部 override 的 $initParams 替换,动态字段强制回写
1430
+ override = self._load_override_config()
1431
+ if '$initParams' in override:
1432
+ init_params = override['$initParams']
1433
+ logger.info("init_params 已被 $initParams 替换")
1434
+
1435
+ # 动态字段始终用运行时值
1436
+ init_params['processId'] = os.getpid()
1437
+ init_params['rootUri'] = self.lua_scripts_dir.as_uri()
1438
+ init_params['workspaceFolders'] = [{
1439
+ "uri": self.lua_scripts_dir.as_uri(),
1440
+ "name": "LuaScripts"
1441
+ }]
1442
+
1443
+ self._send_message({
1444
+ "jsonrpc": "2.0",
1445
+ "id": self._next_id(),
1446
+ "method": "initialize",
1447
+ "params": init_params
1448
+ })
1449
+ time.sleep(2)
1450
+
1451
+ initialized_notification = {
1452
+ "jsonrpc": "2.0",
1453
+ "method": "initialized",
1454
+ "params": {}
1455
+ }
1456
+
1457
+ self._send_message(initialized_notification)
1458
+ logger.info("LSP 连接已初始化(配置通过 workspace/configuration 注入)")
1459
+
1460
+ def _read_lsp_output(self):
1461
+ """读取 LSP 输出"""
1462
+ buffer = b''
1463
+
1464
+ while self.running and self.lsp_process:
1465
+ try:
1466
+ chunk = self.lsp_process.stdout.read(4096)
1467
+ if not chunk:
1468
+ break
1469
+
1470
+ buffer += chunk
1471
+
1472
+ # 尝试解析消息
1473
+ while True:
1474
+ # 查找完整消息
1475
+ header_end = buffer.find(b'\r\n\r\n')
1476
+ if header_end == -1:
1477
+ break
1478
+
1479
+ # 解析 Content-Length
1480
+ header = buffer[:header_end].decode('utf-8')
1481
+ content_length = None
1482
+ for line in header.split('\r\n'):
1483
+ if line.startswith('Content-Length:'):
1484
+ content_length = int(line.split(':')[1].strip())
1485
+ break
1486
+
1487
+ if content_length is None:
1488
+ buffer = buffer[header_end + 4:]
1489
+ continue
1490
+
1491
+ # 检查是否有完整的消息体
1492
+ body_start = header_end + 4
1493
+ if len(buffer) < body_start + content_length:
1494
+ break
1495
+
1496
+ # 提取并处理消息
1497
+ body = buffer[body_start:body_start + content_length]
1498
+ message = json.loads(body.decode('utf-8'))
1499
+ self._handle_message(message)
1500
+
1501
+ # 移除已处理的消息
1502
+ buffer = buffer[body_start + content_length:]
1503
+
1504
+ except Exception as e:
1505
+ logger.error(f"读取 LSP 输出时出错: {e}")
1506
+ break
1507
+
1508
+ # emmylua_ls 意外退出时自动重启
1509
+ if self.running and self.lsp_process and self.lsp_process.poll() is not None:
1510
+ should_restart = False
1511
+ with self.lock:
1512
+ if not self.running or (self.lsp_process and self.lsp_process.poll() is None):
1513
+ return
1514
+ self._restart_count += 1
1515
+ if self._restart_count > 3:
1516
+ logger.error("emmylua_ls 连续崩溃超过 3 次,退出进程")
1517
+ os._exit(1)
1518
+ exit_code = self.lsp_process.returncode
1519
+ logger.warning(f"emmylua_ls 意外退出 (exit={exit_code}),3 秒后重启 ({self._restart_count}/3)...")
1520
+ should_restart = True
1521
+
1522
+ if should_restart:
1523
+ time.sleep(3)
1524
+ if not self.running:
1525
+ return
1526
+ try:
1527
+ self._start_lsp_process()
1528
+ self._initialize_lsp()
1529
+ self._check_all_files()
1530
+ self._restart_count = 0
1531
+ logger.info("emmylua_ls 已自动重启")
1532
+ except Exception as e:
1533
+ logger.error(f"自动重启失败: {e}")
1534
+
1535
+ def _handle_message(self, message: dict):
1536
+ """处理 LSP 消息"""
1537
+ msg_id = message.get('id')
1538
+
1539
+ # 检查是否是我们等待的响应(有 id 且有 result 或 error)
1540
+ if msg_id is not None and ('result' in message or 'error' in message):
1541
+ with self._pending_lock:
1542
+ if msg_id in self._pending_requests:
1543
+ self._pending_requests[msg_id]['result'] = message
1544
+ self._pending_requests[msg_id]['event'].set()
1545
+ logger.debug(f"收到响应: id={msg_id}")
1546
+ return
1547
+
1548
+ method = message.get('method')
1549
+
1550
+ # emmylua_ls 注册文件监视模式,解析 glob 并确认
1551
+ if method == 'client/registerCapability' and msg_id is not None:
1552
+ for reg in message.get('params', {}).get('registrations', []):
1553
+ if reg.get('method') == 'workspace/didChangeWatchedFiles':
1554
+ watchers = reg.get('registerOptions', {}).get('watchers', [])
1555
+ self._watch_patterns = [w.get('globPattern', '') for w in watchers
1556
+ if isinstance(w.get('globPattern'), str)]
1557
+ logger.info(f"client/registerCapability: {self._watch_patterns}")
1558
+ self._send_message({
1559
+ "jsonrpc": "2.0",
1560
+ "id": msg_id,
1561
+ "result": None
1562
+ })
1563
+ return
1564
+
1565
+ # emmylua_ls 请求 workspace/configuration 时,返回 override 配置
1566
+ if method == 'workspace/configuration' and msg_id is not None:
1567
+ items = message.get('params', {}).get('items', [])
1568
+ logger.info(f"workspace/configuration 请求: items={json.dumps(items, ensure_ascii=False)}")
1569
+ override = self._load_override_config()
1570
+ override['workspace'] = self._resolve_workspace_paths()
1571
+ # 读项目配置的 severity 值,全转小写合入 override(修复旧 LuaLS 大写问题)
1572
+ self._normalize_project_severity(override)
1573
+ # 剔除 $ 开头的扩展字段,不发给 emmylua_ls
1574
+ override = {k: v for k, v in override.items() if not k.startswith('$')}
1575
+ result = [override] * len(items) if items else [override]
1576
+ self._send_message({
1577
+ "jsonrpc": "2.0",
1578
+ "id": msg_id,
1579
+ "result": result
1580
+ })
1581
+ logger.info(f"workspace/configuration 已响应 override 配置 ({len(items)} items)")
1582
+ logger.info(f"workspace/configuration payload: {json.dumps(result[0], ensure_ascii=False)[:500]}")
1583
+ return
1584
+
1585
+ # 处理 LSP 主动推送的通知
1586
+ if method == 'textDocument/publishDiagnostics':
1587
+ self._handle_diagnostics(message['params'])
1588
+ elif 'error' in message:
1589
+ logger.error(f"LSP 错误: {message['error']}")
1590
+
1591
+ # ========== 共享查询接口(HTTP RPC / MCP 共用) ==========
1592
+
1593
+ def handle_diagnostic_request(self, params: dict, page: int = 1, page_size: int = 20, page_max_bytes: int = 16000) -> dict:
1594
+ """处理 textDocument/diagnostic 请求(从内存诊断缓存返回)
1595
+
1596
+ 标准 LSP 参数:
1597
+ - textDocument.uri: 单文件诊断
1598
+
1599
+ 扩展参数:
1600
+ - severity: 返回该级别及更严重的诊断 1=Error, 2=Warning, 3=Info, 4=Hint(默认 2)
1601
+ - summaryOnly: 只返回摘要
1602
+
1603
+ 无 textDocument.uri 时返回整个工作区的诊断(扩展行为)
1604
+ """
1605
+ severity_threshold = params.get('severity', 2)
1606
+ summary_only = params.get('summaryOnly', False)
1607
+
1608
+ text_document = params.get('textDocument', {})
1609
+ target_uri = text_document.get('uri') if isinstance(text_document, dict) else None
1610
+ target_path = None
1611
+ if target_uri:
1612
+ target_path = self._uri_to_path(target_uri)
1613
+
1614
+ with self.lock:
1615
+ diagnostics_snapshot = dict(self.diagnostics)
1616
+
1617
+ if target_path:
1618
+ file_diags = diagnostics_snapshot.get(target_path, [])
1619
+ filtered_diags = [d for d in file_diags if d.get('severity', 4) <= severity_threshold]
1620
+ return {
1621
+ 'ok': True,
1622
+ 'result': {'kind': 'full', 'items': filtered_diags}
1623
+ }
1624
+
1625
+ all_paths = list(diagnostics_snapshot.keys())
1626
+ common_prefix = ''
1627
+ if all_paths:
1628
+ try:
1629
+ common_prefix = os.path.commonpath(all_paths)
1630
+ if not os.path.isdir(common_prefix):
1631
+ common_prefix = os.path.dirname(common_prefix)
1632
+ except ValueError:
1633
+ common_prefix = ''
1634
+
1635
+ flat_diags = []
1636
+ severity_count = {1: 0, 2: 0, 3: 0, 4: 0}
1637
+ for file_path, diags in diagnostics_snapshot.items():
1638
+ rel_path = os.path.relpath(file_path, common_prefix) if common_prefix else file_path
1639
+ for d in diags:
1640
+ sev = d.get('severity', 4)
1641
+ severity_count[sev] = severity_count.get(sev, 0) + 1
1642
+ if sev <= severity_threshold:
1643
+ flat_item = {'file': rel_path}
1644
+ flat_item.update(d)
1645
+ flat_diags.append(flat_item)
1646
+
1647
+ summary = {
1648
+ 'files': len(diagnostics_snapshot),
1649
+ 'total': sum(len(d) for d in diagnostics_snapshot.values()),
1650
+ 'errors': severity_count.get(1, 0),
1651
+ 'warnings': severity_count.get(2, 0),
1652
+ 'info': severity_count.get(3, 0),
1653
+ 'hints': severity_count.get(4, 0)
1654
+ }
1655
+
1656
+ if summary_only:
1657
+ logger.info(f"诊断摘要: {summary['files']} 文件, {summary['total']} 问题")
1658
+ return {'ok': True, 'result': {'kind': 'full', 'summary': summary, 'items': []}}
1659
+
1660
+ result = paginate_result(
1661
+ {'ok': True, 'result': flat_diags},
1662
+ page, page_size, page_max_bytes
1663
+ )
1664
+
1665
+ response = {'ok': True}
1666
+ if 'pagination' in result:
1667
+ response['pagination'] = result['pagination']
1668
+ response['result'] = {
1669
+ 'kind': 'full',
1670
+ 'summary': summary,
1671
+ 'items': result.get('result', flat_diags)
1672
+ }
1673
+
1674
+ logger.info(f"诊断查询: {summary['files']} 文件, {len(response['result']['items'])} 条")
1675
+ return response
1676
+
1677
+ # ========== HTTP RPC 代理功能 ==========
1678
+
1679
+ def forward_request(self, method: str, params: dict, timeout: float = 30.0) -> dict:
1680
+ """转发 LSP 请求并等待响应
1681
+
1682
+ Args:
1683
+ method: LSP 方法名
1684
+ params: 请求参数
1685
+ timeout: 超时时间(秒)
1686
+
1687
+ Returns:
1688
+ {"ok": True, "result": ...} 或 {"ok": False, "error": ...}
1689
+ """
1690
+ logger.debug(f"forward_request: method={method}, is_notification={method in LSP_NOTIFICATION_METHODS}")
1691
+ try:
1692
+ if method in LSP_NOTIFICATION_METHODS:
1693
+ # Notification: 发送后直接返回
1694
+ logger.debug(f"forward_request: sending notification...")
1695
+ self._send_message({
1696
+ 'jsonrpc': '2.0',
1697
+ 'method': method,
1698
+ 'params': params
1699
+ })
1700
+ logger.debug(f"forward_request: notification sent")
1701
+ return {'ok': True}
1702
+ else:
1703
+ # Request: 需要等待响应
1704
+ internal_id = self._next_id()
1705
+ event = threading.Event()
1706
+
1707
+ with self._pending_lock:
1708
+ self._pending_requests[internal_id] = {
1709
+ 'event': event,
1710
+ 'result': None
1711
+ }
1712
+
1713
+ # 发送请求
1714
+ self._send_message({
1715
+ 'jsonrpc': '2.0',
1716
+ 'id': internal_id,
1717
+ 'method': method,
1718
+ 'params': params
1719
+ })
1720
+
1721
+ # 等待响应
1722
+ if event.wait(timeout):
1723
+ with self._pending_lock:
1724
+ response = self._pending_requests.pop(internal_id)['result']
1725
+
1726
+ if 'error' in response:
1727
+ return {'ok': False, 'error': response['error']}
1728
+ else:
1729
+ return {'ok': True, 'result': response.get('result')}
1730
+ else:
1731
+ # 超时,清理
1732
+ with self._pending_lock:
1733
+ self._pending_requests.pop(internal_id, None)
1734
+ return {'ok': False, 'error': f'Timeout after {timeout}s'}
1735
+
1736
+ except Exception as e:
1737
+ logger.error(f"forward_request 失败: {e}")
1738
+ return {'ok': False, 'error': str(e)}
1739
+
1740
+ def start_http_server(self, port: int) -> bool:
1741
+ """启动 HTTP RPC 代理服务器
1742
+
1743
+ Returns:
1744
+ True 如果启动成功,否则抛出异常
1745
+ """
1746
+ self.http_port = port
1747
+
1748
+ # 创建 Handler 类,绑定 lsp_server 实例
1749
+ lsp_server = self
1750
+
1751
+ class RPCProxyHandler(BaseHTTPRequestHandler):
1752
+ """HTTP RPC 代理处理器"""
1753
+
1754
+ def do_GET(self):
1755
+ """GET 端点"""
1756
+ if self.path == '/health':
1757
+ self.send_response(200)
1758
+ self.send_header('Content-Type', 'text/plain')
1759
+ self.end_headers()
1760
+ self.wfile.write(b'OK')
1761
+ else:
1762
+ self.send_error(404)
1763
+
1764
+ def do_POST(self):
1765
+ start_time = time.time()
1766
+ logger.info(f"[HTTP] do_POST 开始处理 path={self.path}")
1767
+
1768
+ if self.path != '/rpc':
1769
+ self.send_error(404, 'Not Found')
1770
+ return
1771
+
1772
+ try:
1773
+ # 读取请求体
1774
+ content_length = int(self.headers.get('Content-Length', 0))
1775
+ logger.debug(f"[HTTP] 读取 body, Content-Length={content_length}")
1776
+ body = self.rfile.read(content_length)
1777
+ logger.debug(f"[HTTP] body 读取完成, 耗时 {time.time() - start_time:.3f}s")
1778
+ request = json.loads(body.decode('utf-8'))
1779
+ logger.info(f"RPC 收到请求: method={request.get('method')}, size={len(body)} bytes")
1780
+
1781
+ # 提取 method 和 params
1782
+ method = request.get('method')
1783
+ params = request.get('params', {})
1784
+
1785
+ # 提取分页参数(不影响 LSP 请求)
1786
+ page = request.get('page', 1)
1787
+ page_size = request.get('page_size', 20)
1788
+ page_max_bytes = request.get('page_max_bytes', 16000)
1789
+
1790
+ if not method:
1791
+ self._send_json({'ok': False, 'error': 'Missing method'})
1792
+ return
1793
+
1794
+ # 模拟 textDocument/diagnostic(从内存诊断缓存返回)
1795
+ if method == 'textDocument/diagnostic':
1796
+ result = self._handle_diagnostic_request(params, page, page_size, page_max_bytes)
1797
+ self._send_json(result)
1798
+ return
1799
+
1800
+ # 转发到 LSP
1801
+ logger.info(f"RPC 转发: {method}")
1802
+ result = lsp_server.forward_request(method, params)
1803
+
1804
+ # 应用分页
1805
+ result = paginate_result(result, page, page_size, page_max_bytes)
1806
+
1807
+ result_str = json.dumps(result, ensure_ascii=False)
1808
+ logger.info(f"RPC 响应: ok={result.get('ok')}, size={len(result_str)} bytes")
1809
+ self._send_json(result)
1810
+
1811
+ except json.JSONDecodeError as e:
1812
+ self._send_json({'ok': False, 'error': f'Invalid JSON: {e}'})
1813
+ except Exception as e:
1814
+ logger.error(f"RPC 处理错误: {e}")
1815
+ self._send_json({'ok': False, 'error': str(e)})
1816
+
1817
+ def _handle_diagnostic_request(self, params: dict, page: int, page_size: int, page_max_bytes: int) -> dict:
1818
+ return lsp_server.handle_diagnostic_request(params, page, page_size, page_max_bytes)
1819
+
1820
+ def _send_json(self, data: dict):
1821
+ body = json.dumps(data, ensure_ascii=False).encode('utf-8')
1822
+ self.send_response(200)
1823
+ self.send_header('Content-Type', 'application/json; charset=utf-8')
1824
+ self.send_header('Content-Length', len(body))
1825
+ self.end_headers()
1826
+ self.wfile.write(body)
1827
+
1828
+ def log_message(self, format, *args):
1829
+ # 使用我们的 logger
1830
+ logger.debug(f"HTTP: {format % args}")
1831
+
1832
+ # 启动 HTTP 服务器(使用 ThreadingHTTPServer 支持并发)
1833
+ try:
1834
+ server = ThreadingHTTPServer(('127.0.0.1', port), RPCProxyHandler)
1835
+ server.daemon_threads = True # 请求处理线程设为 daemon
1836
+ logger.info(f"HTTP RPC 代理已启动: http://127.0.0.1:{port}/rpc")
1837
+
1838
+ def serve():
1839
+ logger.debug("HTTP 服务线程已启动")
1840
+ server.serve_forever()
1841
+
1842
+ http_thread = threading.Thread(target=serve, daemon=True, name="HTTP-RPC-Server")
1843
+ http_thread.start()
1844
+ logger.debug(f"HTTP 线程状态: alive={http_thread.is_alive()}")
1845
+ return True
1846
+ except OSError as e:
1847
+ # 端口被占用等系统错误
1848
+ logger.error(f"启动 HTTP 服务器失败 (端口 {port}): {e}")
1849
+ raise RuntimeError(f"HTTP 服务器启动失败: {e}") from e
1850
+ except Exception as e:
1851
+ logger.error(f"启动 HTTP 服务器失败: {e}")
1852
+ raise RuntimeError(f"HTTP 服务器启动失败: {e}") from e
1853
+
1854
+ # ========== MCP stdio 前端 ==========
1855
+
1856
+ MCP_TOOL = {
1857
+ "name": "lua_lsp",
1858
+ "description": (
1859
+ "Send raw JSON-RPC requests to Lua Language Server (EmmyLua).\n"
1860
+ "\n"
1861
+ "This is a standard LSP JSON-RPC interface - construct LSP requests directly.\n"
1862
+ "\n"
1863
+ "Common LSP methods:\n"
1864
+ "- textDocument/hover: Type information and documentation at a position\n"
1865
+ "- textDocument/completion: Auto-completion suggestions\n"
1866
+ "- textDocument/definition: Jump to symbol definition\n"
1867
+ "- textDocument/references: Find all references to a symbol\n"
1868
+ "- textDocument/documentSymbol: Document outline/symbols\n"
1869
+ "- workspace/symbol: Search symbols across workspace\n"
1870
+ "- textDocument/formatting: Format document\n"
1871
+ "- textDocument/rename: Rename symbol\n"
1872
+ "- textDocument/signatureHelp: Function signature help\n"
1873
+ "- textDocument/diagnostic: Get diagnostics (cached from publishDiagnostics)\n"
1874
+ " - Standard: { textDocument: { uri } } for single file\n"
1875
+ " - Extension: omit textDocument for workspace-wide; severity?: 1-4 (default 2); summaryOnly?: bool\n"
1876
+ "\n"
1877
+ "LSP Protocol Rules:\n"
1878
+ "1. Line and character numbers are 0-based\n"
1879
+ "2. File paths must be absolute URIs: file:///C:/path/file.lua (Win) or file:///path/file.lua (Unix)\n"
1880
+ "3. Standard request format: { method, params: { textDocument: {uri}, position: {line, character} } }\n"
1881
+ "\n"
1882
+ "Pagination (extension): { method, params, page?, page_size?, page_max_bytes? }"
1883
+ ),
1884
+ "inputSchema": {
1885
+ "type": "object",
1886
+ "properties": {
1887
+ "method": {
1888
+ "type": "string",
1889
+ "description": "LSP method name (e.g. textDocument/hover, textDocument/diagnostic)"
1890
+ },
1891
+ "params": {
1892
+ "type": "object",
1893
+ "description": "LSP method parameters as defined in LSP specification"
1894
+ },
1895
+ "page": {
1896
+ "type": "integer",
1897
+ "description": "Page number for paginated results (default: 1)"
1898
+ },
1899
+ "page_size": {
1900
+ "type": "integer",
1901
+ "description": "Items per page (default: 20, max: 100)"
1902
+ },
1903
+ "page_max_bytes": {
1904
+ "type": "integer",
1905
+ "description": "Max bytes per page (default: 16000, ~4000 tokens)"
1906
+ },
1907
+ },
1908
+ "required": ["method", "params"],
1909
+ },
1910
+ }
1911
+
1912
+ def _mcp_call_tool(self, arguments: dict) -> dict:
1913
+ """MCP tool 调用:透传 LSP JSON-RPC 请求"""
1914
+ if not self._lsp_ready.wait(timeout=30):
1915
+ return {'ok': False, 'error': 'LSP server not ready (timeout 30s)'}
1916
+ method = arguments.get('method', '')
1917
+ params = arguments.get('params', {})
1918
+ page = arguments.get('page', 1)
1919
+ page_size = arguments.get('page_size', 20)
1920
+ page_max_bytes = arguments.get('page_max_bytes', 16000)
1921
+
1922
+ if not method:
1923
+ return {'ok': False, 'error': 'Missing method'}
1924
+
1925
+ if method == 'textDocument/diagnostic':
1926
+ return self.handle_diagnostic_request(params, page, page_size, page_max_bytes)
1927
+
1928
+ result = self.forward_request(method, params)
1929
+ return paginate_result(result, page, page_size, page_max_bytes)
1930
+
1931
+ def start_mcp_frontend(self):
1932
+ """MCP stdio 前端:自动检测 NDJSON / Content-Length 帧格式"""
1933
+ self._mcp_use_ndjson = None # 首次读取时自动检测
1934
+
1935
+ def mcp_write(msg: dict):
1936
+ line = json.dumps(msg, ensure_ascii=False)
1937
+ if self._mcp_use_ndjson is not False:
1938
+ sys.stdout.write(line + "\n")
1939
+ sys.stdout.flush()
1940
+ else:
1941
+ body = line.encode('utf-8')
1942
+ header = f"Content-Length: {len(body)}\r\n\r\n".encode('ascii')
1943
+ sys.stdout.buffer.write(header + body)
1944
+ sys.stdout.buffer.flush()
1945
+
1946
+ def mcp_handle(message: dict):
1947
+ method = message.get('method', '')
1948
+ msg_id = message.get('id')
1949
+
1950
+ if method == 'initialize':
1951
+ proto = message.get('params', {}).get('protocolVersion', '2025-11-25')
1952
+ mcp_write({
1953
+ "jsonrpc": "2.0", "id": msg_id,
1954
+ "result": {
1955
+ "protocolVersion": proto,
1956
+ "capabilities": {"tools": {}},
1957
+ "serverInfo": {"name": "emmylua-lsp", "version": "1.0.0"},
1958
+ }
1959
+ })
1960
+ elif method == 'notifications/initialized':
1961
+ pass
1962
+ elif method == 'tools/list':
1963
+ mcp_write({
1964
+ "jsonrpc": "2.0", "id": msg_id,
1965
+ "result": {"tools": [self.MCP_TOOL]}
1966
+ })
1967
+ elif method == 'tools/call':
1968
+ params = message.get('params', {})
1969
+ tool_args = params.get('arguments', {})
1970
+ try:
1971
+ result = self._mcp_call_tool(tool_args)
1972
+ mcp_write({
1973
+ "jsonrpc": "2.0", "id": msg_id,
1974
+ "result": {
1975
+ "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]
1976
+ }
1977
+ })
1978
+ except Exception as e:
1979
+ mcp_write({
1980
+ "jsonrpc": "2.0", "id": msg_id,
1981
+ "error": {"code": -32000, "message": str(e)}
1982
+ })
1983
+ elif msg_id is not None:
1984
+ mcp_write({
1985
+ "jsonrpc": "2.0", "id": msg_id,
1986
+ "error": {"code": -32601, "message": f"Method not found: {method}"}
1987
+ })
1988
+
1989
+ logger.info("MCP stdio 前端已启动")
1990
+
1991
+ stdin_bin = sys.stdin.buffer
1992
+ buffer = b''
1993
+ while self.running:
1994
+ try:
1995
+ chunk = stdin_bin.read1(4096) if hasattr(stdin_bin, 'read1') else stdin_bin.read(4096)
1996
+ if not chunk:
1997
+ break
1998
+ buffer += chunk
1999
+
2000
+ # 首次读取时检测格式:{ 开头 = NDJSON,C 开头 = Content-Length
2001
+ if self._mcp_use_ndjson is None:
2002
+ first = buffer.lstrip()[:1]
2003
+ self._mcp_use_ndjson = (first == b'{')
2004
+ logger.info(f"MCP 协议检测: {'NDJSON' if self._mcp_use_ndjson else 'Content-Length'}")
2005
+
2006
+ if self._mcp_use_ndjson:
2007
+ while b'\n' in buffer:
2008
+ line, buffer = buffer.split(b'\n', 1)
2009
+ line = line.strip()
2010
+ if not line:
2011
+ continue
2012
+ message = json.loads(line.decode('utf-8'))
2013
+ mcp_handle(message)
2014
+ else:
2015
+ while True:
2016
+ header_end = buffer.find(b'\r\n\r\n')
2017
+ if header_end == -1:
2018
+ break
2019
+ header = buffer[:header_end].decode('utf-8')
2020
+ content_length = None
2021
+ for h_line in header.split('\r\n'):
2022
+ if h_line.startswith('Content-Length:'):
2023
+ content_length = int(h_line.split(':')[1].strip())
2024
+ break
2025
+ if content_length is None:
2026
+ buffer = buffer[header_end + 4:]
2027
+ continue
2028
+ body_start = header_end + 4
2029
+ if len(buffer) < body_start + content_length:
2030
+ break
2031
+ body = buffer[body_start:body_start + content_length]
2032
+ buffer = buffer[body_start + content_length:]
2033
+ message = json.loads(body.decode('utf-8'))
2034
+ mcp_handle(message)
2035
+ except Exception as e:
2036
+ logger.error(f"MCP stdin 读取错误: {e}")
2037
+ break
2038
+
2039
+ logger.info("MCP stdio 前端已退出")
2040
+
2041
+ def _handle_diagnostics(self, params: dict):
2042
+ """处理诊断信息(watch 模式)"""
2043
+ uri = params['uri']
2044
+ diagnostics = params['diagnostics']
2045
+
2046
+ # 转换 URI 到文件路径
2047
+ file_path = self._uri_to_path(uri)
2048
+ file_name = os.path.basename(file_path)
2049
+
2050
+ with self.lock:
2051
+ if diagnostics:
2052
+ # 清空该文件的旧诊断
2053
+ self.diagnostics[file_path] = []
2054
+
2055
+ # 显示文件头部
2056
+ if self.use_color:
2057
+ logger.info(f"\n{Color.MAGENTA}{'━'*60}{Color.RESET}")
2058
+ logger.info(f"{Color.BOLD}📄 {file_name}{Color.RESET} - 发现 {Color.RED}{len(diagnostics)}{Color.RESET} 个问题")
2059
+ logger.info(f"{Color.MAGENTA}{'━'*60}{Color.RESET}")
2060
+ else:
2061
+ logger.info(f"\n{'━'*60}")
2062
+ logger.info(f"📄 {file_name} - 发现 {len(diagnostics)} 个问题")
2063
+ logger.info(f"{'━'*60}")
2064
+
2065
+ # 添加每个诊断(使用统一方法)
2066
+ for diag in diagnostics:
2067
+ self._add_diagnostic_to_store(file_path, diag, show_output=True)
2068
+ else:
2069
+ # 没有问题,删除该文件的诊断
2070
+ if file_path in self.diagnostics:
2071
+ del self.diagnostics[file_path]
2072
+ if self.use_color:
2073
+ logger.info(f"{Color.GREEN}✓{Color.RESET} {file_name} - 没有问题")
2074
+ else:
2075
+ logger.info(f"✓ {file_name} - 没有问题")
2076
+
2077
+ # 保存诊断结果到 .log 文件
2078
+ self._save_diagnostics()
2079
+ self._save_errors_only()
2080
+
2081
+ def _save_diagnostics(self):
2082
+ """保存诊断结果到 .log 文件(单行格式,包含所有级别)"""
2083
+ try:
2084
+ with open(self.output_file, 'w', encoding='utf-8') as f:
2085
+ # 统计信息
2086
+ error_count = sum(1 for diags in self.diagnostics.values()
2087
+ for d in diags if d.get('severity') == 1)
2088
+ warning_count = sum(1 for diags in self.diagnostics.values()
2089
+ for d in diags if d.get('severity') == 2)
2090
+ info_count = sum(1 for diags in self.diagnostics.values()
2091
+ for d in diags if d.get('severity') == 3)
2092
+ hint_count = sum(1 for diags in self.diagnostics.values()
2093
+ for d in diags if d.get('severity') == 4)
2094
+
2095
+ total_issues = error_count + warning_count + info_count + hint_count
2096
+
2097
+ # 头部(第一行写明 .emmylua 位置)
2098
+ f.write(f"Lua Diagnostics | Errors: {error_count} | Warnings: {warning_count} | ")
2099
+ f.write(f"EmmyLua Types: {self.emmylua_dir}\n")
2100
+ f.write(f"Info: {info_count} | Hints: {hint_count} | ")
2101
+ f.write(f"Total: {total_issues} | Time: {time.strftime('%Y-%m-%d %H:%M:%S')} | ")
2102
+ f.write(f"Dir: {self.lua_scripts_dir}\n")
2103
+ f.write("---\n")
2104
+
2105
+ if total_issues == 0:
2106
+ f.write("✅ No issues found\n")
2107
+ else:
2108
+ # 单行格式:LEVEL | 文件:行:列 | 信息 [错误码]
2109
+ # 严重级别映射
2110
+ severity_map = {1: "ERROR", 2: "WARN ", 3: "INFO ", 4: "HINT "}
2111
+
2112
+ # 收集所有诊断并按严重级别排序
2113
+ all_diags = []
2114
+ for file_path, diags in sorted(self.diagnostics.items()):
2115
+ file_name = os.path.basename(file_path)
2116
+ for diag in diags:
2117
+ all_diags.append((file_name, diag))
2118
+
2119
+ # 按严重级别排序(错误优先)
2120
+ all_diags.sort(key=lambda x: x[1].get('severity', 1))
2121
+
2122
+ # 输出每一行诊断
2123
+ for file_name, diag in all_diags:
2124
+ severity = diag.get('severity', 1)
2125
+ level = severity_map.get(severity, "UNKN ")
2126
+
2127
+ range_info = diag.get('range', {})
2128
+ start = range_info.get('start', {})
2129
+ line = start.get('line', 0) + 1
2130
+ column = start.get('character', 0) + 1
2131
+
2132
+ # 清理消息(只取第一行)
2133
+ message = diag.get('message', '').split('\n')[0].strip()
2134
+
2135
+ code = diag.get('code', '')
2136
+ code_str = f"[{code}]" if code else ""
2137
+
2138
+ # 格式:LEVEL | 文件:行:列 | 信息 [错误码]
2139
+ f.write(f"{level} | {file_name}:{line}:{column} | {message} {code_str}\n".strip() + "\n")
2140
+
2141
+ logger.debug(f"诊断结果已保存到 {self.output_file}")
2142
+ except Exception as e:
2143
+ logger.error(f"保存诊断结果失败: {e}")
2144
+
2145
+ def _save_errors_only(self):
2146
+ """只保存错误到 .log 文件(单行格式)"""
2147
+ try:
2148
+ with open(self.errors_only_file, 'w', encoding='utf-8') as f:
2149
+ # 统计错误数量
2150
+ error_count = sum(1 for diags in self.diagnostics.values()
2151
+ for d in diags if d.get('severity') == 1)
2152
+
2153
+ # 头部
2154
+ f.write(f"Lua Errors: {error_count} | Dir: {self.lua_scripts_dir} | Time: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
2155
+ f.write(f"EmmyLua Types: {self.emmylua_dir}\n")
2156
+ f.write("---\n")
2157
+
2158
+ if error_count == 0:
2159
+ f.write("✅ No errors\n")
2160
+ else:
2161
+ # 单行格式:ERROR | 文件:行:列 | 错误信息 [错误码]
2162
+ for file_path, diags in sorted(self.diagnostics.items()):
2163
+ errors = [d for d in diags if d.get('severity') == 1]
2164
+ if errors:
2165
+ file_name = os.path.basename(file_path)
2166
+
2167
+ for err in errors:
2168
+ range_info = err.get('range', {})
2169
+ start = range_info.get('start', {})
2170
+ line = start.get('line', 0) + 1
2171
+ column = start.get('character', 0) + 1
2172
+
2173
+ # 清理重复的错误消息
2174
+ message = err.get('message', '').split('\n')[0].strip()
2175
+
2176
+ code = err.get('code', '')
2177
+ code_str = f"[{code}]" if code else ""
2178
+
2179
+ # 格式:ERROR | 文件:行:列 | 错误信息 [错误码]
2180
+ f.write(f"ERROR | {file_name}:{line}:{column} | {message} {code_str}\n".strip() + "\n")
2181
+
2182
+ logger.debug(f"错误报告已保存到 {self.errors_only_file}")
2183
+ except Exception as e:
2184
+ logger.error(f"保存错误报告失败: {e}")
2185
+
2186
+ def _send_message(self, message: dict):
2187
+ """发送消息到 LSP"""
2188
+ if not self.lsp_process or not self.lsp_process.stdin:
2189
+ return
2190
+
2191
+ if self.lsp_process.poll() is not None:
2192
+ return
2193
+
2194
+ try:
2195
+ encoded = LSPProtocol.encode_message(message)
2196
+ with self._write_lock:
2197
+ self.lsp_process.stdin.write(encoded)
2198
+ self.lsp_process.stdin.flush()
2199
+ except BrokenPipeError:
2200
+ logger.warning("LSP 进程已断开连接 (Broken pipe)")
2201
+ except Exception as e:
2202
+ logger.error(f"发送消息失败: {e}")
2203
+
2204
+ def close_file(self, file_path: str):
2205
+ """关闭文件并释放 LSP 资源"""
2206
+ file_path = str(Path(file_path).resolve())
2207
+
2208
+ if file_path not in self.opened_files:
2209
+ return
2210
+
2211
+ uri = Path(file_path).as_uri()
2212
+
2213
+ # 发送 didClose 通知
2214
+ did_close = {
2215
+ "jsonrpc": "2.0",
2216
+ "method": "textDocument/didClose",
2217
+ "params": {
2218
+ "textDocument": {"uri": uri}
2219
+ }
2220
+ }
2221
+ self._send_message(did_close)
2222
+
2223
+ # 从缓存中移除
2224
+ del self.opened_files[file_path]
2225
+
2226
+ # 清理诊断信息
2227
+ with self.lock:
2228
+ if file_path in self.diagnostics:
2229
+ del self.diagnostics[file_path]
2230
+
2231
+ logger.debug(f"已关闭文件: {Path(file_path).name}")
2232
+
2233
+ def check_file(self, file_path: str):
2234
+ """检查单个文件"""
2235
+ file_path = str(Path(file_path).resolve())
2236
+
2237
+ if not Path(file_path).exists():
2238
+ # 文件已删除,发送 didClose 并清理
2239
+ if file_path in self.opened_files:
2240
+ self.close_file(file_path)
2241
+ return
2242
+
2243
+ try:
2244
+ with open(file_path, 'r', encoding='utf-8') as f:
2245
+ content = f.read()
2246
+
2247
+ uri = Path(file_path).as_uri()
2248
+
2249
+ if file_path in self.opened_files:
2250
+ # 文件已打开,发送 didChange + 递增版本号
2251
+ self.opened_files[file_path] += 1
2252
+ version = self.opened_files[file_path]
2253
+
2254
+ did_change = {
2255
+ "jsonrpc": "2.0",
2256
+ "method": "textDocument/didChange",
2257
+ "params": {
2258
+ "textDocument": {
2259
+ "uri": uri,
2260
+ "version": version
2261
+ },
2262
+ "contentChanges": [
2263
+ {"text": content} # 全量更新
2264
+ ]
2265
+ }
2266
+ }
2267
+ self._send_message(did_change)
2268
+
2269
+ # 发送 didSave 通知,触发工作区级别的诊断(包括依赖文件)
2270
+ did_save = {
2271
+ "jsonrpc": "2.0",
2272
+ "method": "textDocument/didSave",
2273
+ "params": {
2274
+ "textDocument": {"uri": uri},
2275
+ "text": content
2276
+ }
2277
+ }
2278
+ self._send_message(did_save)
2279
+ logger.debug(f"已发送变更通知: {Path(file_path).name} (v{version})")
2280
+ else:
2281
+ # 首次打开,发送 didOpen
2282
+ self.opened_files[file_path] = 1
2283
+
2284
+ did_open = {
2285
+ "jsonrpc": "2.0",
2286
+ "method": "textDocument/didOpen",
2287
+ "params": {
2288
+ "textDocument": {
2289
+ "uri": uri,
2290
+ "languageId": "lua",
2291
+ "version": 1,
2292
+ "text": content
2293
+ }
2294
+ }
2295
+ }
2296
+ self._send_message(did_open)
2297
+
2298
+ # 同时发送 didSave 触发完整的工作区诊断
2299
+ did_save = {
2300
+ "jsonrpc": "2.0",
2301
+ "method": "textDocument/didSave",
2302
+ "params": {
2303
+ "textDocument": {"uri": uri},
2304
+ "text": content
2305
+ }
2306
+ }
2307
+ self._send_message(did_save)
2308
+ logger.debug(f"已发送打开通知: {Path(file_path).name} (v1)")
2309
+
2310
+ except Exception as e:
2311
+ logger.error(f"检查文件 {file_path} 失败: {e}")
2312
+
2313
+ def _check_all_files(self):
2314
+ """检查所有 Lua 文件"""
2315
+ if self.use_color:
2316
+ logger.info(f"\n{Color.CYAN}{'='*60}{Color.RESET}")
2317
+ logger.info(f"{Color.BOLD}🔍 开始扫描 Lua 文件...{Color.RESET}")
2318
+ logger.info(f"{Color.CYAN}{'='*60}{Color.RESET}")
2319
+ else:
2320
+ logger.info(f"\n{'='*60}")
2321
+ logger.info(f"🔍 开始扫描 Lua 文件...")
2322
+ logger.info(f"{'='*60}")
2323
+
2324
+ lua_files = list(self.lua_scripts_dir.rglob('*.lua'))
2325
+ logger.info(f"找到 {len(lua_files)} 个 Lua 文件")
2326
+
2327
+ for lua_file in lua_files:
2328
+ self.check_file(str(lua_file))
2329
+ time.sleep(0.1) # 避免过快发送请求
2330
+
2331
+ # 等待一段时间让诊断结果返回
2332
+ time.sleep(2)
2333
+
2334
+ # 输出摘要
2335
+ with self.lock:
2336
+ summary = DiagnosticFormatter.format_summary(self.diagnostics, use_color=self.use_color)
2337
+ logger.info(summary)
2338
+
2339
+ if self.use_color:
2340
+ logger.info(f"{Color.GREEN}✓ 初始检查完成{Color.RESET}")
2341
+ else:
2342
+ logger.info("✓ 初始检查完成")
2343
+
2344
+ def _start_file_watcher(self, block: bool = True):
2345
+ """启动文件监视器
2346
+
2347
+ Args:
2348
+ block: True 时在主线程阻塞等待(控制台模式),False 时后台运行(MCP 模式)
2349
+ """
2350
+ event_handler = LuaFileWatcher(self)
2351
+ self._observer = Observer()
2352
+ self._observer.schedule(event_handler, str(self.lua_scripts_dir), recursive=True)
2353
+ self._observer.start()
2354
+ logger.info("文件监视器已启动")
2355
+
2356
+ if block:
2357
+ try:
2358
+ while self.running:
2359
+ time.sleep(1)
2360
+ finally:
2361
+ self._observer.stop()
2362
+ self._observer.join()
2363
+
2364
+ def stop(self):
2365
+ """停止服务器"""
2366
+ if not self.running:
2367
+ return
2368
+ self.running = False
2369
+ logger.info("正在停止 Lua LSP 服务器...")
2370
+
2371
+ if self._observer and self._observer.is_alive():
2372
+ self._observer.stop()
2373
+ self._observer.join(timeout=2)
2374
+
2375
+ if self.lsp_process:
2376
+ pid = self.lsp_process.pid
2377
+ logger.info(f"正在清理 LSP 进程 (PID: {pid})...")
2378
+
2379
+ try:
2380
+ # 尝试优雅退出
2381
+ shutdown_request = {
2382
+ "jsonrpc": "2.0",
2383
+ "id": self._next_id(),
2384
+ "method": "shutdown",
2385
+ "params": None
2386
+ }
2387
+ self._send_message(shutdown_request)
2388
+ time.sleep(0.2)
2389
+
2390
+ exit_notification = {
2391
+ "jsonrpc": "2.0",
2392
+ "method": "exit",
2393
+ "params": None
2394
+ }
2395
+ self._send_message(exit_notification)
2396
+
2397
+ # 等待进程退出
2398
+ try:
2399
+ self.lsp_process.wait(timeout=2)
2400
+ except subprocess.TimeoutExpired:
2401
+ pass
2402
+ except Exception:
2403
+ pass
2404
+
2405
+ # 如果还没退出,强制杀死进程树
2406
+ if self.lsp_process.poll() is None:
2407
+ logger.info(f"强制终止 LSP 进程树 (PID: {pid})")
2408
+ self._kill_process_tree(pid)
2409
+
2410
+ self.lsp_process = None
2411
+ logger.info("LSP 进程已清理")
2412
+
2413
+ logger.info("Lua LSP 服务器已停止")
2414
+
2415
+ def _next_id(self) -> int:
2416
+ return next(self._id_counter)
2417
+
2418
+
2419
+ def main():
2420
+ # 解析命令行参数
2421
+ parser = argparse.ArgumentParser(description='Lua LSP 服务器 - 监视并检查 Lua 文件')
2422
+ parser.add_argument('--path', '-p',
2423
+ help='要监视的 Lua 脚本目录路径',
2424
+ default=None)
2425
+ parser.add_argument('--output-dir', '-d',
2426
+ help='日志文件输出目录(默认为脚本所在目录)',
2427
+ default=None)
2428
+ parser.add_argument('--mode', '-m',
2429
+ help='运行模式:watch(持续监视) / check(单次检查) / bridge(stdio 桥接)',
2430
+ choices=['watch', 'check', 'bridge'],
2431
+ default='watch')
2432
+ parser.add_argument('--configpath', '-c',
2433
+ help='指定 .luarc.json 配置文件路径',
2434
+ default=None)
2435
+ parser.add_argument('--http-port',
2436
+ help='HTTP RPC 代理端口(默认禁用,传入端口号如 9527 启用)',
2437
+ type=int,
2438
+ default=0)
2439
+ parser.add_argument('--enable-system-log',
2440
+ help='启用系统日志文件 lua_lsp.log(写到 --output-dir 目录)',
2441
+ action='store_true')
2442
+ parser.add_argument('--ls-path',
2443
+ help='emmylua_ls 可执行文件路径(默认从 PATH 查找)',
2444
+ default=None)
2445
+ parser.add_argument('--debug',
2446
+ help='启用调试模式(显示详细日志)',
2447
+ action='store_true')
2448
+ parser.add_argument('--quiet', '-q',
2449
+ help='静默模式(仅显示错误和警告,适合后台运行)',
2450
+ action='store_true')
2451
+
2452
+ args = parser.parse_args()
2453
+
2454
+ # bridge 模式 或 watch+非TTY(MCP):stdout 是协议流,logging 切到 stderr
2455
+ stdio_is_protocol = (args.mode == 'bridge') or (args.mode == 'watch' and not sys.stdin.isatty())
2456
+ if stdio_is_protocol:
2457
+ root = logging.getLogger()
2458
+ root.handlers.clear()
2459
+ root.addHandler(logging.StreamHandler(sys.stderr))
2460
+ root.handlers[0].setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
2461
+
2462
+ # 设置日志级别(只改 stdout handler,不影响后续添加的文件 handler)
2463
+ if args.debug:
2464
+ logging.getLogger().setLevel(logging.DEBUG)
2465
+ for handler in logging.getLogger().handlers:
2466
+ handler.setLevel(logging.DEBUG)
2467
+ elif args.quiet:
2468
+ logging.getLogger().setLevel(logging.DEBUG) # root 保持最低,由 handler 各自过滤
2469
+ for handler in logging.getLogger().handlers:
2470
+ handler.setLevel(logging.WARNING) # stdout 只输出 warning+
2471
+
2472
+ # 配置路径
2473
+ script_dir = Path(__file__).parent.resolve()
2474
+
2475
+ # 确定监视目录
2476
+ if args.path:
2477
+ lua_scripts_dir = Path(args.path).resolve()
2478
+ else:
2479
+ # 默认路径:相对于脚本位置(tools/lua-tools/lua_lsp -> 项目根目录)
2480
+ lua_scripts_dir = script_dir.parent.parent.parent / "engine" / "bin" / "Data" / "LuaScripts"
2481
+
2482
+ if not lua_scripts_dir.exists():
2483
+ logger.error(f"LuaScripts 目录不存在: {lua_scripts_dir}")
2484
+ sys.exit(1)
2485
+
2486
+ # 确定输出目录
2487
+ if args.output_dir:
2488
+ output_dir = Path(args.output_dir).resolve()
2489
+ else:
2490
+ # 默认输出到脚本所在目录
2491
+ output_dir = script_dir
2492
+
2493
+ # 创建输出目录
2494
+ output_dir.mkdir(parents=True, exist_ok=True)
2495
+
2496
+ # 追加文件日志到 output_dir
2497
+ # 优先级:--enable-system-log CLI 参数 > .emmyrc.override.json 的 $enableSystemLog
2498
+ override_file = script_dir / '.emmyrc.override.json'
2499
+ override_enable_log = False
2500
+ if override_file.exists():
2501
+ try:
2502
+ with open(override_file, 'r', encoding='utf-8-sig') as f:
2503
+ override_enable_log = json.load(f).get('$enableSystemLog', False)
2504
+ except Exception:
2505
+ pass
2506
+ if args.enable_system_log or override_enable_log:
2507
+ class StripAnsiFormatter(logging.Formatter):
2508
+ _ansi_re = re.compile(r'\x1b\[[0-9;]*m')
2509
+ def format(self, record):
2510
+ msg = super().format(record)
2511
+ return self._ansi_re.sub('', msg)
2512
+
2513
+ file_handler = logging.FileHandler(str(output_dir / "lua_lsp.log"), mode='w', encoding='utf-8')
2514
+ file_handler.setFormatter(StripAnsiFormatter('%(asctime)s - %(process)d - %(levelname)s - %(message)s'))
2515
+ logging.getLogger().addHandler(file_handler)
2516
+
2517
+ # 固定的日志文件名
2518
+ output_file = str(output_dir / "lua_diagnostics.log")
2519
+ errors_file = str(output_dir / "lua_errors.log")
2520
+
2521
+ # 创建并启动服务器
2522
+ server = LuaLSPServer(
2523
+ str(lua_scripts_dir),
2524
+ output_file=output_file,
2525
+ errors_only_file=errors_file,
2526
+ mode=args.mode,
2527
+ config_path=args.configpath,
2528
+ lsp_path=args.ls_path
2529
+ )
2530
+
2531
+ # 设置 HTTP 端口(仅 watch 模式)
2532
+ if args.mode == 'watch':
2533
+ server.http_port = args.http_port
2534
+
2535
+ # 注册退出清理(确保关闭控制台时也能清理)
2536
+ atexit.register(server.stop)
2537
+
2538
+ # 信号处理(Windows 和 Unix)
2539
+ def signal_handler(signum, frame):
2540
+ logger.info(f"收到信号 {signum},正在清理...")
2541
+ server.stop()
2542
+ sys.exit(0)
2543
+
2544
+ signal.signal(signal.SIGTERM, signal_handler)
2545
+ signal.signal(signal.SIGINT, signal_handler)
2546
+ if hasattr(signal, 'SIGBREAK'): # Windows only
2547
+ signal.signal(signal.SIGBREAK, signal_handler)
2548
+
2549
+ # bridge 模式:直接启动,不打 banner
2550
+ if args.mode == 'bridge':
2551
+ server.start()
2552
+ return
2553
+
2554
+ # 根据模式调整日志头部
2555
+ mode_display = "持续监视模式" if args.mode == "watch" else "单次检查模式"
2556
+
2557
+ logger.info(f"\n{Color.BOLD}{Color.GREEN}{'='*60}{Color.RESET}")
2558
+ logger.info(f"{Color.BOLD}{Color.GREEN} Lua LSP 服务器 v2.0.0 - EmmyLuaLS ({mode_display}){Color.RESET}")
2559
+ logger.info(f"{Color.BOLD}{Color.GREEN}{'='*60}{Color.RESET}")
2560
+ logger.info(f"监视目录: {Color.CYAN}{lua_scripts_dir}{Color.RESET}")
2561
+ logger.info(f"EmmyLua: {Color.CYAN}{server.emmylua_dir}{Color.RESET}")
2562
+ logger.info(f"完整报告: {Color.CYAN}{output_file}{Color.RESET}")
2563
+ logger.info(f"错误报告: {Color.CYAN}{errors_file}{Color.RESET}")
2564
+ if args.mode == 'watch' and args.http_port > 0:
2565
+ logger.info(f"HTTP RPC: {Color.CYAN}http://127.0.0.1:{args.http_port}/rpc{Color.RESET}")
2566
+ logger.info(f"{Color.GREEN}{'='*60}{Color.RESET}\n")
2567
+
2568
+ server.start()
2569
+
2570
+
2571
+ if __name__ == "__main__":
2572
+ main()
2573
+