larkvs 0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. larkvs-0.1/PKG-INFO +108 -0
  2. larkvs-0.1/larkvs/__init__.py +50 -0
  3. larkvs-0.1/larkvs/claude_helper.py +106 -0
  4. larkvs-0.1/larkvs/colorparser.py +403 -0
  5. larkvs-0.1/larkvs/colorpicker.py +127 -0
  6. larkvs-0.1/larkvs/common.py +1047 -0
  7. larkvs-0.1/larkvs/completer.py +410 -0
  8. larkvs-0.1/larkvs/execution.py +710 -0
  9. larkvs-0.1/larkvs/highlighters.py +364 -0
  10. larkvs-0.1/larkvs/home.py +408 -0
  11. larkvs-0.1/larkvs/logos/__init__.py +0 -0
  12. larkvs-0.1/larkvs/logos/larkvs-logo.png +0 -0
  13. larkvs-0.1/larkvs/logos/nahida-close.png +0 -0
  14. larkvs-0.1/larkvs/logos/nahida-new.png +0 -0
  15. larkvs-0.1/larkvs/logos/nahida-new2.png +0 -0
  16. larkvs-0.1/larkvs/logos/nahida-open.png +0 -0
  17. larkvs-0.1/larkvs/logos/nahida-open2.png +0 -0
  18. larkvs-0.1/larkvs/main.py +1697 -0
  19. larkvs-0.1/larkvs/mcp_ipc.py +206 -0
  20. larkvs-0.1/larkvs/mcp_server.py +419 -0
  21. larkvs-0.1/larkvs/mcp_tools.py +48 -0
  22. larkvs-0.1/larkvs/panels.py +701 -0
  23. larkvs-0.1/larkvs/projectio.py +316 -0
  24. larkvs-0.1/larkvs/settings.py +1170 -0
  25. larkvs-0.1/larkvs/settingsmgr.py +206 -0
  26. larkvs-0.1/larkvs/settingsui.py +239 -0
  27. larkvs-0.1/larkvs/test_mcp_e2e.py +120 -0
  28. larkvs-0.1/larkvs/test_mcp_ipc.py +118 -0
  29. larkvs-0.1/larkvs/test_mcp_multi.py +63 -0
  30. larkvs-0.1/larkvs/test_mcp_stdio.py +114 -0
  31. larkvs-0.1/larkvs/theme.py +926 -0
  32. larkvs-0.1/larkvs/workers.py +343 -0
  33. larkvs-0.1/larkvs/workspace.py +2066 -0
  34. larkvs-0.1/larkvs/xml_tree_canvas.py +1310 -0
  35. larkvs-0.1/larkvs.egg-info/PKG-INFO +108 -0
  36. larkvs-0.1/larkvs.egg-info/SOURCES.txt +40 -0
  37. larkvs-0.1/larkvs.egg-info/dependency_links.txt +1 -0
  38. larkvs-0.1/larkvs.egg-info/entry_points.txt +2 -0
  39. larkvs-0.1/larkvs.egg-info/requires.txt +4 -0
  40. larkvs-0.1/larkvs.egg-info/top_level.txt +1 -0
  41. larkvs-0.1/setup.cfg +4 -0
  42. larkvs-0.1/setup.py +39 -0
