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.
- maker_lua_lsp/__init__.py +3 -0
- maker_lua_lsp/__main__.py +2573 -0
- maker_lua_lsp/bin/emmylua_ls.exe +0 -0
- maker_lua_lsp-0.2.0.dist-info/METADATA +10 -0
- maker_lua_lsp-0.2.0.dist-info/RECORD +8 -0
- maker_lua_lsp-0.2.0.dist-info/WHEEL +5 -0
- maker_lua_lsp-0.2.0.dist-info/entry_points.txt +2 -0
- maker_lua_lsp-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|