larkvs-0.1/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: larkvs
3
+ Version: 0.1
4
+ Summary: A visual debugging tool for Lark grammars, VS Code-style desktop IDE.
5
+ Author-email: 2229066748@qq.com
6
+ Maintainer: Eagle'sBaby
7
+ Maintainer-email: 2229066748@qq.com
8
+ License: Apache Licence 2.0
9
+ Keywords: python,lark,ui
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: PyQt6>6.4
15
+ Requires-Dist: lark>=1.1.0
16
+ Requires-Dist: qtawesome
17
+ Requires-Dist: fastmcp<3,>2
18
+ Dynamic: author-email
19
+ Dynamic: classifier
20
+ Dynamic: description
21
+ Dynamic: description-content-type
22
+ Dynamic: keywords
23
+ Dynamic: license
24
+ Dynamic: maintainer
25
+ Dynamic: maintainer-email
26
+ Dynamic: requires-dist
27
+ Dynamic: requires-python
28
+ Dynamic: summary
29
+
30
+ # Lark Visual Studio
31
+
32
+ A visual debugging tool for Lark grammars, VS Code-style desktop IDE.
33
+
34
+ ## Features
35
+
36
+ - **Grammar Editing** — Lark syntax highlighting, code completion, color configuration (`#RRGGBB` inline/block)
37
+ - **Parse Tree Visualization** — Tree/text dual view, node color mapping, click-to-locate source
38
+ - **Execution Engine** — Write and run Transformer/Visitor code, variable monitor, safe sandbox
39
+ - **Compile & Run Pipeline** — Compile (F6) parse only; Run (F5) parse + compile + execute
40
+ - **Project Management** — `.larkvs` project directories, recent projects, import/export `.plark`
41
+ - **Theme System** — Light/dark real-time switching
42
+ - **Settings System** — General/Appearance/Editor/Shortcuts/Interpreter, global and project-level
43
+ - **MCP Integration** — Claude Code can read/write grammar, trigger compile/run via MCP
44
+ - **Built-in Examples** — Arithmetic, JSON, CSV, lexical analysis, Lua interpreter, and more
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install larkvs
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```bash
55
+ # Launch GUI (default)
56
+ larkvs
57
+
58
+ # Open a project file
59
+ larkvs -f <path/to/project>/.larkvs
60
+
61
+ # Start MCP Server (stdio transport)
62
+ larkvs -m
63
+
64
+ # Show help
65
+ larkvs -h
66
+ ```
67
+
68
+ | Flag | Description |
69
+ |------|-------------|
70
+ | `-m`, `--mcp` | Start MCP Server (stdio) for AI clients like Claude Code |
71
+ | `-f`, `--file` <path> | Open the specified `.larkvs` project file |
72
+ | `-h` | Show help and exit |
73
+
74
+ ## Shortcuts
75
+
76
+ | Shortcut | Action |
77
+ |----------|--------|
78
+ | F6 | Compile (parse only) |
79
+ | F5 | Run (compile + execute) |
80
+ | Ctrl+, | Open settings |
81
+ | Ctrl+Shift+F | Global search |
82
+ | Double Shift | Search dialog |
83
+
84
+ ## Project Structure
85
+
86
+ ```
87
+ main.py Main window + entry point
88
+ common.py Base components (CodeEditor, Icons, StatusBar)
89
+ panels.py Feature panels (grammar editor, test input, parse result, console)
90
+ execution.py Execution engine (ExecutionPanel, VariableMonitor, ProblemPanel)
91
+ workspace.py Workspace (ActivityBar, TabWorkspace, BottomPanel, search dialog)
92
+ workers.py Background threads (ParseWorker, ExecutionWorker)
93
+ highlighters.py Syntax highlighters
94
+ colorparser.py Color configuration parser
95
+ colorpicker.py Color picker popup
96
+ completer.py Code completion
97
+ home.py Home page (recent project buttons)
98
+ projectio.py Project file I/O
99
+ settings.py Settings dialog
100
+ settingsmgr.py Settings manager
101
+ settingsui.py Settings UI components
102
+ theme.py Theme/examples/templates
103
+ xml_tree_canvas.py XML tree visualization
104
+ claude_helper.py Claude Code integration
105
+ mcp_ipc.py MCP TCP server
106
+ mcp_server.py MCP standalone server
107
+ mcp_tools.py MCP method constants
108
+ ```
@@ -0,0 +1,50 @@
1
+ """Lark Visual Studio — Lark 语法可视化调试工具"""
2
+ import sys
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+
7
+ def FN_OpenUI(filepath=None):
8
+ """启动 Lark Visual Studio GUI。"""
9
+ from PyQt6.QtWidgets import QApplication
10
+ from PyQt6.QtGui import QFont
11
+ from larkvs.main import MainWindow
12
+
13
+ app = QApplication(sys.argv)
14
+ app.setApplicationName("Lark Visual Studio")
15
+ app.setStyle("Fusion")
16
+
17
+ _font = QFont("Segoe UI", 10)
18
+ _font.setStyleHint(QFont.StyleHint.SansSerif)
19
+ app.setFont(_font)
20
+
21
+ window = MainWindow()
22
+
23
+ if filepath:
24
+ _path = Path(filepath).resolve()
25
+ if _path.exists():
26
+ window._loadProjectFromPath(str(_path))
27
+ else:
28
+ print(f"项目文件不存在: {_path}")
29
+
30
+ window.show()
31
+ sys.exit(app.exec())
32
+
33
+
34
+ def FN_StartMCP():
35
+ """启动 larkvs MCP Server (stdio 传输)。"""
36
+ from larkvs.mcp_server import mcp
37
+ mcp.run(transport="stdio")
38
+
39
+
40
+ def CMD_Enter():
41
+ """larkvs 入口:默认启动 GUI,-m 启动 MCP Server。"""
42
+ _parser = argparse.ArgumentParser(description="Lark Visual Studio")
43
+ _parser.add_argument("-m", "--mcp", action="store_true", help="启动 MCP Server (stdio)")
44
+ _parser.add_argument("-f", "--file", dest="file", default=None, help="打开指定的 .larkvs 项目文件")
45
+ _args = _parser.parse_args()
46
+
47
+ if _args.mcp:
48
+ FN_StartMCP()
49
+ else:
50
+ FN_OpenUI(_args.file)
@@ -0,0 +1,106 @@
1
+ """Claude Code 辅助集成"""
2
+ import json
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from PyQt6.QtWidgets import QMessageBox
11
+
12
+
13
+ class ClaudeHelperMixin:
14
+ """提供“帮助 claude”菜单功能:检测 claude、生成 MCP 配置并启动。"""
15
+
16
+ def _onHelpClaude(self):
17
+ """检查 claude 是否可用,若可用则创建临时目录、写入 MCP 配置并启动 claude。"""
18
+ claude_cmd = shutil.which("claude")
19
+ if claude_cmd is None:
20
+ QMessageBox.warning(
21
+ self,
22
+ "claude 未找到",
23
+ "未在 PATH 中找到 claude 命令。\n\n"
24
+ "请确保 Claude Code CLI 已安装并添加到系统 PATH 中。\n"
25
+ "安装指南: https://docs.anthropic.com/en/docs/claude-code/setup",
26
+ )
27
+ return
28
+
29
+ project_root = Path(__file__).parent.resolve()
30
+ mcp_server_path = project_root / "mcp_server.py"
31
+ if not mcp_server_path.exists():
32
+ QMessageBox.critical(
33
+ self,
34
+ "错误",
35
+ f"未找到 MCP 服务器脚本:\n{mcp_server_path}\n\n"
36
+ "请确保项目文件完整。",
37
+ )
38
+ return
39
+
40
+ temp_dir = Path(tempfile.mkdtemp(prefix="larkvs_claude_"))
41
+ claude_dir = temp_dir / ".claude"
42
+ try:
43
+ claude_dir.mkdir(parents=True, exist_ok=True)
44
+ except Exception as e:
45
+ QMessageBox.critical(
46
+ self,
47
+ "错误",
48
+ f"无法创建临时目录:\n{claude_dir}\n\n{str(e)}",
49
+ )
50
+ return
51
+
52
+ settings = {
53
+ "mcpServers": {
54
+ "larkvs": {
55
+ "command": sys.executable,
56
+ "args": [str(mcp_server_path)],
57
+ "env": {
58
+ "LARKVS_PID": str(os.getpid()),
59
+ "PYTHONIOENCODING": "utf-8",
60
+ "PYTHONUNBUFFERED": "1",
61
+ },
62
+ "type": "stdio",
63
+ }
64
+ }
65
+ }
66
+ settings_path = claude_dir / "settings.json"
67
+ try:
68
+ settings_path.write_text(
69
+ json.dumps(settings, ensure_ascii=False, indent=2),
70
+ encoding="utf-8",
71
+ )
72
+ except Exception as e:
73
+ QMessageBox.critical(
74
+ self,
75
+ "错误",
76
+ f"无法写入 Claude 设置文件:\n{settings_path}\n\n{str(e)}",
77
+ )
78
+ return
79
+
80
+ if hasattr(self, "_console"):
81
+ self._console.appendInfo(f"正在启动 claude,临时目录: {temp_dir}")
82
+ if hasattr(self, "_statusBar"):
83
+ self._statusBar.showMessage("正在启动 claude...", 5000)
84
+
85
+ try:
86
+ kwargs = {}
87
+ if os.name == "nt":
88
+ kwargs["creationflags"] = subprocess.CREATE_NEW_CONSOLE
89
+
90
+ subprocess.Popen(
91
+ [claude_cmd],
92
+ cwd=str(temp_dir),
93
+ env={**os.environ},
94
+ **kwargs,
95
+ )
96
+
97
+ if hasattr(self, "_statusBar"):
98
+ self._statusBar.showMessage(
99
+ f"claude 已启动 ({temp_dir.name})", 5000
100
+ )
101
+ except Exception as e:
102
+ QMessageBox.critical(
103
+ self,
104
+ "启动失败",
105
+ f"无法启动 claude:\n\n{str(e)}",
106
+ )
@@ -0,0 +1,403 @@
1
+ """颜色配置解析器:从语法文本中提取颜色配置,剥离写法2配置块"""
2
+ import re
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ # ==================== 常量 ====================
7
+ COLOR_PATTERN = re.compile(r'#[0-9A-Fa-f]{6}(?::(?:light|dark))?')
8
+ HEX_PREFIX = re.compile(r'#[0-9A-Fa-f]{6}')
9
+
10
+
11
+ # ==================== 数据类 ====================
12
+ @dataclass
13
+ class ColorConfig:
14
+ """颜色配置项(纯数据,不含方法)"""
15
+ light: str | None = None
16
+ dark: str | None = None
17
+ tokens: set = field(default_factory=set)
18
+
19
+
20
+ # ==================== 解析函数 ====================
21
+ def ParseColorConfigs(text):
22
+ """从语法文本中解析颜色配置,返回 list[ColorConfig]
23
+
24
+ 识别两种写法:
25
+ - 写法1(行内/向上匹配):#color 附加到规则行末尾或独立行
26
+ - 写法2(事后描述):#color { tokens } 或多行变体
27
+ """
28
+ lines = text.split('\n')
29
+ configs = []
30
+ i = 0
31
+ n = len(lines)
32
+
33
+ while i < n:
34
+ stripped = lines[i].strip()
35
+
36
+ # 跳过空行和注释行
37
+ if not stripped or stripped.startswith('//'):
38
+ i += 1
39
+ continue
40
+
41
+ # 尝试写法2(事后描述)
42
+ result = _tryPostDesc(lines, i)
43
+ if result is not None:
44
+ config, nextI = result
45
+ configs.append(config)
46
+ i = nextI
47
+ continue
48
+
49
+ # 尝试写法1行内(规则行末尾带 #color)
50
+ config = _tryInline(lines[i])
51
+ if config is not None:
52
+ configs.append(config)
53
+ i += 1
54
+ continue
55
+
56
+ # 尝试写法1向上匹配(独立颜色行,无 { 跟随)
57
+ config = _tryUpward(lines, i)
58
+ if config is not None:
59
+ configs.append(config)
60
+ i += 1
61
+ continue
62
+
63
+ i += 1
64
+
65
+ return configs
66
+
67
+
68
+ def StripColorConfigs(text):
69
+ """剥离写法2配置块,返回纯 Lark 语法
70
+
71
+ 写法1的 #color 是 Lark 注释,无需剥离。
72
+ 写法2的 {...} 和 (...) 非注释,必须剥离。
73
+ """
74
+ lines = text.split('\n')
75
+ result = []
76
+ i = 0
77
+ n = len(lines)
78
+
79
+ while i < n:
80
+ stripped = lines[i].strip()
81
+
82
+ # 空行和注释行保留
83
+ if not stripped or stripped.startswith('//'):
84
+ result.append(lines[i])
85
+ i += 1
86
+ continue
87
+
88
+ # 检查是否为写法2块起始
89
+ blockEnd = _findPostDescEnd(lines, i)
90
+ if blockEnd is not None:
91
+ i = blockEnd + 1
92
+ continue
93
+
94
+ result.append(lines[i])
95
+ i += 1
96
+
97
+ return '\n'.join(result)
98
+
99
+
100
+ # ==================== 内部函数 ====================
101
+ def _extractColors(text):
102
+ """从文本中提取颜色值,返回 dict: {'light': str|None, 'dark': str|None}"""
103
+ result = {'light': None, 'dark': None}
104
+ for m in COLOR_PATTERN.finditer(text):
105
+ full = m.group(0)
106
+ hexval = full[:7] # #RRGGBB
107
+ suffix = full[7:] # :light, :dark, or ''
108
+ if suffix == ':light':
109
+ result['light'] = hexval
110
+ elif suffix == ':dark':
111
+ result['dark'] = hexval
112
+ else:
113
+ # 无后缀:同时应用于 light 和 dark
114
+ if result['light'] is None:
115
+ result['light'] = hexval
116
+ if result['dark'] is None:
117
+ result['dark'] = hexval
118
+ return result
119
+
120
+
121
+ def _extractTokenNames(text):
122
+ """从文本中提取 TOKEN 名称(终结符名和字面量)"""
123
+ tokens = set()
124
+ # 字符串字面量: "..."
125
+ for m in re.finditer(r'"([^"]*)"', text):
126
+ tokens.add(m.group(1))
127
+ # 大写标识符(终结符名)
128
+ for m in re.finditer(r'\b[A-Z_][A-Z_0-9]*\b', text):
129
+ tokens.add(m.group(0))
130
+ return tokens
131
+
132
+
133
+ def _extractTokensFromRule(line):
134
+ """从规则行中提取终结符名和字面量"""
135
+ # 先移除颜色值
136
+ cleaned = COLOR_PATTERN.sub('', line)
137
+ return _extractTokenNames(cleaned)
138
+
139
+
140
+ def _isRuleLine(text):
141
+ """判断是否为规则行(含 : 或以 | 开头)"""
142
+ # 先移除颜色值,避免 :light/:dark 后缀导致误判
143
+ cleaned = COLOR_PATTERN.sub('', text).strip()
144
+ return ':' in cleaned or cleaned.startswith('|')
145
+
146
+
147
+ def _tryPostDesc(lines, startIdx):
148
+ """尝试解析写法2(事后描述),返回 (ColorConfig, nextIdx) 或 None"""
149
+ idx = startIdx
150
+ line = lines[idx].strip()
151
+
152
+ # 情况1:以 ( 开头的颜色组
153
+ if line.startswith('('):
154
+ return _tryPostDescParen(lines, idx)
155
+
156
+ # 情况2:以 #color 开头
157
+ if not HEX_PREFIX.match(line):
158
+ return None
159
+
160
+ colors = _extractColors(line)
161
+ afterColors = COLOR_PATTERN.sub('', line).strip()
162
+
163
+ # #color { ... } 同行
164
+ if afterColors.startswith('{'):
165
+ tokens = _parseBraceInline(afterColors)
166
+ if tokens is None:
167
+ # 多行 { },查找闭合 }
168
+ endIdx = _findClosingBrace(lines, idx)
169
+ if endIdx is None:
170
+ return None
171
+ tokens = _parseBraceBlock(lines, idx, endIdx)
172
+ return ColorConfig(colors['light'], colors['dark'], tokens), endIdx + 1
173
+ # } 也在同行
174
+ return ColorConfig(colors['light'], colors['dark'], tokens), idx + 1
175
+
176
+ # #color 独立行,查找下一非空行是否有 {
177
+ if not afterColors:
178
+ nextIdx = idx + 1
179
+ while nextIdx < len(lines):
180
+ s = lines[nextIdx].strip()
181
+ if not s or s.startswith('//'):
182
+ nextIdx += 1
183
+ continue
184
+ if s.startswith('{'):
185
+ endIdx = _findClosingBrace(lines, nextIdx)
186
+ if endIdx is None:
187
+ return None
188
+ tokens = _parseBraceBlock(lines, nextIdx, endIdx)
189
+ return ColorConfig(colors['light'], colors['dark'], tokens), endIdx + 1
190
+ return None # 非 {,不是写法2
191
+ return None
192
+
193
+ return None
194
+
195
+
196
+ def _tryPostDescParen(lines, startIdx):
197
+ """处理以 ( 开头的颜色组写法2"""
198
+ idx = startIdx
199
+ # 收集直到 ) 的文本
200
+ groupEnd = idx
201
+ while groupEnd < len(lines):
202
+ if ')' in lines[groupEnd]:
203
+ break
204
+ groupEnd += 1
205
+ if groupEnd >= len(lines):
206
+ return None
207
+
208
+ groupText = ' '.join(lines[j].strip() for j in range(idx, groupEnd + 1))
209
+ # 检查组内是否有颜色
210
+ if not HEX_PREFIX.search(groupText):
211
+ return None
212
+
213
+ colors = _extractColors(groupText)
214
+
215
+ # 检查 ) 后是否有 {
216
+ lineWithParen = lines[groupEnd].strip()
217
+ parenEnd = lineWithParen.index(')')
218
+ afterParen = lineWithParen[parenEnd + 1:].strip()
219
+
220
+ if afterParen.startswith('{'):
221
+ endIdx = _findClosingBrace(lines, groupEnd)
222
+ if endIdx is None:
223
+ return None
224
+ tokens = _parseBraceBlock(lines, groupEnd, endIdx)
225
+ return ColorConfig(colors['light'], colors['dark'], tokens), endIdx + 1
226
+
227
+ if not afterParen:
228
+ # 查找下一非空行
229
+ nextIdx = groupEnd + 1
230
+ while nextIdx < len(lines):
231
+ s = lines[nextIdx].strip()
232
+ if not s or s.startswith('//'):
233
+ nextIdx += 1
234
+ continue
235
+ if s.startswith('{'):
236
+ endIdx = _findClosingBrace(lines, nextIdx)
237
+ if endIdx is None:
238
+ return None
239
+ tokens = _parseBraceBlock(lines, nextIdx, endIdx)
240
+ return ColorConfig(colors['light'], colors['dark'], tokens), endIdx + 1
241
+ return None
242
+ return None
243
+
244
+ return None
245
+
246
+
247
+ def _findClosingBrace(lines, startIdx):
248
+ """从 startIdx 开始查找包含 } 的行索引,返回索引或 None"""
249
+ for j in range(startIdx, len(lines)):
250
+ if '}' in lines[j]:
251
+ return j
252
+ return None
253
+
254
+
255
+ def _parseBraceInline(text):
256
+ """解析同行 { ... } 内的 tokens。返回 set 或 None(如果 } 不在同行)"""
257
+ braceStart = text.index('{')
258
+ braceEnd = text.find('}', braceStart)
259
+ if braceEnd == -1:
260
+ return None # } 不在同行
261
+ inner = text[braceStart + 1:braceEnd].strip()
262
+ return _extractTokenNames(inner)
263
+
264
+
265
+ def _parseBraceBlock(lines, braceIdx, endIdx):
266
+ """解析多行 { ... } 块内的 tokens"""
267
+ tokens = set()
268
+ for j in range(braceIdx, endIdx + 1):
269
+ s = lines[j].strip()
270
+ if j == braceIdx:
271
+ # 第一行:移除 { 前的内容
272
+ if '{' in s:
273
+ s = s[s.index('{') + 1:].strip()
274
+ if j == endIdx:
275
+ # 最后一行:移除 } 及之后的内容
276
+ if '}' in s:
277
+ s = s[:s.index('}')].strip()
278
+ if not s or s.startswith('//'):
279
+ continue
280
+ tokens.update(_extractTokenNames(s))
281
+ return tokens
282
+
283
+
284
+ def _tryInline(line):
285
+ """尝试解析写法1行内(规则行末尾带 #color)"""
286
+ stripped = line.strip()
287
+
288
+ colors = _extractColors(stripped)
289
+ if colors['light'] is None and colors['dark'] is None:
290
+ return None
291
+
292
+ # 移除颜色后的内容
293
+ afterColors = COLOR_PATTERN.sub('', stripped).strip()
294
+
295
+ # 必须是规则行
296
+ if not _isRuleLine(afterColors):
297
+ return None
298
+
299
+ tokens = _extractTokensFromRule(afterColors)
300
+ return ColorConfig(colors['light'], colors['dark'], tokens)
301
+
302
+
303
+ def _tryUpward(lines, idx):
304
+ """尝试解析写法1向上匹配(独立颜色行,应用于上一规则行)"""
305
+ stripped = lines[idx].strip()
306
+
307
+ colors = _extractColors(stripped)
308
+ if colors['light'] is None and colors['dark'] is None:
309
+ return None
310
+
311
+ # 移除颜色后必须为空(纯颜色行)
312
+ afterColors = COLOR_PATTERN.sub('', stripped).strip()
313
+ if afterColors:
314
+ return None
315
+
316
+ # 查找上一非空、非注释行(跳过颜色行)
317
+ prevIdx = idx - 1
318
+ while prevIdx >= 0:
319
+ prevLine = lines[prevIdx].strip()
320
+ if not prevLine or prevLine.startswith('//'):
321
+ prevIdx -= 1
322
+ continue
323
+ # 跳过独立的颜色行
324
+ prevAfterColors = COLOR_PATTERN.sub('', prevLine).strip()
325
+ prevColors = _extractColors(prevLine)
326
+ if not prevAfterColors and (prevColors['light'] is not None or prevColors['dark'] is not None):
327
+ prevIdx -= 1
328
+ continue
329
+ if _isRuleLine(prevLine):
330
+ tokens = _extractTokensFromRule(prevLine)
331
+ return ColorConfig(colors['light'], colors['dark'], tokens)
332
+ break
333
+
334
+ return None
335
+
336
+
337
+ def _findPostDescEnd(lines, startIdx):
338
+ """如果 lines[startIdx] 开始一个写法2块,返回块最后一行的索引;否则 None"""
339
+ idx = startIdx
340
+ line = lines[idx].strip()
341
+
342
+ # 以 ( 开头的颜色组
343
+ if line.startswith('('):
344
+ # 查找 )
345
+ groupEnd = idx
346
+ while groupEnd < len(lines):
347
+ if ')' in lines[groupEnd]:
348
+ break
349
+ groupEnd += 1
350
+ if groupEnd >= len(lines):
351
+ return None
352
+
353
+ groupText = ' '.join(lines[j].strip() for j in range(idx, groupEnd + 1))
354
+ if not HEX_PREFIX.search(groupText):
355
+ return None
356
+
357
+ lineWithParen = lines[groupEnd].strip()
358
+ parenEnd = lineWithParen.index(')')
359
+ afterParen = lineWithParen[parenEnd + 1:].strip()
360
+
361
+ if afterParen.startswith('{'):
362
+ endIdx = _findClosingBrace(lines, groupEnd)
363
+ return endIdx if endIdx is not None else None
364
+
365
+ if not afterParen:
366
+ nextIdx = groupEnd + 1
367
+ while nextIdx < len(lines):
368
+ s = lines[nextIdx].strip()
369
+ if not s or s.startswith('//'):
370
+ nextIdx += 1
371
+ continue
372
+ if s.startswith('{'):
373
+ endIdx = _findClosingBrace(lines, nextIdx)
374
+ return endIdx if endIdx is not None else None
375
+ return None
376
+ return None
377
+
378
+ # 以 #color 开头
379
+ if not HEX_PREFIX.match(line):
380
+ return None
381
+
382
+ afterColors = COLOR_PATTERN.sub('', line).strip()
383
+
384
+ # #color { ... } 同行
385
+ if afterColors.startswith('{'):
386
+ endIdx = _findClosingBrace(lines, idx)
387
+ return endIdx if endIdx is not None else idx
388
+
389
+ # #color 独立行,查找下一非空行
390
+ if not afterColors:
391
+ nextIdx = idx + 1
392
+ while nextIdx < len(lines):
393
+ s = lines[nextIdx].strip()
394
+ if not s or s.startswith('//'):
395
+ nextIdx += 1
396
+ continue
397
+ if s.startswith('{'):
398
+ endIdx = _findClosingBrace(lines, nextIdx)
399
+ return endIdx if endIdx is not None else None
400
+ return None
401
+ return None
402
+
403
+ return None