From 15bf1454db38bf539f406720b45c4a10c5b3aaec Mon Sep 17 00:00:00 2001 From: Tss Date: Mon, 27 Apr 2026 21:28:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0Python=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=9A=84EasyCon=20Script=E8=AF=AD=E8=A8=80=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode-plugin/README.md | 58 +- vscode-plugin/ecs-language-server/.gitignore | 2 + .../ecs-language-server/ecs-lsp.spec | 42 + .../ecs-language-server/lsp_entry.py | 5 + .../ecs-language-server/pyproject.toml | 49 + .../src/easycon_grammar/__init__.py | 7 + .../src/easycon_grammar/ast.py | 233 ++++ .../src/easycon_grammar/compilation.py | 35 + .../easycon_grammar/grammar/ecp_grammar.lark | 247 ++++ .../src/easycon_grammar/parser.py | 1086 +++++++++++++++++ .../src/easycon_script_lsp/__init__.py | 3 + .../src/easycon_script_lsp/__main__.py | 6 + .../src/easycon_script_lsp/constants.py | 120 ++ .../easycon_script_lsp/features/__init__.py | 1 + .../easycon_script_lsp/features/completion.py | 54 + .../easycon_script_lsp/features/definition.py | 157 +++ .../features/diagnostics.py | 54 + .../features/document_symbols.py | 45 + .../easycon_script_lsp/features/formatting.py | 195 +++ .../src/easycon_script_lsp/features/hover.py | 372 ++++++ .../features/semantic_tokens.py | 90 ++ .../src/easycon_script_lsp/server.py | 223 ++++ .../src/easycon_script_lsp/utils/__init__.py | 1 + .../easycon_script_lsp/utils/ast_walker.py | 136 +++ .../src/easycon_script_lsp/utils/token_map.py | 85 ++ .../src/ecs_language_server.egg-info/PKG-INFO | 23 + .../ecs_language_server.egg-info/SOURCES.txt | 40 + .../dependency_links.txt | 1 + .../entry_points.txt | 2 + .../ecs_language_server.egg-info/requires.txt | 6 + .../top_level.txt | 2 + .../ecs-language-server/tests/__init__.py | 0 .../ecs-language-server/tests/conftest.py | 32 + .../tests/test_compilation.py | 53 + .../tests/test_integration.py | 260 ++++ .../tests/test_lsp_completions.py | 62 + .../tests/test_lsp_definition.py | 56 + .../tests/test_lsp_diagnostics.py | 35 + .../tests/test_lsp_document_symbols.py | 60 + .../tests/test_lsp_formatting.py | 155 +++ .../tests/test_lsp_hover.py | 73 ++ .../tests/test_lsp_semantic_tokens.py | 39 + .../tests/test_parser_errors.py | 117 ++ .../tests/test_parser_expressions.py | 287 +++++ .../tests/test_parser_statements.py | 526 ++++++++ .../tests/test_tokenization.py | 310 +++++ vscode-plugin/package.json | 23 +- vscode-plugin/src/extension.js | 335 ++--- vscode-plugin/syntaxes/ecs.tmLanguage.json | 177 ++- 49 files changed, 5704 insertions(+), 276 deletions(-) create mode 100644 vscode-plugin/ecs-language-server/.gitignore create mode 100644 vscode-plugin/ecs-language-server/ecs-lsp.spec create mode 100644 vscode-plugin/ecs-language-server/lsp_entry.py create mode 100644 vscode-plugin/ecs-language-server/pyproject.toml create mode 100644 vscode-plugin/ecs-language-server/src/easycon_grammar/__init__.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_grammar/ast.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_grammar/compilation.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_grammar/grammar/ecp_grammar.lark create mode 100644 vscode-plugin/ecs-language-server/src/easycon_grammar/parser.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/__init__.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/__main__.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/constants.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/__init__.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/completion.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/definition.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/diagnostics.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/document_symbols.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/formatting.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/hover.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/semantic_tokens.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/server.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/utils/__init__.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/utils/ast_walker.py create mode 100644 vscode-plugin/ecs-language-server/src/easycon_script_lsp/utils/token_map.py create mode 100644 vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/PKG-INFO create mode 100644 vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/SOURCES.txt create mode 100644 vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/dependency_links.txt create mode 100644 vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/entry_points.txt create mode 100644 vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/requires.txt create mode 100644 vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/top_level.txt create mode 100644 vscode-plugin/ecs-language-server/tests/__init__.py create mode 100644 vscode-plugin/ecs-language-server/tests/conftest.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_compilation.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_integration.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_lsp_completions.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_lsp_definition.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_lsp_diagnostics.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_lsp_document_symbols.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_lsp_formatting.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_lsp_hover.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_lsp_semantic_tokens.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_parser_errors.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_parser_expressions.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_parser_statements.py create mode 100644 vscode-plugin/ecs-language-server/tests/test_tokenization.py diff --git a/vscode-plugin/README.md b/vscode-plugin/README.md index f447b807..b3638808 100644 --- a/vscode-plugin/README.md +++ b/vscode-plugin/README.md @@ -1,6 +1,6 @@ # EasyCon for VS Code -EasyCon 伊机控 VS Code 插件,为 `.ecs` 脚本文件提供语法高亮、注释切换和脚本执行等功能。 +EasyCon 伊机控 VS Code 插件,为 `.ecs` 脚本文件提供语法高亮、代码智能和脚本执行等功能。 ## 功能特性 @@ -22,11 +22,17 @@ EasyCon 伊机控 VS Code 插件,为 `.ecs` 脚本文件提供语法高亮、 ### 代码导航 -- **跳转到定义** — 鼠标悬停到变量/常量/方法名上并按住CTRL 键,出现下划线点击可跳转到定义位置 +以下功能由 Python 语言服务器(LSP)提供: + +- **跳转到定义** — 鼠标悬停到变量、常量、函数名上并按住 CTRL 键,点击可跳转到定义位置 +- **悬停提示** — 将鼠标悬停到关键字、内置函数、变量、按键上可查看详细说明 +- **自动补全** — 输入 `$`、`_`、`@`、`(` 时自动提示关键字、内置函数、变量名和常量名 +- **文档符号** — 在 VS Code 的大纲视图中查看脚本的变量、常量和函数列表 +- **语义高亮** — 基于 AST 的精确语义着色,区分按键、方向、变量等不同语义 ### 代码格式化 -保存正确格式的脚本时会自动格式化代码。 +保存 `.ecs` 文件时会自动格式化代码。格式化功能由 Python 语言服务器提供,无需安装 `ezcon` 命令行工具即可使用。 ### 执行脚本 @@ -46,6 +52,39 @@ videotype=ANY #采集卡打开类型 状态栏会显示当前安装的 EasyCon 版本号。 +## 架构说明 + +EasyCon for VS Code 采用混合架构,部分功能已迁移至新的 Python 语言服务器(LSP)。 + +### 已迁移至 Python LSP 的功能 + +这些功能由 `ecs-language-server`(Python / pygls)提供,随插件自动启动,不需要安装 `ezcon` 工具: + +| 功能 | 说明 | +|---|---| +| **代码诊断** | 实时显示脚本中的语法错误和语义错误(如未闭合的语句块、无效的按键名等) | +| **跳转到定义** | 变量、常量、函数的定义跳转 | +| **悬停提示** | 关键字、内置函数、变量、按键的详细说明 | +| **自动补全** | 关键字、内置函数、变量名、常量名的智能补全 | +| **代码格式化** | 脚本的自动格式化(缩进、对齐等) | +| **文档符号** | VS Code 大纲视图中的符号列表 | +| **语义高亮** | 基于 AST 的精确语义着色(区分按键、方向键、修饰键等) | + +### 保留在旧 C# 系统的功能 + +这些功能仍由 `ezcon`(.NET 命令行工具)提供: + +| 功能 | 说明 | +|---|---| +| **脚本执行** | 通过 `ezcon run` 在 Nintendo Switch 硬件上执行脚本(按 F5 或点击运行按钮) | +| **配置管理** | 读取 `config.toml` 获取串口、摄像头等参数 | +| **版本显示** | 状态栏显示的 EasyCon 版本号 | + +### 依赖关系 + +- **代码格式化、跳转定义、悬停提示、自动补全等编辑功能**:由 Python LSP 提供,不再依赖 `ezcon` +- **脚本执行**:仍然依赖 `ezcon run` 命令,需要安装 EasyCon CLI 工具 + ## 打包脚本 ### 使用 build-vsix 脚本打包插件 @@ -70,4 +109,15 @@ videotype=ANY #采集卡打开类型 ## 前置条件 -使用脚本执行和格式化功能需要安装 EasyCon 命令行工具 `ezcon`,可通过设置环境变量 `EASYCON_ROOT` 指定其路径,或将 `ezcon` 添加到系统 PATH 中。 +### 脚本执行 + +使用脚本执行功能需要安装 EasyCon 命令行工具 `ezcon`,可通过设置环境变量 `EASYCON_ROOT` 指定其路径,或将 `ezcon` 添加到系统 PATH 中。 + +### 语言服务器 + +代码格式化、跳转定义、悬停提示、自动补全等编辑功能由 Python 语言服务器提供。语言服务器随插件自动启动,支持以下三种运行方式(按优先级): + +1. **自定义路径**:在 VS Code 设置中配置 `easycon.languageServer.path` 指定语言服务器可执行文件路径 +2. **内置可执行文件**:使用插件 `bin/` 目录下的 `ecs-lsp`(Windows 下为 `ecs-lsp.exe`) +3. **Python 运行**:通过 `python -m easycon_script_lsp` 启动(需要 Python 3.10+) +4. **打包**:cd ecs-language-server && python -m PyInstaller ecs-lsp.spec \ No newline at end of file diff --git a/vscode-plugin/ecs-language-server/.gitignore b/vscode-plugin/ecs-language-server/.gitignore new file mode 100644 index 00000000..54cee9a2 --- /dev/null +++ b/vscode-plugin/ecs-language-server/.gitignore @@ -0,0 +1,2 @@ +build/ +dist \ No newline at end of file diff --git a/vscode-plugin/ecs-language-server/ecs-lsp.spec b/vscode-plugin/ecs-language-server/ecs-lsp.spec new file mode 100644 index 00000000..64d1bebf --- /dev/null +++ b/vscode-plugin/ecs-language-server/ecs-lsp.spec @@ -0,0 +1,42 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_data_files + +datas = [] +datas += collect_data_files('easycon_grammar') + + +a = Analysis( + ['lsp_entry.py'], + pathex=[], + binaries=[], + datas=datas, + hiddenimports=['easycon_grammar'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='ecs-lsp', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/vscode-plugin/ecs-language-server/lsp_entry.py b/vscode-plugin/ecs-language-server/lsp_entry.py new file mode 100644 index 00000000..778f4aca --- /dev/null +++ b/vscode-plugin/ecs-language-server/lsp_entry.py @@ -0,0 +1,5 @@ +"""PyInstaller entry point for EasyCon Script Language Server.""" +from easycon_script_lsp.server import main + +if __name__ == "__main__": + main() diff --git a/vscode-plugin/ecs-language-server/pyproject.toml b/vscode-plugin/ecs-language-server/pyproject.toml new file mode 100644 index 00000000..a9ede242 --- /dev/null +++ b/vscode-plugin/ecs-language-server/pyproject.toml @@ -0,0 +1,49 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ecs-language-server" +version = "0.1.0" +description = "Language Server for EasyCon Script (.ecs) — parser, AST, and LSP server" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [ + { name = "EasyCon Team" }, +] +keywords = ["lsp", "language-server", "easycon", "ecs", "parser"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "lark>=1.1.0", + "pygls>=2.1.0", + "lsprotocol>=2025.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", +] + +[project.scripts] +ecs-lsp = "easycon_script_lsp.server:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.setuptools.package-data] +easycon_grammar = ["grammar/*.lark"] diff --git a/vscode-plugin/ecs-language-server/src/easycon_grammar/__init__.py b/vscode-plugin/ecs-language-server/src/easycon_grammar/__init__.py new file mode 100644 index 00000000..7e7d7d0d --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_grammar/__init__.py @@ -0,0 +1,7 @@ +"""EasyCon Grammar - Parser and AST for EasyCon Script.""" + +from .parser import Parser, ParseError +from .compilation import Compilation, CompilationResult +from . import ast + +__all__ = ["Parser", "ParseError", "Compilation", "CompilationResult", "ast"] diff --git a/vscode-plugin/ecs-language-server/src/easycon_grammar/ast.py b/vscode-plugin/ecs-language-server/src/easycon_grammar/ast.py new file mode 100644 index 00000000..1819fbd1 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_grammar/ast.py @@ -0,0 +1,233 @@ +"""AST nodes for EasyCon Python script engine.""" +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Optional, List, Any + + +@dataclass +class TextLocation: + line: int = 0 + column: int = 0 + + +@dataclass +class AstNode: + loc: TextLocation = field(default_factory=TextLocation) + + +# Expressions + +@dataclass +class Expr(AstNode): + pass + + +@dataclass +class LiteralExpr(Expr): + value: Any = None + + +@dataclass +class VariableExpr(Expr): + name: str = "" + read_only: bool = False + + +@dataclass +class ConstVarExpr(VariableExpr): + pass + + +@dataclass +class ExtVarExpr(Expr): + name: str = "" + + +@dataclass +class BinaryExpr(Expr): + op: str = "" + left: Expr = field(default_factory=Expr) + right: Expr = field(default_factory=Expr) + + +@dataclass +class UnaryExpr(Expr): + op: str = "" + operand: Expr = field(default_factory=Expr) + + +@dataclass +class ParenExpr(Expr): + inner: Expr = field(default_factory=Expr) + + +@dataclass +class IndexDefExpr(Expr): + items: List[Expr] = field(default_factory=list) + + +@dataclass +class IndexExpr(Expr): + name: str = "" + index: Expr = field(default_factory=Expr) + + +@dataclass +class SliceExpr(Expr): + name: str = "" + start: Optional[Expr] = None + end: Expr = field(default_factory=Expr) + + +@dataclass +class CallExpr(Expr): + name: str = "" + args: List[Expr] = field(default_factory=list) + + +# External function parameter info + +@dataclass +class ExternParamInfo: + name: str = "" + type: str = "" + + +# Statements + +@dataclass +class Stmt(AstNode): + comment: str = "" + + +@dataclass +class EmptyStmt(Stmt): + pass + + +@dataclass +class ImportStmt(Stmt): + module: str = "" + + +@dataclass +class ConstantDeclStmt(Stmt): + name: str = "" + value: Expr = field(default_factory=Expr) + + +@dataclass +class AssignmentStmt(Stmt): + target: Expr = field(default_factory=Expr) + op: str = "=" + value: Expr = field(default_factory=Expr) + + +@dataclass +class ReturnStmt(Stmt): + value: Optional[Expr] = None + + +@dataclass +class CallStmt(Stmt): + name: str = "" + args: List[Expr] = field(default_factory=list) + is_call: bool = False # True if from CALL keyword, False if direct (PRINT, etc.) + + +@dataclass +class WaitStmt(Stmt): + duration: Expr = field(default_factory=Expr) + omitted: bool = False + + +@dataclass +class KeyPressStmt(Stmt): + key: str = "" + duration: Optional[Expr] = None + + +@dataclass +class KeyActStmt(Stmt): + key: str = "" + up: bool = False + + +@dataclass +class StickPressStmt(Stmt): + key: str = "" + direction: str = "" + duration: Optional[Expr] = None + + +@dataclass +class StickActStmt(Stmt): + key: str = "" + direction: str = "" + + +@dataclass +class BreakStmt(Stmt): + level: int = 1 + + +@dataclass +class ContinueStmt(Stmt): + pass + + +# Block statements + +@dataclass +class IfStmt(Stmt): + condition: Expr = field(default_factory=Expr) + body: List[Stmt] = field(default_factory=list) + elifs: List[tuple] = field(default_factory=list) # [(Expr, [Stmt])] + else_body: List[Stmt] = field(default_factory=list) + + +@dataclass +class ForStmt(Stmt): + iter_name: Optional[str] = None + lower: Optional[Expr] = None + upper: Expr = field(default_factory=Expr) + step: Optional[Expr] = None + body: List[Stmt] = field(default_factory=list) + infinite: bool = False + + +@dataclass +class WhileStmt(Stmt): + condition: Expr = field(default_factory=Expr) + body: List[Stmt] = field(default_factory=list) + + +@dataclass +class FuncDeclStmt(Stmt): + name: str = "" + params: List[str] = field(default_factory=list) + body: List[Stmt] = field(default_factory=list) + + +@dataclass +class BeepStmt(Stmt): + frequency: Expr = field(default_factory=Expr) + duration: Expr = field(default_factory=Expr) + + +@dataclass +class AmiiboStmt(Stmt): + index: Expr = field(default_factory=Expr) + + +@dataclass +class ExternFuncStmt(Stmt): + name: str = "" + params: List[ExternParamInfo] = field(default_factory=list) + return_type: str = "" + library: str = "" + + +@dataclass +class Program(AstNode): + statements: List[Stmt] = field(default_factory=list) diff --git a/vscode-plugin/ecs-language-server/src/easycon_grammar/compilation.py b/vscode-plugin/ecs-language-server/src/easycon_grammar/compilation.py new file mode 100644 index 00000000..34b1a0c2 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_grammar/compilation.py @@ -0,0 +1,35 @@ +"""Compilation entry point for EasyCon script engine.""" +from __future__ import annotations +from typing import Optional, List + +from .parser import Parser, ParseError, _validate_program +from . import ast + + +class CompilationResult: + def __init__(self, program: Optional[ast.Program] = None, errors: Optional[List[str]] = None): + self.program = program + self.errors = errors or [] + + @property + def has_errors(self) -> bool: + return len(self.errors) > 0 + + +class Compilation: + def __init__(self, source: str): + self.source = source + self._parser = Parser() + self._program: Optional[ast.Program] = None + self._errors: List[str] = [] + + def compile(self) -> CompilationResult: + try: + self._program = self._parser.parse(self.source) + semantic_errors = _validate_program(self._program) + if semantic_errors: + return CompilationResult(program=self._program, errors=semantic_errors) + return CompilationResult(program=self._program) + except ParseError as e: + self._errors.append(str(e)) + return CompilationResult(errors=self._errors) diff --git a/vscode-plugin/ecs-language-server/src/easycon_grammar/grammar/ecp_grammar.lark b/vscode-plugin/ecs-language-server/src/easycon_grammar/grammar/ecp_grammar.lark new file mode 100644 index 00000000..4d47e16a --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_grammar/grammar/ecp_grammar.lark @@ -0,0 +1,247 @@ +// EasyCon Python (ECP) Grammar for Lark +// Based on EasyCon.Script C# implementation + +?start: program + +program: line* + +line: statement [comment] _NL + | comment _NL + | _NL + +statement: import_stmt + | extern_func_decl + | const_decl + | var_decl + | assignment + | func_decl + | return_stmt + | call_stmt + | wait_stmt + | key_action + | stick_action + | func_stmt + | beep_stmt + | amiibo_stmt + | if_open + | elif_open + | else_open + | endif_stmt + | for_open + | next_stmt + | while_open + | end_stmt + | break_stmt + | continue_stmt + +// Import +import_stmt: IMPORT_KW STRING + +// Constant declaration: _MAX = 100 +const_decl: CONST "=" expression + +// Variable declaration: $x = 10 +var_decl: VAR "=" expression + +// Assignment with optional compound operator +assignment: assign_target ASSIGN_OP expression + +assign_target: VAR | EX_VAR | index_expr | slice_expr + +ASSIGN_OP: "=" | "+=" | "-=" | "*=" | "/=" | "\\=" | "%=" | "&=" | "|=" | "^=" | "<<=" | ">>=" + +// Function declaration line +func_decl: FUNC_KW IDENT "(" [param_list] ")" [":" type_name] + | FUNC_KW IDENT [":" type_name] +param_list: param ("," param)* +param: VAR [":" type_name] +type_name: IDENT + +// External function declaration: EXTERN FUNC name(params):ret_type FROM "lib" +extern_func_decl: EXTERN_KW FUNC_KW IDENT "(" [extern_param_list] ")" ":" type_name FROM_KW STRING + +extern_param_list: extern_param ("," extern_param)* + +extern_param: VAR [":" type_name] + +// Return +return_stmt: RETURN_KW [expression] + +// Call statement: CALL funcname +call_stmt: CALL_KW IDENT + +// Wait: WAIT 100 or 100 (omitted) +wait_stmt: WAIT_KW expression + | INT -> wait_omitted + +// Function call statement (like PRINT, ALERT, or user-defined) +func_stmt: IDENT expression* + +// Key actions: A, A 100, A DOWN, A UP +// D-pad directions as buttons: UP, DOWN 100, LEFT +key_action: BUTTON_KEY [key_mod] + | DIRECTION_KEY [key_mod] -> dir_action +key_mod: expression + | "DOWN" -> key_down + | "UP" -> key_up + +// Stick actions: LS UP, LS UP,100, LS 90, RS 45,200, LS RESET +stick_action: STICK_KEY stick_mod +stick_mod: stick_direction COMMA expression -> stick_direction_duration + | stick_direction + | RESET_KW +stick_direction: DIRECTION_KEY | INT +COMMA: "," + +// Block control statements (simplified: parser builds blocks in post-processing) +if_open: IF_KW expression +elif_open: ELIF_KW expression +else_open: ELSE_KW +endif_stmt: ENDIF_KW + +for_open: FOR_KW [for_iter] +for_iter: INT [STEP_KW step_bound] + | VAR [STEP_KW step_bound] + | CONST [STEP_KW step_bound] + | VAR "=" loop_bound TO_KW loop_bound [STEP_KW step_bound] +step_bound: INT | CONST | VAR +loop_bound: INT | CONST | VAR + +next_stmt: NEXT_KW + +while_open: WHILE_KW expression +end_stmt: END_KW | ENDFUNC_KW + +// Loop control +break_stmt: BREAK_KW [INT] +continue_stmt: CONTINUE_KW + +// Builtin function statements +beep_stmt: BEEP_KW expression "," expression +amiibo_stmt: AMIIBO_KW expression + +// Comment token on a line +comment: COMMENT + +// Expressions +?expression: or_expr + +?or_expr: and_expr + | or_expr "or" and_expr -> bin_or + +?and_expr: eq_expr + | and_expr "and" eq_expr -> bin_and + +?eq_expr: rel_expr + | eq_expr "==" rel_expr -> bin_eq + | eq_expr "=" rel_expr -> bin_eq + | eq_expr "!=" rel_expr -> bin_ne + +?rel_expr: add_expr + | rel_expr "<" add_expr -> bin_lt + | rel_expr "<=" add_expr -> bin_le + | rel_expr ">" add_expr -> bin_gt + | rel_expr ">=" add_expr -> bin_ge + +?add_expr: mul_expr + | add_expr "+" mul_expr -> bin_add + | add_expr "-" mul_expr -> bin_sub + +?mul_expr: unary_expr + | mul_expr "*" unary_expr -> bin_mul + | mul_expr "/" unary_expr -> bin_div + | mul_expr "\\" unary_expr -> bin_idiv + | mul_expr "%" unary_expr -> bin_mod + | mul_expr "&" unary_expr -> bin_bitand + | mul_expr "|" unary_expr -> bin_bitor + | mul_expr "^" unary_expr -> bin_xor + | mul_expr "<<" unary_expr -> bin_shl + | mul_expr ">>" unary_expr -> bin_shr + +?unary_expr: primary + | "-" unary_expr -> neg + | "~" unary_expr -> bitnot + | "not" unary_expr -> lnot + +?primary: INT + | NUMBER + | STRING + | TRUE_KW -> bool_true + | FALSE_KW -> bool_false + | CONST + | VAR + | EX_VAR + | IDENT + | "(" expression ")" -> paren_expr + | array_def + | func_call + | index_expr + | slice_expr + +array_def: "[" [expression ("," expression)*] "]" + +func_call: IDENT "(" [expression ("," expression)*] ")" + +index_expr: VAR "[" expression "]" + +slice_expr: VAR "[" [expression] ":" expression "]" + +// Tokens +_INT: /-?\d+/ +_NUMBER: /-?\d+\.\d+/ +_STRING: /"([^"\\]|\\.)*"/ | /'([^'\\]|\\.)*'/ +_CONST: /_[a-zA-Z_\u4e00-\u9fa5][a-zA-Z0-9_\u4e00-\u9fa5]*/ +_VAR: /\$\$?[a-zA-Z0-9_\u4e00-\u9fa5][a-zA-Z0-9_\u4e00-\u9fa5]*/ +_EX_VAR: /@[a-zA-Z_\u4e00-\u9fa5][a-zA-Z0-9_\u4e00-\u9fa5]*/ +_IDENT: /[a-zA-Z_\u4e00-\u9fa5][a-zA-Z0-9_\u4e00-\u9fa5!]*/ +_COMMENT: /#[^\r\n]*/ + +%import common.WS_INLINE +%ignore WS_INLINE + +_NL: /\r?\n/ + +// Terminal aliases for Lark tree +INT: _INT +NUMBER: _NUMBER +STRING: _STRING +CONST: _CONST +VAR: _VAR +EX_VAR: _EX_VAR +IDENT: _IDENT +COMMENT: _COMMENT + +// Script keywords (must be before IDENT and BUTTON_KEY in lexer priority) +IMPORT_KW.3: "IMPORT" +IF_KW.3: "IF" +ELIF_KW.3: "ELIF" +ELSE_KW.3: "ELSE" +ENDIF_KW.3: "ENDIF" +WHILE_KW.3: "WHILE" +FOR_KW.3: "FOR" +TO_KW.3: "TO" +IN_KW.3: "IN" +STEP_KW.3: "STEP" +BREAK_KW.3: "BREAK" +CONTINUE_KW.3: "CONTINUE" +NEXT_KW.3: "NEXT" +FUNC_KW.3: "FUNC" +RETURN_KW.3: "RETURN" +ENDFUNC_KW.3: "ENDFUNC" +END_KW.3: "END" +TRUE_KW.3: "TRUE" +FALSE_KW.3: "FALSE" +RESET_KW.3: "RESET" +WAIT_KW.3: "WAIT" +CALL_KW.3: "CALL" +EXTERN_KW.3: "EXTERN" +FROM_KW.3: "FROM" +BEEP_KW.3: "BEEP" +AMIIBO_KW.3: "AMIIBO" + +// Gamepad keywords (must be before IDENT in lexer priority) +// Direction words (UP/DOWN/LEFT/RIGHT) are only in DIRECTION_KEY, not BUTTON_KEY, +// to avoid token ambiguity. D-pad presses use the dir_action rule in key_action. +BUTTON_KEY.2: /LCLICK|RCLICK|CAPTURE|MINUS|HOME|PLUS|ZL|ZR|L|R|A|B|X|Y/i +STICK_KEY.2: /LS|RS/i +DIRECTION_KEY.2: /DOWNLEFT|DOWNRIGHT|UPLEFT|UPRIGHT|UP|DOWN|LEFT|RIGHT/i diff --git a/vscode-plugin/ecs-language-server/src/easycon_grammar/parser.py b/vscode-plugin/ecs-language-server/src/easycon_grammar/parser.py new file mode 100644 index 00000000..9288215a --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_grammar/parser.py @@ -0,0 +1,1086 @@ +"""Lark-based parser for EasyCon Python.""" +from __future__ import annotations +import re +from pathlib import Path +from typing import List, Optional, Tuple + +from dataclasses import dataclass, field +from lark import Lark, Transformer, Token, Tree + +from . import ast + + +class ParseError(Exception): + pass + + +# Load grammar +_GRAMMAR_PATH = Path(__file__).parent / "grammar" / "ecp_grammar.lark" + + +def _get_loc(token_or_tree) -> ast.TextLocation: + if isinstance(token_or_tree, Token): + return ast.TextLocation(line=token_or_tree.line or 0, column=token_or_tree.column or 0) + if hasattr(token_or_tree, "meta") and token_or_tree.meta: + return ast.TextLocation(line=token_or_tree.meta.line or 0, column=token_or_tree.meta.column or 0) + return ast.TextLocation() + + +class _ExprTransformer(Transformer): + """Convert Lark expression trees to AST Expr nodes.""" + + def INT(self, tok: Token) -> ast.LiteralExpr: + return ast.LiteralExpr(loc=_get_loc(tok), value=int(tok.value)) + + def NUMBER(self, tok: Token) -> ast.LiteralExpr: + return ast.LiteralExpr(loc=_get_loc(tok), value=float(tok.value)) + + def STRING(self, tok: Token) -> ast.LiteralExpr: + raw = tok.value + if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")): + raw = raw[1:-1] + # Simple escape sequences + raw = raw.replace("\\n", "\n").replace("\\t", "\t").replace("\\r", "\r").replace("\\\\", "\\") + return ast.LiteralExpr(loc=_get_loc(tok), value=raw) + + def CONST(self, tok: Token) -> ast.ConstVarExpr: + return ast.ConstVarExpr(loc=_get_loc(tok), name=tok.value) + + def VAR(self, tok: Token) -> ast.VariableExpr: + return ast.VariableExpr(loc=_get_loc(tok), name=tok.value) + + def EX_VAR(self, tok: Token) -> ast.ExtVarExpr: + name = tok.value[1:] if tok.value.startswith("@") else tok.value + return ast.ExtVarExpr(loc=_get_loc(tok), name=name) + + def IDENT(self, tok: Token) -> ast.VariableExpr: + return ast.VariableExpr(loc=_get_loc(tok), name=tok.value, read_only=True) + + def bool_true(self, items) -> ast.LiteralExpr: + return ast.LiteralExpr(value=True) + + def bool_false(self, items) -> ast.LiteralExpr: + return ast.LiteralExpr(value=False) + + def array_def(self, items) -> ast.IndexDefExpr: + return ast.IndexDefExpr(loc=_get_loc(items[0]) if items else ast.TextLocation(), items=[i for i in items if i is not None]) + + def func_call(self, items) -> ast.CallExpr: + first = items[0] + if isinstance(first, ast.VariableExpr): + name = first.name + elif isinstance(first, Token): + name = first.value + else: + name = str(first) + args = [i for i in items[1:] if i is not None] + return ast.CallExpr(loc=_get_loc(first), name=name, args=args) + + def index_expr(self, items) -> ast.IndexExpr: + first = items[0] + if isinstance(first, ast.VariableExpr): + name = first.name + elif isinstance(first, Token): + name = first.value + else: + name = str(first) + return ast.IndexExpr(loc=_get_loc(first), name=name, index=items[1]) + + def slice_expr(self, items) -> ast.SliceExpr: + first = items[0] + if isinstance(first, ast.VariableExpr): + name = first.name + elif isinstance(first, Token): + name = first.value + else: + name = str(first) + if len(items) == 2: + return ast.SliceExpr(loc=_get_loc(first), name=name, start=None, end=items[1]) + return ast.SliceExpr(loc=_get_loc(first), name=name, start=items[1], end=items[2]) + + def type_name(self, items): + return items[0] + + def paren_expr(self, items) -> ast.ParenExpr: + inner = items[0] + return ast.ParenExpr(loc=_get_loc(inner) if hasattr(inner, "loc") else ast.TextLocation(), inner=inner) + + def neg(self, items) -> ast.UnaryExpr: + return ast.UnaryExpr(op="-", operand=items[0]) + + def bitnot(self, items) -> ast.UnaryExpr: + return ast.UnaryExpr(op="~", operand=items[0]) + + def lnot(self, items) -> ast.UnaryExpr: + return ast.UnaryExpr(op="not", operand=items[0]) + + def bin_or(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="or", left=items[0], right=items[1]) + + def bin_and(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="and", left=items[0], right=items[1]) + + def bin_eq(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="==", left=items[0], right=items[1]) + + def bin_ne(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="!=", left=items[0], right=items[1]) + + def bin_lt(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="<", left=items[0], right=items[1]) + + def bin_le(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="<=", left=items[0], right=items[1]) + + def bin_gt(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op=">", left=items[0], right=items[1]) + + def bin_ge(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op=">=", left=items[0], right=items[1]) + + def bin_add(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="+", left=items[0], right=items[1]) + + def bin_sub(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="-", left=items[0], right=items[1]) + + def bin_mul(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="*", left=items[0], right=items[1]) + + def bin_div(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="/", left=items[0], right=items[1]) + + def bin_idiv(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="\\", left=items[0], right=items[1]) + + def bin_mod(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="%", left=items[0], right=items[1]) + + def bin_bitand(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="&", left=items[0], right=items[1]) + + def bin_bitor(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="|", left=items[0], right=items[1]) + + def bin_xor(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="^", left=items[0], right=items[1]) + + def bin_shl(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op="<<", left=items[0], right=items[1]) + + def bin_shr(self, items) -> ast.BinaryExpr: + return ast.BinaryExpr(op=">>", left=items[0], right=items[1]) + + +class _StmtTransformer(Transformer): + """Convert Lark statement/line trees to AST Stmt nodes.""" + + def __init__(self): + super().__init__() + self.expr_transformer = _ExprTransformer() + + def _transform_expr(self, tree_or_token): + if isinstance(tree_or_token, Token): + return self.expr_transformer.transform(tree_or_token) + return self.expr_transformer.transform(tree_or_token) + + def type_name(self, items): + """Unwrap type_name: IDENT rule, returning the IDENT Token.""" + return items[0] + + def import_stmt(self, items) -> ast.ImportStmt: + mod = items[1].value + if mod.startswith('"') and mod.endswith('"'): + mod = mod[1:-1] + elif mod.startswith("'") and mod.endswith("'"): + mod = mod[1:-1] + return ast.ImportStmt(loc=_get_loc(items[0]), module=mod) + + def const_decl(self, items) -> ast.ConstantDeclStmt: + return ast.ConstantDeclStmt(loc=_get_loc(items[0]), name=items[0].value, value=self._transform_expr(items[1])) + + def var_decl(self, items) -> ast.AssignmentStmt: + target = self.expr_transformer.VAR(items[0]) + return ast.AssignmentStmt(loc=_get_loc(items[0]), target=target, op="=", value=self._transform_expr(items[1])) + + def assignment(self, items) -> ast.AssignmentStmt: + target = self._transform_expr(items[0]) + op_token = items[1] + op = op_token.value if isinstance(op_token, Token) else str(op_token) + return ast.AssignmentStmt(loc=_get_loc(items[0]), target=target, op=op, value=self._transform_expr(items[2])) + + def assign_target(self, items) -> ast.Expr: + return items[0] + + def func_decl(self, items) -> ast.FuncDeclStmt: + # items[0] is FUNC_KW, items[1] is IDENT + name_token = items[1] if len(items) > 1 else items[0] + if isinstance(name_token, ast.VariableExpr): + name = name_token.name + elif isinstance(name_token, Token): + name = name_token.value + else: + name = str(name_token) + params = [] + idx = 2 + if idx < len(items) and isinstance(items[idx], list): + params = items[idx] + idx += 1 + return ast.FuncDeclStmt(loc=_get_loc(name_token), name=name, params=params) + + def param_list(self, items) -> List[str]: + return [str(i.value if isinstance(i, Token) else i) for i in items] + + def param(self, items) -> str: + return items[0].value if isinstance(items[0], Token) else str(items[0]) + + def return_stmt(self, items) -> ast.ReturnStmt: + if len(items) > 1: + return ast.ReturnStmt(loc=_get_loc(items[0]), value=self._transform_expr(items[1])) + if items and not (isinstance(items[0], Token) and items[0].type in ("RETURN_KW",)): + return ast.ReturnStmt(loc=_get_loc(items[0]), value=self._transform_expr(items[0])) + return ast.ReturnStmt() + + def extern_func_decl(self, items) -> ast.ExternFuncStmt: + """EXTERN FUNC name(params):ret_type FROM \"lib\" + + Tree children (parens/colon are dropped by Lark): + With params (7): EXTERN_KW, FUNC_KW, IDENT, extern_param_list, type_name, FROM_KW, STRING + Without params (6): EXTERN_KW, FUNC_KW, IDENT, type_name, FROM_KW, STRING + """ + name_token = items[2] + name = name_token.value if isinstance(name_token, Token) else str(name_token) + + params = [] + # Use negative indices for the fixed-layout tail: ... type_name, FROM_KW, STRING + if len(items) == 7 and items[3] is not None: + params = items[3] + type_token = items[-3] + else: + type_token = items[-3] + string_token = items[-1] + + return_type = type_token.value if isinstance(type_token, Token) else str(type_token) + + library = string_token.value + if (library.startswith('"') and library.endswith('"')) or (library.startswith("'") and library.endswith("'")): + library = library[1:-1] + + return ast.ExternFuncStmt( + loc=_get_loc(name_token), + name=name, + params=params, + return_type=return_type, + library=library, + ) + + def extern_param_list(self, items) -> list: + return [i for i in items if i is not None] + + def extern_param(self, items) -> ast.ExternParamInfo: + name_tok = items[0] + name = name_tok.value if isinstance(name_tok, Token) else str(name_tok) + type_str = "" + if len(items) >= 2 and items[1] is not None: + type_tok = items[1] + type_str = type_tok.value if isinstance(type_tok, Token) else str(type_tok) + return ast.ExternParamInfo(name=name, type=type_str) + + def call_stmt(self, items) -> ast.CallStmt: + name_token = items[1] if len(items) > 1 else items[0] + if isinstance(name_token, ast.VariableExpr): + name = name_token.name + elif isinstance(name_token, Token): + name = name_token.value + else: + name = str(name_token) + return ast.CallStmt(loc=_get_loc(name_token), name=name, is_call=True) + + def wait_stmt(self, items) -> ast.WaitStmt: + expr = items[1] if len(items) > 1 else items[0] + return ast.WaitStmt(loc=_get_loc(items[0]), duration=self._transform_expr(expr), omitted=False) + + def wait_omitted(self, items) -> ast.WaitStmt: + return ast.WaitStmt(loc=_get_loc(items[0]), duration=self._transform_expr(items[0]), omitted=True) + + def func_stmt(self, items) -> ast.CallStmt: + first = items[0] + if isinstance(first, ast.VariableExpr): + name = first.name + elif isinstance(first, Token): + name = first.value + else: + name = str(first) + args = [self._transform_expr(i) for i in items[1:]] + return ast.CallStmt(loc=_get_loc(first), name=name, args=args, is_call=False) + + def key_action(self, items) -> ast.Stmt: + key = items[0].value.upper() + if len(items) == 1: + return ast.KeyPressStmt(loc=_get_loc(items[0]), key=key, duration=None) + mod = items[1] + if isinstance(mod, Token): + val = mod.value.upper() + if val == "DOWN": + return ast.KeyActStmt(loc=_get_loc(items[0]), key=key, up=False) + if val == "UP": + return ast.KeyActStmt(loc=_get_loc(items[0]), key=key, up=True) + return ast.KeyPressStmt(loc=_get_loc(items[0]), key=key, duration=self._transform_expr(mod)) + + def dir_action(self, items) -> ast.Stmt: + """D-pad direction used as a button press (e.g. UP 100).""" + key = items[0].value.upper() + if len(items) == 1: + return ast.KeyPressStmt(loc=_get_loc(items[0]), key=key, duration=None) + mod = items[1] + if isinstance(mod, Token): + val = mod.value.upper() + if val == "DOWN": + return ast.KeyActStmt(loc=_get_loc(items[0]), key=key, up=False) + if val == "UP": + return ast.KeyActStmt(loc=_get_loc(items[0]), key=key, up=True) + return ast.KeyPressStmt(loc=_get_loc(items[0]), key=key, duration=self._transform_expr(mod)) + + def stick_action(self, items) -> ast.Stmt: + key = items[0].value.upper() + mod = items[1] + if isinstance(mod, Token) and mod.value.upper() == "RESET": + return ast.StickActStmt(loc=_get_loc(items[0]), key=key, direction="RESET") + # mod is a tuple/list: (direction_token,) for stick_direction, + # or (direction_token, duration_expr) for stick_direction_duration + if isinstance(mod, (list, tuple)) and len(mod) >= 1: + direction = self._resolve_direction(mod[0]) + if len(mod) >= 2 and mod[1] is not None: + return ast.StickPressStmt(loc=_get_loc(items[0]), key=key, direction=direction, duration=self._transform_expr(mod[1])) + return ast.StickActStmt(loc=_get_loc(items[0]), key=key, direction=direction) + direction = self._resolve_direction(mod) + return ast.StickActStmt(loc=_get_loc(items[0]), key=key, direction=direction) + + @staticmethod + def _resolve_direction(item) -> str: + if isinstance(item, Token): + return item.value.upper() + if isinstance(item, ast.LiteralExpr) and isinstance(item.value, int): + return str(item.value) + return str(item).upper() + + def key_mod(self, items): + return items[0] if items else None + + def stick_mod(self, items): + if len(items) == 1 and isinstance(items[0], Token) and items[0].value.upper() == "RESET": + return items[0] + return items + + def stick_direction_duration(self, items): + # items: [direction, COMMA, expression] — filter out COMMA + return [items[0], items[-1]] + + def stick_direction(self, items): + # items[0] is either a DIRECTION_KEY Token or an INT Token + raw = items[0] + if isinstance(raw, Token) and raw.type == "INT": + return self.expr_transformer.transform(raw) + return raw + + def if_open(self, items) -> ast.Stmt: + cond = items[1] if len(items) > 1 else items[0] + return _IfOpen(loc=_get_loc(items[0]), condition=self._transform_expr(cond)) + + def elif_open(self, items) -> ast.Stmt: + cond = items[1] if len(items) > 1 else items[0] + return _ElifOpen(loc=_get_loc(items[0]), condition=self._transform_expr(cond)) + + def else_open(self, items) -> ast.Stmt: + return _ElseOpen(loc=_get_loc(items[0])) + + def endif_stmt(self, items) -> ast.Stmt: + return _EndifStmt(loc=_get_loc(items[0]) if items else ast.TextLocation()) + + def for_open(self, items) -> ast.Stmt: + if not items: + return _ForOpen(loc=ast.TextLocation()) + # for_open children: [FOR_KW, for_iter_items] + for_iter_items = items[1] if len(items) > 1 else None + if for_iter_items is None: + return _ForOpen(loc=ast.TextLocation()) + if isinstance(for_iter_items, list): + # Case 3 first: VAR = lower TO upper [STEP step] — distinguished by length >= 4 + # Children without STEP: [VAR, lower, TO_KW, upper] + # Children with STEP: [VAR, lower, TO_KW, upper, STEP_KW, step] + if len(for_iter_items) >= 4: + iter_name = for_iter_items[0].value + lower = for_iter_items[1] + upper = for_iter_items[3] + step = None + if len(for_iter_items) >= 6: + step_raw = for_iter_items[5] + step = self._transform_expr(step_raw) if not isinstance(step_raw, ast.Expr) else step_raw + if not isinstance(lower, ast.Expr): + lower = self._transform_expr(lower) + if not isinstance(upper, ast.Expr): + upper = self._transform_expr(upper) + return _ForOpen(loc=_get_loc(for_iter_items[0]), iter_name=iter_name, lower=lower, upper=upper, step=step) + # Case 1: INT [STEP step] (FOR 5, FOR 5 STEP 2) + if isinstance(for_iter_items[0], Token) and for_iter_items[0].type == "INT": + step = self._transform_expr(for_iter_items[2]) if len(for_iter_items) >= 3 else None + return _ForOpen(loc=_get_loc(for_iter_items[0]), upper=self._transform_expr(for_iter_items[0]), step=step) + # Case 2: VAR [STEP step] (FOR $count, FOR $count STEP 2) + if isinstance(for_iter_items[0], Token) and for_iter_items[0].type == "VAR": + step = self._transform_expr(for_iter_items[2]) if len(for_iter_items) >= 3 else None + return _ForOpen(loc=_get_loc(for_iter_items[0]), upper=self._transform_expr(for_iter_items[0]), step=step) + # Case 2b: CONST [STEP step] (FOR _count, FOR _count STEP 2) + if isinstance(for_iter_items[0], Token) and for_iter_items[0].type == "CONST": + step = self._transform_expr(for_iter_items[2]) if len(for_iter_items) >= 3 else None + return _ForOpen(loc=_get_loc(for_iter_items[0]), upper=self._transform_expr(for_iter_items[0]), step=step) + # Single token (e.g., INT when for_iter matched first branch and returned the token directly) + if isinstance(for_iter_items, Token): + return _ForOpen(loc=_get_loc(for_iter_items), upper=self._transform_expr(for_iter_items)) + return _ForOpen(loc=ast.TextLocation()) + + def for_iter(self, items): + return items + + def step_bound(self, items): + return self.expr_transformer.transform(items[0]) + + def loop_bound(self, items): + return self.expr_transformer.transform(items[0]) + + def next_stmt(self, items) -> ast.Stmt: + return _NextStmt(loc=_get_loc(items[0]) if items else ast.TextLocation()) + + def while_open(self, items) -> ast.Stmt: + cond = items[1] if len(items) > 1 else items[0] + return _WhileOpen(loc=_get_loc(items[0]), condition=self._transform_expr(cond)) + + def end_stmt(self, items) -> ast.Stmt: + return _EndStmt(loc=_get_loc(items[0]) if items else ast.TextLocation()) + + def break_stmt(self, items) -> ast.BreakStmt: + level = 1 + loc = ast.TextLocation() + for item in items: + if isinstance(item, Token): + if item.type == "INT": + level = int(item.value) + loc = _get_loc(item) + return ast.BreakStmt(loc=loc, level=level) + + def continue_stmt(self, items) -> ast.ContinueStmt: + return ast.ContinueStmt() + + def beep_stmt(self, items) -> ast.BeepStmt: + freq = items[1] + dur = items[2] # comma is consumed by Lark, not present in items + freq_expr = self._transform_expr(freq) + dur_expr = self._transform_expr(dur) + return ast.BeepStmt(loc=_get_loc(items[0]), frequency=freq_expr, duration=dur_expr) + + def amiibo_stmt(self, items) -> ast.AmiiboStmt: + index_expr = self._transform_expr(items[1]) if len(items) > 1 else ast.LiteralExpr(value=0) + return ast.AmiiboStmt(loc=_get_loc(items[0]), index=index_expr) + + def comment(self, items): + # Return the raw COMMENT token so line() can attach it to the statement + return items[0] if items else None + + def comment_stmt(self, items) -> ast.EmptyStmt: + s = ast.EmptyStmt() + if items: + s.comment = items[0].value + return s + + def statement(self, items): + return items[0] if items else ast.EmptyStmt() + + def line(self, items): + # line: statement [comment] _NL | comment _NL | _NL + if not items: + return ast.EmptyStmt() + stmt = items[0] + if isinstance(stmt, Token) and stmt.type == "COMMENT": + return ast.EmptyStmt(comment=stmt.value) + # comment can be a Token (from comment rule) or the raw value already set + if len(items) > 1: + comment_item = items[1] + comment_val = None + if isinstance(comment_item, Token) and comment_item.type == "COMMENT": + comment_val = comment_item.value + elif isinstance(comment_item, ast.EmptyStmt) and comment_item.comment: + comment_val = comment_item.comment + if comment_val and isinstance(stmt, ast.Stmt): + stmt.comment = comment_val + return stmt + + def program(self, items) -> ast.Program: + flat = [] + for item in items: + if isinstance(item, list): + flat.extend(item) + else: + flat.append(item) + return _build_blocks(flat) + + +# Internal markers for block building + +@dataclass +class _IfOpen(ast.Stmt): + condition: ast.Expr = field(default_factory=ast.Expr) + + +@dataclass +class _ElifOpen(ast.Stmt): + condition: ast.Expr = field(default_factory=ast.Expr) + + +@dataclass +class _ElseOpen(ast.Stmt): + pass + + +@dataclass +class _EndifStmt(ast.Stmt): + pass + + +@dataclass +class _ForOpen(ast.Stmt): + iter_name: Optional[str] = None + lower: Optional[ast.Expr] = None + upper: Optional[ast.Expr] = None + step: Optional[ast.Expr] = None + + +@dataclass +class _NextStmt(ast.Stmt): + pass + + +@dataclass +class _WhileOpen(ast.Stmt): + condition: ast.Expr = field(default_factory=ast.Expr) + + +@dataclass +class _EndStmt(ast.Stmt): + pass + + +def _build_blocks(flat_stmts: List[ast.Stmt]) -> ast.Program: + """Assemble flat statement list into nested blocks, matching C# Parser.ParseProgram logic.""" + result: List[ast.Stmt] = [] + stack: List[List[ast.Stmt]] = [result] + + i = 0 + while i < len(flat_stmts): + stmt = flat_stmts[i] + + if isinstance(stmt, ast.EmptyStmt): + stack[-1].append(stmt) + i += 1 + continue + + if isinstance(stmt, ast.ImportStmt): + # Import validation: must be at top + if any(not isinstance(s, (ast.ImportStmt, ast.EmptyStmt)) for s in stack[0]): + raise ParseError(f"导入只能在脚本开头 (line {stmt.loc.line})") + stack[-1].append(stmt) + i += 1 + continue + + if isinstance(stmt, ast.ExternFuncStmt): + if len(stack) > 1: + raise ParseError(f"EXTERN FUNC只能在顶层定义 (line {stmt.loc.line})") + stack[-1].append(stmt) + i += 1 + continue + + if isinstance(stmt, (_IfOpen, _ForOpen, _WhileOpen, ast.FuncDeclStmt)): + if isinstance(stmt, ast.FuncDeclStmt) and len(stack) > 1: + raise ParseError(f"函数必须在顶层定义 (line {stmt.loc.line})") + stack[-1].append(stmt) + stack.append([stmt]) + i += 1 + continue + + if isinstance(stmt, _ElifOpen): + if len(stack) <= 1: + raise ParseError(f"ELIF需要对应的If语句 (line {stmt.loc.line})") + block = stack[-1] + if not block or not isinstance(block[0], (_IfOpen, _ElifOpen)): + raise ParseError(f"ELIF需要对应的If语句 (line {stmt.loc.line})") + if any(isinstance(s, _ElseOpen) for s in block): + raise ParseError(f"Else语句后不能再接Elif (line {stmt.loc.line})") + stack[-1].append(stmt) + i += 1 + continue + + if isinstance(stmt, _ElseOpen): + if len(stack) <= 1: + raise ParseError(f"ELSE需要对应的If语句 (line {stmt.loc.line})") + block = stack[-1] + if not block or not isinstance(block[0], (_IfOpen, _ElifOpen)): + raise ParseError(f"ELSE需要对应的If语句 (line {stmt.loc.line})") + if any(isinstance(s, _ElseOpen) for s in block): + raise ParseError(f"一个If只能对应一个Else (line {stmt.loc.line})") + stack[-1].append(stmt) + i += 1 + continue + + if isinstance(stmt, (_EndifStmt, _NextStmt, _EndStmt)): + if len(stack) <= 1: + raise ParseError(f"多余的结束语句 (line {stmt.loc.line})") + block = stack[-1] + opener = block[0] + valid = True + if isinstance(stmt, _EndifStmt) and not isinstance(opener, (_IfOpen, _ElifOpen)): + valid = False + msg = "ENDIF需要对应的If语句" + elif isinstance(stmt, _NextStmt) and not isinstance(opener, _ForOpen): + valid = False + msg = "NEXT需要对应的For语句" + elif isinstance(stmt, _EndStmt) and isinstance(opener, ast.FuncDeclStmt): + valid = True # ENDFUNC handled here + elif isinstance(stmt, _EndStmt) and not isinstance(opener, (_WhileOpen, ast.FuncDeclStmt, _IfOpen, _ElifOpen)): + valid = False + msg = "END需要对应的语句开头" + else: + msg = "" + + if not valid: + raise ParseError(f"{msg} (line {stmt.loc.line})") + + stack.pop() + parent = stack[-1] + # Replace opener marker with proper AST block node + if isinstance(opener, (_IfOpen, _ElifOpen)): + if_stmts = block[1:] # skip the IF marker + if_body: List[ast.Stmt] = [] + elifs: List[Tuple[ast.Expr, List[ast.Stmt]]] = [] + else_body: List[ast.Stmt] = [] + current_block = if_body + first_cond = opener.condition if isinstance(opener, _IfOpen) else None + j = 0 + while j < len(if_stmts): + s = if_stmts[j] + if isinstance(s, _ElifOpen): + if current_block is if_body: + elifs.append((s.condition, [])) + else: + elifs[-1] = (elifs[-1][0], current_block) + elifs.append((s.condition, [])) + current_block = elifs[-1][1] + elif isinstance(s, _ElseOpen): + if elifs: + elifs[-1] = (elifs[-1][0], current_block) + current_block = else_body + else: + current_block.append(s) + j += 1 + if elifs and current_block is not else_body: + elifs[-1] = (elifs[-1][0], current_block) + if_node = ast.IfStmt(loc=opener.loc, condition=first_cond or ast.LiteralExpr(value=True), body=if_body, elifs=elifs, else_body=else_body, comment=opener.comment) + parent[-1] = if_node + elif isinstance(opener, _ForOpen): + body = block[1:] + for_node = ast.ForStmt(loc=opener.loc, iter_name=opener.iter_name, lower=opener.lower, upper=opener.upper or ast.LiteralExpr(value=0), step=opener.step, body=body, infinite=opener.upper is None, comment=opener.comment) + parent[-1] = for_node + elif isinstance(opener, _WhileOpen): + body = block[1:] + while_node = ast.WhileStmt(loc=opener.loc, condition=opener.condition, body=body, comment=opener.comment) + parent[-1] = while_node + elif isinstance(opener, ast.FuncDeclStmt): + body = block[1:] + opener.body = body + parent[-1] = opener + i += 1 + continue + + # Regular statement + stack[-1].append(stmt) + i += 1 + + if len(stack) > 1: + opener = stack[-1][0] + raise ParseError(f"语句块没有正确结束 (line {opener.loc.line})") + + return ast.Program(statements=result) + + +# Token patterns for syntax highlighting, ordered by priority. +# For overlapping patterns the longest match wins (maximal munch). +_TOKEN_PATTERNS = [ + # Script keywords (must be before identifiers) + ("IMPORT_KW", r"IMPORT"), + ("IF_KW", r"IF"), + ("ELIF_KW", r"ELIF"), + ("ELSE_KW", r"ELSE"), + ("ENDIF_KW", r"ENDIF"), + ("WHILE_KW", r"WHILE"), + ("FOR_KW", r"FOR"), + ("TO_KW", r"TO"), + ("IN_KW", r"IN"), + ("STEP_KW", r"STEP"), + ("BREAK_KW", r"BREAK"), + ("CONTINUE_KW", r"CONTINUE"), + ("NEXT_KW", r"NEXT"), + ("FUNC_KW", r"FUNC"), + ("RETURN_KW", r"RETURN"), + ("ENDFUNC_KW", r"ENDFUNC"), + ("END_KW", r"END"), + ("TRUE_KW", r"TRUE"), + ("FALSE_KW", r"FALSE"), + ("RESET_KW", r"RESET"), + ("WAIT_KW", r"WAIT"), + ("CALL_KW", r"CALL"), + ("EXTERN_KW", r"EXTERN"), + ("FROM_KW", r"FROM"), + ("BEEP_KW", r"BEEP"), + ("AMIIBO_KW", r"AMIIBO"), + # Logical operators + ("LOGIC_OP", r"or|and|not"), + # Gamepad keys (case-insensitive). DIRECTION_KEY before BUTTON_KEY so longer + # overlapping direction matches win. UP/DOWN/LEFT/RIGHT only in DIRECTION_KEY. + ("STICK_KEY", r"LS|RS"), + ("DIRECTION_KEY", r"DOWNLEFT|DOWNRIGHT|UPLEFT|UPRIGHT|UP|DOWN|LEFT|RIGHT"), + ("BUTTON_KEY", r"LCLICK|RCLICK|CAPTURE|MINUS|HOME|PLUS|ZL|ZR|L|R|A|B|X|Y"), + # String/number literals + ("STRING", r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\''), + ("NUMBER", r"-?\d+\.\d+"), + ("INT", r"-?\d+"), + # Variables and constants + ("VAR", r"\$\$?[a-zA-Z0-9_一-龥][a-zA-Z0-9_一-龥]*"), + ("CONST", r"_[a-zA-Z_一-龥][a-zA-Z0-9_一-龥]*"), + ("EX_VAR", r"@[a-zA-Z_一-龥][a-zA-Z0-9_一-龥]*"), + # Identifiers + ("IDENT", r"[a-zA-Z_一-龥][a-zA-Z0-9_一-龥!]*"), + # Assignment operators (before OP so <<=, +=, etc. win) + ("ASSIGN_OP", r"<<=|>>=|\+=|-=|\*=|/=|\\=|%=|&=|\|=|\^=|="), + # Comparison / arithmetic / bitwise operators + ("OP", r"==|!=|<=|>=|<<|>>|\+|-|\*|/|\\|%|&|\||\^|~|<|>"), + # Punctuation + ("PAREN", r"[()]"), + ("BRACKET", r"[\[\]]"), + ("COMMA", r","), + ("COLON", r":"), + # Comments + ("COMMENT", r"#[^\r\n]*"), +] + +_COMPILED_PATTERNS = [] +for _name, _pat in _TOKEN_PATTERNS: + # Gamepad keys are case-insensitive; keywords are case-sensitive (matching Lark grammar) + _flags = re.IGNORECASE if _name.endswith("_KEY") else 0 + _COMPILED_PATTERNS.append((_name, re.compile(_pat, _flags))) + + +def _tokenize(source: str) -> List[dict]: + """Hand-written maximal-munch lexer for ECS syntax highlighting.""" + tokens: List[dict] = [] + pos = 0 + line = 1 + col = 1 + length = len(source) + + while pos < length: + ch = source[pos] + # Inline whitespace is ignored + if ch in " \t": + pos += 1 + col += 1 + continue + # Newlines + if ch == "\n": + tokens.append({"type": "_NL", "value": "\n", "line": line, "column": col}) + pos += 1 + line += 1 + col = 1 + continue + if ch == "\r": + if pos + 1 < length and source[pos + 1] == "\n": + tokens.append( + {"type": "_NL", "value": "\r\n", "line": line, "column": col} + ) + pos += 2 + else: + tokens.append( + {"type": "_NL", "value": "\r", "line": line, "column": col} + ) + pos += 1 + line += 1 + col = 1 + continue + + best_name = None + best_match = None + best_len = 0 + for name, regex in _COMPILED_PATTERNS: + m = regex.match(source, pos) + if m: + mlen = m.end() - m.start() + if mlen > best_len: + best_len = mlen + best_match = m + best_name = name + + if best_match: + tokens.append( + { + "type": best_name, + "value": best_match.group(), + "line": line, + "column": col, + } + ) + pos += best_len + col += best_len + else: + # Unknown character – emit as-is so text isn't lost + tokens.append( + {"type": "UNKNOWN", "value": ch, "line": line, "column": col} + ) + pos += 1 + col += 1 + + return tokens + + +_BUILTIN_FUNCS = { + "WAIT", "PRINT", "ALERT", "RAND", "TIME", "LEN", "APPEND" +} + +_VALID_BUTTONS = { + "LCLICK", "RCLICK", "CAPTURE", "MINUS", "HOME", "PLUS", + "ZL", "ZR", "L", "R", "A", "B", "X", "Y", + "UP", "DOWN", "LEFT", "RIGHT", +} + +_VALID_STICKS = {"LS", "RS"} +_VALID_DIRECTIONS = { + "DOWNLEFT", "DOWNRIGHT", "UPLEFT", "UPRIGHT", + "UP", "DOWN", "LEFT", "RIGHT", "RESET", +} + + +def _validate_program(program: ast.Program) -> List[str]: + """Run semantic validation on a successfully parsed program.""" + errors: List[str] = [] + func_names: set[str] = set() + + def _collect_funcs(stmts: List[ast.Stmt]) -> None: + for stmt in stmts: + if isinstance(stmt, ast.FuncDeclStmt): + func_names.add(stmt.name.upper()) + elif isinstance(stmt, ast.ExternFuncStmt): + func_names.add(stmt.name.upper()) + elif isinstance(stmt, ast.IfStmt): + _collect_funcs(stmt.body) + for _, elif_body in stmt.elifs: + _collect_funcs(elif_body) + if stmt.else_body: + _collect_funcs(stmt.else_body) + elif isinstance(stmt, ast.ForStmt): + _collect_funcs(stmt.body) + elif isinstance(stmt, ast.WhileStmt): + _collect_funcs(stmt.body) + + _collect_funcs(program.statements) + + def _check_stmts(stmts: List[ast.Stmt]) -> None: + for stmt in stmts: + if isinstance(stmt, ast.CallStmt): + name_upper = stmt.name.upper() + if name_upper not in _BUILTIN_FUNCS and name_upper not in func_names: + errors.append(f"未知的函数或按键 '{stmt.name}' (line {stmt.loc.line})") + elif isinstance(stmt, ast.KeyPressStmt): + if stmt.key.upper() not in _VALID_BUTTONS: + errors.append(f"无效的按键 '{stmt.key}' (line {stmt.loc.line})") + if isinstance(stmt.duration, ast.VariableExpr) and stmt.duration.read_only: + errors.append(f"未知的标识符 '{stmt.duration.name}' (line {stmt.duration.loc.line})") + elif isinstance(stmt, ast.KeyActStmt): + if stmt.key.upper() not in _VALID_BUTTONS: + errors.append(f"无效的按键 '{stmt.key}' (line {stmt.loc.line})") + elif isinstance(stmt, ast.StickActStmt): + if stmt.key.upper() not in _VALID_STICKS: + errors.append(f"无效的摇杆 '{stmt.key}' (line {stmt.loc.line})") + if stmt.direction.upper() not in _VALID_DIRECTIONS: + errors.append(f"无效的方向 '{stmt.direction}' (line {stmt.loc.line})") + elif isinstance(stmt, ast.StickPressStmt): + if stmt.key.upper() not in _VALID_STICKS: + errors.append(f"无效的摇杆 '{stmt.key}' (line {stmt.loc.line})") + if stmt.direction.upper() not in _VALID_DIRECTIONS: + errors.append(f"无效的方向 '{stmt.direction}' (line {stmt.loc.line})") + if isinstance(stmt.duration, ast.VariableExpr) and stmt.duration.read_only: + errors.append(f"未知的标识符 '{stmt.duration.name}' (line {stmt.duration.loc.line})") + elif isinstance(stmt, ast.IfStmt): + _check_stmts(stmt.body) + for _, elif_body in stmt.elifs: + _check_stmts(elif_body) + if stmt.else_body: + _check_stmts(stmt.else_body) + elif isinstance(stmt, ast.ForStmt): + _check_stmts(stmt.body) + elif isinstance(stmt, ast.WhileStmt): + _check_stmts(stmt.body) + elif isinstance(stmt, ast.FuncDeclStmt): + _check_stmts(stmt.body) + + _check_stmts(program.statements) + return errors + + +# --------------------------------------------------------------------------- +# Friendly error formatting +# --------------------------------------------------------------------------- + +_ASSIGN_TOKENS = {"EQUAL", "ASSIGN_OP"} +_EXPR_TOKENS = { + "VAR", "IDENT", "INT", "NUMBER", "STRING", + "TRUE_KW", "FALSE_KW", "MINUS", "NOT", + "TILDE", "LPAR", "LSQB", "CONST", "EX_VAR", +} + + +def _format_lark_error(e: Exception) -> str: + """Convert Lark exception to a user-friendly Chinese message.""" + from lark.exceptions import UnexpectedCharacters, UnexpectedToken + + if isinstance(e, UnexpectedCharacters): + char = e.char + line = e.line + allowed = e.allowed + + if char == "\n": + if allowed & _ASSIGN_TOKENS and not (allowed & _EXPR_TOKENS): + return f"语句不完整,期望赋值操作符 (line {line})" + if allowed & _EXPR_TOKENS: + return f"语句不完整,期望表达式 (line {line})" + if allowed <= {"_NL", "COMMENT"}: + return f"此处不应有内容 (line {line})" + return f"语句不完整 (line {line})" + + if char.strip(): + return f"无效的字符 '{char}' (line {line})" + return f"意外的空白字符 (line {line})" + + if isinstance(e, UnexpectedToken): + token = e.token + line = getattr(token, "line", 0) + expected = set(e.expected) if hasattr(e, "expected") else set() + if expected & _EXPR_TOKENS: + return f"意外的符号,期望表达式 (line {line})" + return f"意外的符号 (line {line})" + + return str(e) + + +_FUNC_CALL_LINE = re.compile( + r'^(\s*)(PRINT|ALERT)\s+(.*)' +) + + +_EXPR_PATTERNS = [ + re.compile(r'^-?\d+(\.\d+)?$'), # number + re.compile(r'^".*"$'), # double-quoted string + re.compile(r"^'.*'$"), # single-quoted string + re.compile(r'^\$\$?[a-zA-Z0-9_一-龥]+$'), # variable + re.compile(r'^_[a-zA-Z0-9_一-龥]+$'), # constant + re.compile(r'^@[a-zA-Z0-9_一-龥]+$'), # external var + re.compile(r'^(TRUE|FALSE)$', re.I), # boolean + re.compile(r'^\(.*\)$'), # parenthesized +] + + +def _is_valid_expr(text: str) -> bool: + """Check if text is a valid expression token (not bare template text).""" + for pat in _EXPR_PATTERNS: + if pat.match(text): + return True + return False + + +def _preprocess_func_calls(source: str) -> str: + """Quote bare text in PRINT/ALERT template arguments so the Lark parser + can handle them as expressions. + + PRINT text: & $var & text! → PRINT "text:" & $var & "text!" + """ + lines = source.splitlines(keepends=True) + result: list[str] = [] + for line in lines: + content = line.rstrip("\r\n") + newline = line[len(content):] # preserve original line ending + + # Separate comment from content + comment = "" + hash_pos = content.find("#") + if hash_pos >= 0: + comment = content[hash_pos:] + content = content[:hash_pos].rstrip() + + m = _FUNC_CALL_LINE.match(content) + if m and not content.rstrip().endswith(":"): + indent = m.group(1) + func_name = m.group(2) + args_text = m.group(3) or "" + + parts = args_text.split("&") + quoted: list[str] = [] + for part in parts: + stripped = part.strip() + if not stripped: + continue + if _is_valid_expr(stripped): + quoted.append(stripped) + else: + escaped = stripped.replace("\\", "\\\\").replace('"', '\\"') + quoted.append(f'"{escaped}"') + + new_args = " & ".join(quoted) + content = f"{indent}{func_name} {new_args}" + + result.append(f"{content}{comment}{newline}") + + return "".join(result) + + +_LARK = None + + +def _get_lark() -> Lark: + """Return a cached Lark parser instance (module-level singleton).""" + global _LARK + if _LARK is None: + grammar = _GRAMMAR_PATH.read_text(encoding="utf-8") + _LARK = Lark(grammar, parser="earley", propagate_positions=True) + return _LARK + + +class Parser: + def __init__(self): + self._lark = _get_lark() + self._transformer = _StmtTransformer() + self.errors: List[str] = [] + + def tokenize(self, source: str) -> List[dict]: + """Lex source into tokens for syntax highlighting. + + Returns a list of dicts with keys: type, value, line, column. + """ + return _tokenize(source) + + def parse(self, source: str) -> ast.Program: + self.errors.clear() + preprocessed = _preprocess_func_calls(source) + # Ensure the source ends with a newline (grammar requires _NL after every line) + if not preprocessed.endswith("\n"): + preprocessed += "\n" + try: + tree = self._lark.parse(preprocessed) + return self._transformer.transform(tree) + except Exception as e: + friendly = _format_lark_error(e) + self.errors.append(friendly) + raise ParseError(friendly) from e diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/__init__.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/__init__.py new file mode 100644 index 00000000..56ae7492 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/__init__.py @@ -0,0 +1,3 @@ +"""EasyCon Script Language Server.""" + +__version__ = "0.1.0" diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/__main__.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/__main__.py new file mode 100644 index 00000000..52124bfd --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/__main__.py @@ -0,0 +1,6 @@ +"""CLI entry point: python -m easycon_script_lsp""" + +from easycon_script_lsp.server import main + +if __name__ == "__main__": + main() diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/constants.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/constants.py new file mode 100644 index 00000000..406bd1d7 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/constants.py @@ -0,0 +1,120 @@ +"""Constants for EasyCon Script language server.""" + +ECS_KEYWORDS = [ + "IMPORT", + "EXTERN", + "FROM", + "IF", + "ELIF", + "ELSE", + "ENDIF", + "WHILE", + "FOR", + "TO", + "IN", + "STEP", + "BREAK", + "CONTINUE", + "NEXT", + "FUNC", + "RETURN", + "ENDFUNC", + "END", + "TRUE", + "FALSE", + "RESET", + "WAIT", + "CALL", +] + +ECS_FFI_TYPES = ["INT", "BOOL", "STRING", "VOID", "PTR", "DOUBLE"] + +ECS_BUILTIN_FUNCTIONS = [ + "WAIT", + "PRINT", + "ALERT", + "RAND", + "TIME", + "LEN", + "APPEND", + "BEEP", + "AMIIBO", +] + +ECS_BUTTON_KEYS = [ + "LCLICK", + "RCLICK", + "CAPTURE", + "MINUS", + "HOME", + "PLUS", + "ZL", + "ZR", + "L", + "R", + "A", + "B", + "X", + "Y", + "UP", + "DOWN", + "LEFT", + "RIGHT", +] + +ECS_STICK_KEYS = ["LS", "RS"] + +ECS_DIRECTION_KEYS = [ + "DOWNLEFT", + "DOWNRIGHT", + "UPLEFT", + "UPRIGHT", + "UP", + "DOWN", + "LEFT", + "RIGHT", +] + +ECS_KEY_MODS = ["DOWN", "UP"] + +ECS_FILE_EXTENSION = ".ecs" +ECS_LANGUAGE_ID = "easycon-script" + +BUILTIN_DOCS = { + "WAIT": "暂停执行指定的时间(毫秒)。例如:`WAIT 1000` 暂停 1 秒。", + "PRINT": "输出信息到控制台。例如:`PRINT \"Hello\"`。", + "ALERT": "显示弹窗提示。例如:`ALERT \"Message\"`。", + "RAND": "生成随机数。例如:`RAND(10)` 返回 0~9 的随机整数。", + "TIME": "返回当前时间戳(毫秒)。", + "LEN": "返回数组长度。例如:`LEN($arr)`。", + "APPEND": "向数组末尾添加元素。例如:`APPEND($arr, 1)`。", + "BEEP": "蜂鸣器发声。例如:`BEEP 1000, 200` 以 1kHz 频率鸣叫 200ms。", + "AMIIBO": "切换 Amiibo 槽位(ESP32 专属)。例如:`AMIIBO 0`。", +} + +KEYWORD_DOCS: dict[str, str] = { + "WAIT": "延时等待:脚本在此停顿,直到设定的时间过去后继续执行。", + "IMPORT": "引入模块:将另一个脚本文件的功能导入当前脚本。", + "IF": "条件判断:如果条件成立,执行下方操作;不成立则跳过。", + "ELIF": "否则如果:在前一个条件不成立时,判断另一个条件。", + "ELSE": "否则:当以上所有条件都不成立时,执行的操作。", + "ENDIF": "结束条件判断块。", + "WHILE": "循环:只要条件成立,就反复执行内部操作。", + "FOR": "计数循环:按照设定的次数或范围重复执行。", + "TO": "配合 FOR 使用,指定循环的结束值。", + "IN": "配合 FOR 使用,在集合中逐个取出元素。", + "STEP": "配合 FOR 使用,指定每次循环的增量步长。", + "BREAK": "跳出:立即退出当前循环,继续执行循环后的操作。", + "CONTINUE": "跳过:跳过本次循环的剩余步骤,进入下一次循环。", + "NEXT": "进入下一次循环迭代。", + "FUNC": "定义动作:创建一个自定义动作,后续可用 CALL 来执行。", + "RETURN": "返回:从自定义动作中返回结果,并结束该动作。", + "ENDFUNC": "结束自定义动作的定义。", + "END": "结束当前的循环或代码块。", + "TRUE": "真:表示条件成立。", + "FALSE": "假:表示条件不成立。", + "RESET": "复位:重置所有按键状态,释放所有按下的按键。", + "CALL": "调用:执行一个自定义动作或内置功能。", + "EXTERN": "外部函数声明:声明一个来自原生动态链接库(DLL)的外部函数,配合 FUNC 和 FROM 使用。", + "FROM": "指定库路径:在 EXTERN FUNC 声明中指定外部函数所在的 DLL 文件路径。", +} diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/__init__.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/__init__.py new file mode 100644 index 00000000..e54540ae --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/__init__.py @@ -0,0 +1 @@ +"""LSP feature handlers for EasyCon Script.""" diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/completion.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/completion.py new file mode 100644 index 00000000..ccc62f6a --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/completion.py @@ -0,0 +1,54 @@ +"""Completion support for ECS LSP.""" + +from __future__ import annotations + +from lsprotocol.types import CompletionItem, CompletionItemKind, CompletionList + +from easycon_grammar import ast +from easycon_script_lsp.constants import ECS_KEYWORDS, ECS_BUILTIN_FUNCTIONS, ECS_FFI_TYPES, BUILTIN_DOCS +from easycon_script_lsp.utils.ast_walker import ASTWalker + + +def get_completions(program: ast.Program | None, trigger: str | None = None) -> CompletionList: + """Return completion items for the given program state.""" + items: list[CompletionItem] = [] + seen: set[str] = set() + + def add(label: str, kind: CompletionItemKind, detail: str = "", documentation: str = ""): + if label in seen: + return + seen.add(label) + items.append( + CompletionItem( + label=label, + kind=kind, + detail=detail or None, + documentation=documentation or None, + ) + ) + + # Keywords + for kw in ECS_KEYWORDS: + add(kw, CompletionItemKind.Keyword) + + # Built-in functions + for builtin in ECS_BUILTIN_FUNCTIONS: + doc = BUILTIN_DOCS.get(builtin, "") + add(builtin, CompletionItemKind.Function, detail="内置函数", documentation=doc) + + # FFI type names (for EXTERN FUNC declarations) + for ffi_type in ECS_FFI_TYPES: + add(ffi_type, CompletionItemKind.TypeParameter, detail="FFI 类型") + + # Declared symbols from AST + if program is not None: + walker = ASTWalker(program) + for sym in walker.iter_symbols(): + if sym.kind == "constant": + add(sym.name, CompletionItemKind.Constant) + elif sym.kind == "variable": + add(sym.name, CompletionItemKind.Variable) + elif sym.kind == "function": + add(sym.name, CompletionItemKind.Function) + + return CompletionList(is_incomplete=False, items=items) diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/definition.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/definition.py new file mode 100644 index 00000000..eb757812 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/definition.py @@ -0,0 +1,157 @@ +"""Go-to-definition support for ECS LSP.""" + +from __future__ import annotations + +import re + +from lsprotocol.types import Location, Position, Range + +from easycon_grammar import ast + + +# Pattern for word extraction, matching the old extension's definition provider +_WORD_PATTERN = re.compile(r"[\$_@]?[\w一-鿿!]+") + + +def get_definition( + program: ast.Program, + source: str, + position: Position, +) -> Location | None: + """Return the definition location for the symbol at the given position.""" + word, word_range = _word_at_position(source, position) + if not word: + return None + + decl_loc = _find_declaration(program, word) + if decl_loc is None: + return None + + return Location( + uri="", # filled by client + range=Range( + start=Position(line=max(0, decl_loc.line - 1), character=max(0, decl_loc.column - 1)), + end=Position(line=max(0, decl_loc.line - 1), character=decl_loc.column + len(_decl_name(program, word))), + ), + ) + + +def _word_at_position(source: str, position: Position) -> tuple[str | None, tuple[int, int] | None]: + """Extract the identifier at the given position from source text.""" + lines = source.splitlines() + if position.line >= len(lines): + return None, None + + line_text = lines[position.line] + col = position.character + + # Find the best match that covers the cursor position + for m in _WORD_PATTERN.finditer(line_text): + if m.start() <= col < m.end(): + return m.group(), (position.line, m.start()) + + return None, None + + +def _find_declaration(program: ast.Program, word: str) -> ast.TextLocation | None: + """Search AST for the declaration of the given symbol name.""" + if word.startswith("$$") or word.startswith("$"): + return _find_var_decl(program, word) + if word.startswith("_"): + return _find_const_decl(program, word) + if word.startswith("@"): + return _find_ext_var_decl(program, word) + # Plain identifier → function declaration or builtin + return _find_func_decl(program, word) + + +def _find_var_decl(program: ast.Program, name: str) -> ast.TextLocation | None: + """Find declaration of a variable ($name or $$name).""" + for stmt in program.statements: + loc = _stmt_var_decl(stmt, name) + if loc is not None: + return loc + return None + + +def _stmt_var_decl(stmt: ast.Stmt, name: str) -> ast.TextLocation | None: + """Recurse into statement to find variable declaration.""" + if isinstance(stmt, ast.AssignmentStmt): + target = stmt.target + if isinstance(target, ast.VariableExpr) and target.name == name: + return target.loc + + if isinstance(stmt, ast.ForStmt): + if stmt.iter_name and f"${stmt.iter_name}" == name: + return stmt.loc + for s in stmt.body: + loc = _stmt_var_decl(s, name) + if loc is not None: + return loc + + if isinstance(stmt, ast.FuncDeclStmt): + if name in stmt.params: + return stmt.loc + for s in stmt.body: + loc = _stmt_var_decl(s, name) + if loc is not None: + return loc + + if isinstance(stmt, (ast.IfStmt, ast.WhileStmt)): + children = _child_stmts(stmt) + for child_list in children: + for s in child_list: + loc = _stmt_var_decl(s, name) + if loc is not None: + return loc + + return None + + +def _find_const_decl(program: ast.Program, name: str) -> ast.TextLocation | None: + """Find declaration of a constant (_name).""" + for stmt in program.statements: + if isinstance(stmt, ast.ConstantDeclStmt) and stmt.name == name: + return stmt.loc + return None + + +def _find_ext_var_decl(program: ast.Program, name: str) -> ast.TextLocation | None: + """External variables (@name) are defined externally, no declaration to find.""" + return None + + +def _find_func_decl(program: ast.Program, name: str) -> ast.TextLocation | None: + """Find declaration of a function (regular or extern).""" + for stmt in program.statements: + if isinstance(stmt, ast.FuncDeclStmt) and stmt.name.upper() == name.upper(): + return stmt.loc + if isinstance(stmt, ast.ExternFuncStmt) and stmt.name.upper() == name.upper(): + return stmt.loc + return None + + +def _decl_name(program: ast.Program, word: str) -> str: + """Return the declared name for the symbol (for display).""" + for stmt in program.statements: + if isinstance(stmt, ast.FuncDeclStmt) and stmt.name.upper() == word.upper(): + return stmt.name + if isinstance(stmt, ast.ExternFuncStmt) and stmt.name.upper() == word.upper(): + return stmt.name + return word + + +def _child_stmts(stmt: ast.Stmt) -> list[list[ast.Stmt]]: + """Return child statement lists for block statements.""" + if isinstance(stmt, ast.IfStmt): + children = [stmt.body] + [b for _, b in stmt.elifs] + if stmt.else_body: + children.append(stmt.else_body) + return children + if isinstance(stmt, ast.ForStmt): + return [stmt.body] + if isinstance(stmt, ast.WhileStmt): + return [stmt.body] + if isinstance(stmt, ast.FuncDeclStmt): + return [stmt.body] + return [] diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/diagnostics.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/diagnostics.py new file mode 100644 index 00000000..f94202d4 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/diagnostics.py @@ -0,0 +1,54 @@ +"""Diagnostics support for ECS LSP.""" + +from __future__ import annotations +import re +from typing import Optional + +from lsprotocol.types import Diagnostic, DiagnosticSeverity, Position, PublishDiagnosticsParams, Range +from pygls.lsp.server import LanguageServer +from pygls.workspace import TextDocument + +from easycon_grammar.compilation import Compilation + +# Match both: +# "(line 5)" -- _build_blocks ParseError +# "at line 5 col 3" -- Lark lexer/parser errors +_ERROR_LINE_RE = re.compile(r"(?:\(line\s+(\d+)\)|at\s+line\s+(\d+))", re.IGNORECASE) + + +def _extract_line(error_msg: str) -> int: + match = _ERROR_LINE_RE.search(error_msg) + if match: + line_str = match.group(1) or match.group(2) + return int(line_str) - 1 # 0-based + return 0 + + +def parse_error_to_diagnostic(error_msg: str, source_lines: Optional[list[str]] = None) -> Diagnostic: + """Convert a Compilation error string to an LSP Diagnostic.""" + line = _extract_line(error_msg) + line_length = len(source_lines[line]) if source_lines and line < len(source_lines) else 0 + end_char = max(line_length, 1) + return Diagnostic( + range=Range( + start=Position(line=line, character=0), + end=Position(line=line, character=end_char), + ), + message=error_msg, + severity=DiagnosticSeverity.Error, + source="ecs-lsp", + ) + + +def publish_diagnostics(ls: LanguageServer, text_document: TextDocument) -> list[str]: + """Parse document and publish diagnostics. Returns any error strings.""" + source = text_document.source + comp = Compilation(source) + result = comp.compile() + + lines = source.splitlines() + diagnostics = [parse_error_to_diagnostic(e, lines) for e in result.errors] + ls.text_document_publish_diagnostics( + PublishDiagnosticsParams(uri=text_document.uri, diagnostics=diagnostics) + ) + return result.errors diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/document_symbols.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/document_symbols.py new file mode 100644 index 00000000..c4bc543d --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/document_symbols.py @@ -0,0 +1,45 @@ +"""Document symbol support for ECS LSP.""" + +from __future__ import annotations + +from lsprotocol.types import DocumentSymbol, SymbolKind, Position, Range + +from easycon_grammar import ast +from easycon_script_lsp.utils.ast_walker import ASTWalker + + +def get_document_symbols(program: ast.Program) -> list[DocumentSymbol]: + """Build a list of DocumentSymbol from the AST.""" + walker = ASTWalker(program) + symbols: list[DocumentSymbol] = [] + + for sym in walker.iter_symbols(): + ds = _to_document_symbol(sym) + if ds is not None: + symbols.append(ds) + + return symbols + + +def _to_document_symbol(sym) -> DocumentSymbol | None: + kind_map = { + "constant": SymbolKind.Constant, + "variable": SymbolKind.Variable, + "function": SymbolKind.Function, + "parameter": SymbolKind.Variable, + } + kind = kind_map.get(sym.kind, SymbolKind.Object) + + start = Position(line=max(0, sym.loc.line - 1), character=max(0, sym.loc.column - 1)) + end = Position(line=start.line, character=start.character + len(sym.name)) + + children = [_to_document_symbol(c) for c in sym.children] + children = [c for c in children if c is not None] + + return DocumentSymbol( + name=sym.name, + kind=kind, + range=Range(start=start, end=end), + selection_range=Range(start=start, end=end), + children=children or None, + ) diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/formatting.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/formatting.py new file mode 100644 index 00000000..cd0cc236 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/formatting.py @@ -0,0 +1,195 @@ +"""Document formatting support for ECS LSP.""" + +from __future__ import annotations + +from lsprotocol.types import TextEdit + +from easycon_grammar import ast + + +def format_document(program: ast.Program) -> str: + """Format entire document, returning the formatted source.""" + lines: list[str] = [] + for stmt in program.statements: + _format_stmt(stmt, 0, lines) + # Strip trailing blank lines, ensure single trailing newline + while lines and lines[-1] == "": + lines.pop() + return "\n".join(lines) + "\n" if lines else "" + + +def _format_stmt(stmt: ast.Stmt, indent: int, lines: list[str]) -> None: + prefix = " " * indent + + if isinstance(stmt, ast.EmptyStmt): + if stmt.comment: + lines.append(f"{prefix}{stmt.comment}") + else: + lines.append("") + elif isinstance(stmt, ast.IfStmt): + cond = _expr_to_string(stmt.condition) + header = _append_comment(f"{prefix}IF {cond}", stmt.comment) + lines.append(header) + for s in stmt.body: + _format_stmt(s, indent + 4, lines) + for elif_cond, elif_body in stmt.elifs: + lines.append(f"{prefix}ELIF {_expr_to_string(elif_cond)}") + for s in elif_body: + _format_stmt(s, indent + 4, lines) + if stmt.else_body: + lines.append(f"{prefix}ELSE") + for s in stmt.else_body: + _format_stmt(s, indent + 4, lines) + lines.append(f"{prefix}ENDIF") + elif isinstance(stmt, ast.ForStmt): + if stmt.infinite: + header = f"{prefix}FOR" + elif stmt.iter_name is not None: + lower = _expr_to_string(stmt.lower) if stmt.lower else "" + upper = _expr_to_string(stmt.upper) + header = f"{prefix}FOR {stmt.iter_name} = {lower} TO {upper}" + if stmt.step is not None: + step = _expr_to_string(stmt.step) + header += f" STEP {step}" + else: + upper = _expr_to_string(stmt.upper) + header = f"{prefix}FOR {upper}" + if stmt.step is not None: + step = _expr_to_string(stmt.step) + header += f" STEP {step}" + header = _append_comment(header, stmt.comment) + lines.append(header) + for s in stmt.body: + _format_stmt(s, indent + 4, lines) + lines.append(f"{prefix}NEXT") + elif isinstance(stmt, ast.WhileStmt): + cond = _expr_to_string(stmt.condition) + header = _append_comment(f"{prefix}WHILE {cond}", stmt.comment) + lines.append(header) + for s in stmt.body: + _format_stmt(s, indent + 4, lines) + lines.append(f"{prefix}END") + elif isinstance(stmt, ast.FuncDeclStmt): + if stmt.params: + params = ", ".join(stmt.params) + header = f"{prefix}FUNC {stmt.name}({params})" + else: + header = f"{prefix}FUNC {stmt.name}" + header = _append_comment(header, stmt.comment) + lines.append(header) + for s in stmt.body: + _format_stmt(s, indent + 4, lines) + lines.append(f"{prefix}ENDFUNC") + elif isinstance(stmt, ast.ExternFuncStmt): + params_str = ", ".join( + f"{p.name}:{p.type}" if p.type else p.name + for p in stmt.params + ) + line = f'{prefix}EXTERN FUNC {stmt.name}({params_str}):{stmt.return_type} FROM "{stmt.library}"' + lines.append(_append_comment(line, stmt.comment)) + elif isinstance(stmt, ast.ImportStmt): + lines.append(_append_comment(f'{prefix}IMPORT "{stmt.module}"', stmt.comment)) + elif isinstance(stmt, ast.ConstantDeclStmt): + val = _expr_to_string(stmt.value) + lines.append(_append_comment(f"{prefix}{stmt.name} = {val}", stmt.comment)) + elif isinstance(stmt, ast.AssignmentStmt): + target = _expr_to_string(stmt.target) + val = _expr_to_string(stmt.value) + lines.append(_append_comment(f"{prefix}{target} {stmt.op} {val}", stmt.comment)) + elif isinstance(stmt, ast.ReturnStmt): + if stmt.value is not None: + lines.append(_append_comment(f"{prefix}RETURN {_expr_to_string(stmt.value)}", stmt.comment)) + else: + lines.append(_append_comment(f"{prefix}RETURN", stmt.comment)) + elif isinstance(stmt, ast.CallStmt): + if stmt.args: + args = " ".join(_expr_to_string(arg) for arg in stmt.args) + lines.append(_append_comment(f"{prefix}{stmt.name} {args}", stmt.comment)) + elif stmt.is_call: + lines.append(_append_comment(f"{prefix}CALL {stmt.name}", stmt.comment)) + else: + lines.append(_append_comment(f"{prefix}{stmt.name}", stmt.comment)) + elif isinstance(stmt, ast.WaitStmt): + dur = _expr_to_string(stmt.duration) + if stmt.omitted: + lines.append(_append_comment(f"{prefix}{dur}", stmt.comment)) + else: + lines.append(_append_comment(f"{prefix}WAIT {dur}", stmt.comment)) + elif isinstance(stmt, ast.KeyPressStmt): + if stmt.duration is not None: + lines.append(_append_comment(f"{prefix}{stmt.key} {_expr_to_string(stmt.duration)}", stmt.comment)) + else: + lines.append(_append_comment(f"{prefix}{stmt.key}", stmt.comment)) + elif isinstance(stmt, ast.KeyActStmt): + action = "UP" if stmt.up else "DOWN" + lines.append(_append_comment(f"{prefix}{stmt.key} {action}", stmt.comment)) + elif isinstance(stmt, ast.StickPressStmt): + if stmt.duration is not None: + dur = _expr_to_string(stmt.duration) + lines.append(_append_comment(f"{prefix}{stmt.key} {stmt.direction},{dur}", stmt.comment)) + else: + lines.append(_append_comment(f"{prefix}{stmt.key} {stmt.direction}", stmt.comment)) + elif isinstance(stmt, ast.StickActStmt): + lines.append(_append_comment(f"{prefix}{stmt.key} {stmt.direction}", stmt.comment)) + elif isinstance(stmt, ast.BreakStmt): + if stmt.level > 1: + lines.append(_append_comment(f"{prefix}BREAK {stmt.level}", stmt.comment)) + else: + lines.append(_append_comment(f"{prefix}BREAK", stmt.comment)) + elif isinstance(stmt, ast.ContinueStmt): + lines.append(_append_comment(f"{prefix}CONTINUE", stmt.comment)) + elif isinstance(stmt, ast.BeepStmt): + freq = _expr_to_string(stmt.frequency) + dur = _expr_to_string(stmt.duration) + lines.append(_append_comment(f"{prefix}BEEP {freq}, {dur}", stmt.comment)) + elif isinstance(stmt, ast.AmiiboStmt): + idx = _expr_to_string(stmt.index) + lines.append(_append_comment(f"{prefix}AMIIBO {idx}", stmt.comment)) + + +def _expr_to_string(expr: ast.Expr) -> str: + if isinstance(expr, ast.LiteralExpr): + v = expr.value + if isinstance(v, bool): + return "TRUE" if v else "FALSE" + if isinstance(v, str): + escaped = v.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + return str(v) + elif isinstance(expr, ast.VariableExpr): + return expr.name + elif isinstance(expr, ast.ConstVarExpr): + return expr.name + elif isinstance(expr, ast.ExtVarExpr): + return f"@{expr.name}" + elif isinstance(expr, ast.BinaryExpr): + left = _expr_to_string(expr.left) + right = _expr_to_string(expr.right) + return f"{left} {expr.op} {right}" + elif isinstance(expr, ast.UnaryExpr): + operand = _expr_to_string(expr.operand) + if expr.op in ("-", "~"): + return f"{expr.op}{operand}" + return f"{expr.op} {operand}" + elif isinstance(expr, ast.ParenExpr): + return f"({_expr_to_string(expr.inner)})" + elif isinstance(expr, ast.IndexDefExpr): + items = ", ".join(_expr_to_string(item) for item in expr.items) + return f"[{items}]" + elif isinstance(expr, ast.IndexExpr): + return f"{expr.name}[{_expr_to_string(expr.index)}]" + elif isinstance(expr, ast.SliceExpr): + start = _expr_to_string(expr.start) if expr.start else "" + end = _expr_to_string(expr.end) + return f"{expr.name}[{start}:{end}]" + elif isinstance(expr, ast.CallExpr): + args = ", ".join(_expr_to_string(arg) for arg in expr.args) + return f"{expr.name}({args})" + else: + return "" + + +def _append_comment(text: str, comment: str) -> str: + if comment: + return f"{text} {comment}" + return text diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/hover.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/hover.py new file mode 100644 index 00000000..a703ebcb --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/hover.py @@ -0,0 +1,372 @@ +"""Hover support for ECS LSP.""" + +from __future__ import annotations + +from lsprotocol.types import Hover, MarkupContent, MarkupKind, Position + +from easycon_grammar import ast +from easycon_script_lsp.constants import ( + ECS_KEYWORDS, + ECS_BUILTIN_FUNCTIONS, + ECS_BUTTON_KEYS, + ECS_STICK_KEYS, + ECS_DIRECTION_KEYS, + ECS_KEY_MODS, + BUILTIN_DOCS, + KEYWORD_DOCS, +) +from easycon_script_lsp.utils.ast_walker import ASTWalker + +_KEY_MOD_LABEL = {"DOWN": "按下", "UP": "松开"} + + +def get_hover( + program: ast.Program | None, + word: str, + position: Position | None = None, +) -> Hover | None: + """Return hover information for the given word. + + When position is provided and program is available, uses AST for + precise identification (e.g. button key vs key_mod vs direction). + """ + + # AST position-based lookup + if program is not None and position is not None: + pos_1based = (position.line + 1, position.character + 1) + result = _hover_from_ast(program, pos_1based) + if result is not None: + return result + + # Fallback: word-based matching + return _hover_from_word(program, word) + + +def _hover_from_ast( + program: ast.Program, pos: tuple[int, int] +) -> Hover | None: + """Return hover by finding the AST node at the given 1-based position.""" + node = _find_node_at(program, pos) + if node is None: + return None + + stmt = node["stmt"] + role = node["role"] + return _hover_for_role(stmt, role) + + +def _find_node_at( + program: ast.Program, pos: tuple[int, int] +) -> dict | None: + """Find the AST statement and role at the given 1-based (line, col).""" + line, col = pos + + def search(stmts: list[ast.Stmt]) -> dict | None: + for stmt in stmts: + result = _match_stmt(stmt, line, col) + if result is not None: + return result + # Recurse into blocks + for child_stmts in _child_stmts(stmt): + result = search(child_stmts) + if result is not None: + return result + return None + + return search(program.statements) + + +def _match_stmt(stmt: ast.Stmt, line: int, col: int) -> dict | None: + """Check if position falls within a statement, returning role info.""" + loc = stmt.loc + if loc.line != line: + return None + + if isinstance(stmt, ast.WaitStmt): + if stmt.omitted: + return {"stmt": stmt, "role": "wait_duration"} + # WAIT keyword occupies columns [loc.col, loc.col + 4) + if col < loc.column + 4: + return {"stmt": stmt, "role": "wait_keyword"} + return {"stmt": stmt, "role": "wait_duration"} + + if isinstance(stmt, ast.KeyPressStmt): + key_end = loc.column + len(stmt.key) + if loc.column <= col < key_end: + return {"stmt": stmt, "role": "key"} + if stmt.duration is not None: + return {"stmt": stmt, "role": "duration"} + return None + + if isinstance(stmt, ast.KeyActStmt): + key_end = loc.column + len(stmt.key) + if loc.column <= col < key_end: + return {"stmt": stmt, "role": "key"} + mod_start = loc.column + len(stmt.key) + 1 + mod_end = mod_start + (3 if stmt.up else 4) + if mod_start <= col < mod_end: + return {"stmt": stmt, "role": "key_mod"} + return None + + if isinstance(stmt, (ast.StickActStmt, ast.StickPressStmt)): + key_end = loc.column + len(stmt.key) + if loc.column <= col < key_end: + return {"stmt": stmt, "role": "stick"} + dir_start = loc.column + len(stmt.key) + 1 + dir_end = dir_start + len(stmt.direction) + if dir_start <= col < dir_end: + return {"stmt": stmt, "role": "direction"} + if isinstance(stmt, ast.StickPressStmt) and stmt.duration is not None: + return {"stmt": stmt, "role": "duration"} + return None + + if isinstance(stmt, ast.ExternFuncStmt): + # EXTERN keyword at column for 6 chars + if col < loc.column + 6: + return {"stmt": stmt, "role": "extern_keyword"} + # FUNC keyword after EXTERN (7 chars from start) + func_col = loc.column + 7 + if func_col <= col < func_col + 4: + return {"stmt": stmt, "role": "func_keyword"} + # Function name after "EXTERN FUNC " (12 chars from start) + name_col = loc.column + 12 + if name_col <= col < name_col + len(stmt.name): + return {"stmt": stmt, "role": "extern_func_name"} + return {"stmt": stmt, "role": "extern_func_decl"} + + return None + + +def _hover_for_role(stmt: ast.Stmt, role: str) -> Hover | None: + if isinstance(stmt, ast.WaitStmt) and role == "wait_keyword": + doc = KEYWORD_DOCS.get("WAIT", "延时等待") + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`WAIT`** — {doc}", + ) + ) + + if isinstance(stmt, ast.WaitStmt) and role == "wait_duration": + ms = _extract_number(stmt.duration) + return _build_wait_hover(ms, stmt.omitted) + + if isinstance(stmt, ast.KeyPressStmt) and role == "key": + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{stmt.key}`** — Nintendo Switch 按键", + ) + ) + if isinstance(stmt, ast.KeyPressStmt) and role == "duration": + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"按键 `{stmt.key}` 的持续时间(毫秒)", + ) + ) + if isinstance(stmt, ast.KeyActStmt) and role == "key": + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{stmt.key}`** — Nintendo Switch 按键", + ) + ) + if isinstance(stmt, ast.KeyActStmt) and role == "key_mod": + action = "UP" if stmt.up else "DOWN" + label = _KEY_MOD_LABEL.get(action, action) + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{action}`** — 按键动作({label})", + ) + ) + if isinstance(stmt, (ast.StickActStmt, ast.StickPressStmt)) and role == "stick": + label = "左摇杆" if stmt.key.upper() == "LS" else "右摇杆" + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{stmt.key}`** — {label}(Left Stick / Right Stick)", + ) + ) + if isinstance(stmt, (ast.StickActStmt, ast.StickPressStmt)) and role == "direction": + if stmt.direction == "RESET": + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`RESET`** — 摇杆复位:释放摇杆,恢复到自然居中状态", + ) + ) + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{stmt.direction}`** — 摇杆方向", + ) + ) + if isinstance(stmt, ast.StickPressStmt) and role == "duration": + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"摇杆 `{stmt.key}` 向 `{stmt.direction}` 的持续时间(毫秒)", + ) + ) + if isinstance(stmt, ast.ExternFuncStmt): + return _build_extern_func_hover(stmt) + return None + + +def _child_stmts(stmt: ast.Stmt) -> list[list[ast.Stmt]]: + """Return child statement lists for block statements.""" + if isinstance(stmt, ast.IfStmt): + children = [stmt.body] + [b for _, b in stmt.elifs] + if stmt.else_body: + children.append(stmt.else_body) + return children + if isinstance(stmt, ast.ForStmt): + return [stmt.body] + if isinstance(stmt, ast.WhileStmt): + return [stmt.body] + if isinstance(stmt, ast.FuncDeclStmt): + return [stmt.body] + return [] + + +def _hover_from_word(program: ast.Program | None, word: str) -> Hover | None: + """Fallback: word-based hover matching.""" + word_upper = word.upper() + + # Keyword — use detailed doc when available + if word_upper in ECS_KEYWORDS: + doc = KEYWORD_DOCS.get(word_upper, "") + if doc: + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{word}`** — ECS 关键字\n\n{doc}", + ) + ) + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{word}`** — ECS 脚本关键字", + ) + ) + + # Built-in function + if word_upper in ECS_BUILTIN_FUNCTIONS: + doc = BUILTIN_DOCS.get(word_upper, "") + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{word}`** — 内置功能\n\n{doc}", + ) + ) + + # Button key + if word_upper in ECS_BUTTON_KEYS: + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{word}`** — Nintendo Switch 按键", + ) + ) + + # Stick key + if word_upper in ECS_STICK_KEYS: + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{word}`** — 摇杆(Left Stick / Right Stick)", + ) + ) + + # Direction key + if word_upper in ECS_DIRECTION_KEYS: + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{word}`** — 摇杆方向", + ) + ) + + # Key mod + if word_upper in ECS_KEY_MODS: + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{word}`** — 按键动作(按下/松开)", + ) + ) + + # Declared symbols + if program is not None: + walker = ASTWalker(program) + for sym in walker.iter_symbols(): + if sym.name == word: + kind_label = _kind_label(sym.kind) + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=f"**`{word}`** — {kind_label}", + ) + ) + + return None + + +def _kind_label(kind: str) -> str: + return { + "constant": "常量", + "variable": "变量", + "function": "函数", + "parameter": "参数", + }.get(kind, kind) + + +def _extract_number(expr: ast.Expr) -> int | None: + """Extract integer value from a simple LiteralExpr, or None.""" + if isinstance(expr, ast.LiteralExpr) and isinstance(expr.value, (int, float)): + return int(expr.value) + return None + + +def _build_extern_func_hover(stmt: ast.ExternFuncStmt) -> Hover: + """Build hover content for an EXTERN FUNC declaration.""" + params_str = ", ".join( + f"{p.name}:{p.type}" if p.type else p.name + for p in stmt.params + ) + signature = f'EXTERN FUNC {stmt.name}({params_str}):{stmt.return_type} FROM "{stmt.library}"' + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=( + f"**外部函数声明 (FFI)**\n\n" + f"```ecs\n{signature}\n```\n\n" + f"- **返回类型:** `{stmt.return_type}`\n" + f"- **来源库:** `{stmt.library}`\n\n" + f"> 调用原生函数存在崩溃风险,请确保参数正确。" + ), + ) + ) + + +def _build_wait_hover(ms: int | None, omitted: bool) -> Hover: + """Build hover for a wait duration.""" + if ms is not None: + s = ms / 1000.0 + value = f"**延时等待** — 脚本在此停顿 **{ms} 毫秒**({s:.1f} 秒)后继续执行下一步" + if omitted: + value += ( + f"\n\n> 💡 提示:单独写数字是 `WAIT {ms}` 的简写形式" + ) + else: + value = "**延时等待** — 脚本在此停顿一段时间后继续执行" + if omitted: + value += "\n\n> 💡 提示:单独写数字是 `WAIT` 的简写形式" + return Hover( + contents=MarkupContent( + kind=MarkupKind.Markdown, + value=value, + ) + ) diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/semantic_tokens.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/semantic_tokens.py new file mode 100644 index 00000000..e7288548 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/features/semantic_tokens.py @@ -0,0 +1,90 @@ +"""Semantic token support for ECS LSP.""" + +from __future__ import annotations + +from lsprotocol.types import SemanticTokens + +from easycon_grammar import ast +from easycon_grammar.parser import Parser +from easycon_script_lsp.utils.token_map import get_semantic_token_type + + +def get_semantic_tokens( + source: str, program: ast.Program | None = None +) -> SemanticTokens: + """Build semantic tokens from source, optionally using AST for type overrides.""" + parser = Parser() + tokens = parser.tokenize(source) + + overrides: dict[tuple[int, int], str] = {} + if program: + overrides = _build_overrides(program) + + data: list[int] = [] + prev_line = 0 + prev_col = 0 + + for tok in tokens: + # Override enum member tokens when the tokenizer can't + # distinguish context (e.g. UP as key vs key_mod vs direction) + pos = (tok["line"], tok["column"]) + tok_type = overrides.get(pos, tok["type"]) + + sem_type = get_semantic_token_type(tok_type) + if sem_type is None: + continue + + line = tok["line"] - 1 # 0-based for LSP + col = tok["column"] - 1 + length = len(tok["value"]) + + # Delta encoding + delta_line = line - prev_line + if delta_line == 0: + delta_col = col - prev_col + else: + delta_col = col + prev_line = line + prev_col = col + + data.extend([delta_line, delta_col, length, sem_type, 0]) + + return SemanticTokens(data=data) + + +def _build_overrides(program: ast.Program) -> dict[tuple[int, int], str]: + """Walk AST and collect positions where token type should be overridden.""" + overrides: dict[tuple[int, int], str] = {} + + def walk(stmts: list[ast.Stmt]) -> None: + for stmt in stmts: + if isinstance(stmt, ast.KeyPressStmt): + # The key part should be BUTTON_KEY (like A/B/X/Y) + overrides[(stmt.loc.line, stmt.loc.column)] = "BUTTON_KEY" + elif isinstance(stmt, ast.KeyActStmt): + # key part → BUTTON_KEY, key_mod part → KEY_MOD + overrides[(stmt.loc.line, stmt.loc.column)] = "BUTTON_KEY" + mod_line = stmt.loc.line + mod_col = stmt.loc.column + len(stmt.key) + 1 + overrides[(mod_line, mod_col)] = "KEY_MOD" + elif isinstance(stmt, (ast.StickActStmt, ast.StickPressStmt)): + # Direction follows key with a space + dir_line = stmt.loc.line + dir_col = stmt.loc.column + len(stmt.key) + 1 + overrides[(dir_line, dir_col)] = "DIRECTION_KEY" + # Recurse into block statements + if isinstance(stmt, ast.IfStmt): + walk(stmt.body) + for _, elif_body in stmt.elifs: + walk(elif_body) + if stmt.else_body: + walk(stmt.else_body) + elif isinstance(stmt, ast.ForStmt): + walk(stmt.body) + elif isinstance(stmt, ast.WhileStmt): + walk(stmt.body) + elif isinstance(stmt, ast.FuncDeclStmt): + walk(stmt.body) + + walk(program.statements) + return overrides diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/server.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/server.py new file mode 100644 index 00000000..08a0d9d8 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/server.py @@ -0,0 +1,223 @@ +"""EasyCon Script Language Server.""" + +from __future__ import annotations +import argparse +import asyncio +import logging +from dataclasses import dataclass, field +from typing import Optional + +from lsprotocol import types +from pygls.lsp.server import LanguageServer + +logger = logging.getLogger("ecs-lsp") +# Suppress harmless pygls warnings (e.g. cancelRequest for already-finished requests) +logging.getLogger("pygls").setLevel(logging.ERROR) + +from easycon_grammar import ast +from easycon_grammar.compilation import Compilation +from easycon_script_lsp.features.completion import get_completions +from easycon_script_lsp.features.diagnostics import parse_error_to_diagnostic +from easycon_script_lsp.features.document_symbols import get_document_symbols +from easycon_script_lsp.features.hover import get_hover +from easycon_script_lsp.features.definition import get_definition +from easycon_script_lsp.features.formatting import format_document + + +@dataclass +class DocumentState: + uri: str + source: str + program: Optional[ast.Program] = None + errors: list[str] = field(default_factory=list) + + +class ECSServer(LanguageServer): + def __init__(self): + super().__init__("ecs-lsp", "0.1.0") + self._documents: dict[str, DocumentState] = {} + self._pending_updates: dict[str, asyncio.TimerHandle] = {} + + def get_document_state(self, uri: str) -> Optional[DocumentState]: + return self._documents.get(uri) + + def set_document_state(self, uri: str, state: DocumentState) -> None: + self._documents[uri] = state + + def remove_document_state(self, uri: str) -> None: + self._documents.pop(uri, None) + + def _cancel_pending_update(self, uri: str) -> None: + handle = self._pending_updates.pop(uri, None) + if handle: + handle.cancel() + + def _update_and_publish(self, uri: str, source: str) -> None: + comp = Compilation(source) + result = comp.compile() + logger.info(f"Compiled {uri}, errors={len(result.errors)}") + state = DocumentState( + uri=uri, + source=source, + program=result.program, + errors=result.errors, + ) + self.set_document_state(uri, state) + lines = source.splitlines() + diagnostics = [parse_error_to_diagnostic(e, lines) for e in result.errors] + logger.info(f"Publishing {len(diagnostics)} diagnostics") + self.text_document_publish_diagnostics( + types.PublishDiagnosticsParams(uri=uri, diagnostics=diagnostics) + ) + + def _debounced_update_and_publish(self, uri: str, source: str, delay: float = 0.3) -> None: + self._cancel_pending_update(uri) + loop = asyncio.get_event_loop() + + def callback(): + self._pending_updates.pop(uri, None) + self._update_and_publish(uri, source) + + self._pending_updates[uri] = loop.call_later(delay, callback) + + +ecs_server = ECSServer() + + +# --------------------------------------------------------------------------- # +# Lifecycle +# --------------------------------------------------------------------------- # + + +@ecs_server.feature(types.INITIALIZE) +def on_initialize(ls: ECSServer, params: types.InitializeParams) -> types.InitializeResult: + return types.InitializeResult( + capabilities=types.ServerCapabilities( + text_document_sync=types.TextDocumentSyncOptions( + open_close=True, + change=types.TextDocumentSyncKind.Full, + ), + document_symbol_provider=True, + completion_provider=types.CompletionOptions( + trigger_characters=["$", "_", "@", "("], + ), + hover_provider=True, + definition_provider=True, + document_formatting_provider=True, + ) + ) + + +# --------------------------------------------------------------------------- # +# Document sync +# --------------------------------------------------------------------------- # + + +@ecs_server.feature(types.TEXT_DOCUMENT_DID_OPEN) +def on_did_open(ls: ECSServer, params: types.DidOpenTextDocumentParams) -> None: + doc = params.text_document + ls._update_and_publish(doc.uri, doc.text) + + +@ecs_server.feature(types.TEXT_DOCUMENT_DID_CHANGE) +def on_did_change(ls: ECSServer, params: types.DidChangeTextDocumentParams) -> None: + doc = ls.workspace.get_text_document(params.text_document.uri) + ls._debounced_update_and_publish(doc.uri, doc.source) + + +@ecs_server.feature(types.TEXT_DOCUMENT_DID_CLOSE) +def on_did_close(ls: ECSServer, params: types.DidCloseTextDocumentParams) -> None: + ls._cancel_pending_update(params.text_document.uri) + ls.remove_document_state(params.text_document.uri) + ls.text_document_publish_diagnostics( + types.PublishDiagnosticsParams(uri=params.text_document.uri, diagnostics=[]) + ) + + +# --------------------------------------------------------------------------- # +# Features +# --------------------------------------------------------------------------- # + + +@ecs_server.feature(types.TEXT_DOCUMENT_DOCUMENT_SYMBOL) +def on_document_symbol( + ls: ECSServer, params: types.DocumentSymbolParams +) -> list[types.DocumentSymbol] | None: + state = ls.get_document_state(params.text_document.uri) + if not state or not state.program: + return [] + return get_document_symbols(state.program) + + +@ecs_server.feature( + types.TEXT_DOCUMENT_COMPLETION, + types.CompletionOptions(trigger_characters=["$", "_", "@", "("]), +) +def on_completion( + ls: ECSServer, params: types.CompletionParams +) -> types.CompletionList | None: + state = ls.get_document_state(params.text_document.uri) + program = state.program if state else None + return get_completions(program) + + +@ecs_server.feature(types.TEXT_DOCUMENT_HOVER) +def on_hover(ls: ECSServer, params: types.HoverParams) -> types.Hover | None: + doc = ls.workspace.get_text_document(params.text_document.uri) + word = doc.word_at_position(params.position) + if not word: + return None + state = ls.get_document_state(params.text_document.uri) + return get_hover(state.program if state else None, word, params.position) + + +@ecs_server.feature(types.TEXT_DOCUMENT_DEFINITION) +def on_definition( + ls: ECSServer, params: types.DefinitionParams +) -> types.Location | None: + state = ls.get_document_state(params.text_document.uri) + if not state or not state.program: + return None + return get_definition(state.program, state.source, params.position) + + +@ecs_server.feature(types.TEXT_DOCUMENT_FORMATTING) +def on_formatting( + ls: ECSServer, params: types.DocumentFormattingParams +) -> list[types.TextEdit] | None: + state = ls.get_document_state(params.text_document.uri) + if not state or not state.program: + return None + doc = ls.workspace.get_text_document(params.text_document.uri) + source_lines = doc.source.splitlines() + last_line = max(0, len(source_lines) - 1) + last_char = len(source_lines[last_line]) if source_lines else 0 + formatted = format_document(state.program) + return [ + types.TextEdit( + range=types.Range( + start=types.Position(line=0, character=0), + end=types.Position(line=last_line, character=last_char), + ), + new_text=formatted, + ) + ] + + +# --------------------------------------------------------------------------- # +# CLI entry point +# --------------------------------------------------------------------------- # + + +def main() -> None: + parser = argparse.ArgumentParser(description="EasyCon Script Language Server") + parser.add_argument("--stdio", action="store_true", help="Use stdio (default)") + parser.add_argument("--tcp", action="store_true", help="Use TCP instead of stdio") + parser.add_argument("--host", default="127.0.0.1", help="TCP host") + parser.add_argument("--port", type=int, default=2087, help="TCP port") + args = parser.parse_args() + + if args.tcp: + ecs_server.start_tcp(args.host, args.port) + else: + ecs_server.start_io() diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/utils/__init__.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/utils/__init__.py new file mode 100644 index 00000000..236f8deb --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/utils/__init__.py @@ -0,0 +1 @@ +"""LSP utilities.""" diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/utils/ast_walker.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/utils/ast_walker.py new file mode 100644 index 00000000..ae92b7a1 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/utils/ast_walker.py @@ -0,0 +1,136 @@ +"""AST traversal utilities for the ECS LSP.""" + +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Iterator, Optional, Set + +from easycon_grammar import ast + + +@dataclass +class SymbolInfo: + name: str + kind: str # "constant", "variable", "function", "parameter" + loc: ast.TextLocation + children: list[SymbolInfo] = field(default_factory=list) + + +@dataclass +class ScopeInfo: + variables: Set[str] = field(default_factory=set) + functions: Set[str] = field(default_factory=set) + constants: Set[str] = field(default_factory=set) + + +class ASTWalker: + """Walk ECS AST to extract symbols and scope information.""" + + def __init__(self, program: ast.Program): + self.program = program + + # ------------------------------------------------------------------ # + # Document symbols + # ------------------------------------------------------------------ # + def iter_symbols(self) -> Iterator[SymbolInfo]: + """Yield all top-level symbols.""" + for stmt in self.program.statements: + sym = self._stmt_to_symbol(stmt) + if sym is not None: + yield sym + + def _stmt_to_symbol(self, stmt: ast.Stmt) -> SymbolInfo | None: + if isinstance(stmt, ast.ConstantDeclStmt): + return SymbolInfo(name=stmt.name, kind="constant", loc=stmt.loc) + + if isinstance(stmt, ast.AssignmentStmt): + target = stmt.target + if isinstance(target, ast.VariableExpr) and not target.read_only: + return SymbolInfo(name=target.name, kind="variable", loc=target.loc) + + if isinstance(stmt, ast.FuncDeclStmt): + children = [ + SymbolInfo(name=p, kind="parameter", loc=stmt.loc) + for p in stmt.params + ] + return SymbolInfo( + name=stmt.name, kind="function", loc=stmt.loc, children=children + ) + + if isinstance(stmt, ast.ExternFuncStmt): + children = [ + SymbolInfo(name=p.name, kind="parameter", loc=stmt.loc) + for p in stmt.params + ] + return SymbolInfo( + name=stmt.name, kind="function", loc=stmt.loc, children=children + ) + + return None + + # ------------------------------------------------------------------ # + # Scope at position (for completion) + # ------------------------------------------------------------------ # + def get_scope_at_position(self, line: int, column: int) -> ScopeInfo: + """Return visible names at the given 0-based position.""" + scope = ScopeInfo() + self._collect_top_level_scope(scope) + self._collect_local_scope(self.program.statements, line, column, scope) + return scope + + def _collect_top_level_scope(self, scope: ScopeInfo) -> None: + for stmt in self.program.statements: + if isinstance(stmt, ast.ConstantDeclStmt): + scope.constants.add(stmt.name) + elif isinstance(stmt, ast.AssignmentStmt): + target = stmt.target + if isinstance(target, ast.VariableExpr) and not target.read_only: + scope.variables.add(target.name) + elif isinstance(stmt, ast.FuncDeclStmt): + scope.functions.add(stmt.name) + elif isinstance(stmt, ast.ExternFuncStmt): + scope.functions.add(stmt.name) + + def _collect_local_scope( + self, + statements: list[ast.Stmt], + line: int, + column: int, + scope: ScopeInfo, + ) -> bool: + """Recursively collect local variables visible at (line, column). + + Returns True if the position was found inside this block. + """ + for stmt in statements: + if isinstance(stmt, ast.FuncDeclStmt): + if self._contains(stmt.loc, line, column, statements): + for p in stmt.params: + scope.variables.add(p) + if self._collect_local_scope(stmt.body, line, column, scope): + return True + + elif isinstance(stmt, (ast.IfStmt, ast.ForStmt, ast.WhileStmt)): + body = [] + if isinstance(stmt, ast.IfStmt): + body = stmt.body + for _, elif_body in stmt.elifs: + body.extend(elif_body) + body.extend(stmt.else_body) + elif isinstance(stmt, ast.ForStmt): + body = stmt.body + if stmt.iter_name: + scope.variables.add(stmt.iter_name) + elif isinstance(stmt, ast.WhileStmt): + body = stmt.body + + if self._collect_local_scope(body, line, column, scope): + return True + + return False + + @staticmethod + def _contains( + loc: ast.TextLocation, line: int, column: int, block: list[ast.Stmt] + ) -> bool: + """Rough check: position is at or after the statement's start.""" + return loc.line <= line + 1 diff --git a/vscode-plugin/ecs-language-server/src/easycon_script_lsp/utils/token_map.py b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/utils/token_map.py new file mode 100644 index 00000000..02c69875 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/easycon_script_lsp/utils/token_map.py @@ -0,0 +1,85 @@ +"""Map ECS tokenizer output to LSP semantic token types.""" + +from lsprotocol.types import SemanticTokenTypes + +# Ordered list of token types for the legend +TOKEN_TYPES = [ + SemanticTokenTypes.Keyword, + SemanticTokenTypes.Function, + SemanticTokenTypes.Variable, + SemanticTokenTypes.Number, + SemanticTokenTypes.String, + SemanticTokenTypes.Comment, + SemanticTokenTypes.EnumMember, + SemanticTokenTypes.Operator, + SemanticTokenTypes.Macro, + SemanticTokenTypes.Type, +] + +TOKEN_TYPE_MAP = { + "IMPORT_KW": SemanticTokenTypes.Keyword, + "IF_KW": SemanticTokenTypes.Keyword, + "ELIF_KW": SemanticTokenTypes.Keyword, + "ELSE_KW": SemanticTokenTypes.Keyword, + "ENDIF_KW": SemanticTokenTypes.Keyword, + "WHILE_KW": SemanticTokenTypes.Keyword, + "FOR_KW": SemanticTokenTypes.Keyword, + "TO_KW": SemanticTokenTypes.Keyword, + "IN_KW": SemanticTokenTypes.Keyword, + "STEP_KW": SemanticTokenTypes.Keyword, + "BREAK_KW": SemanticTokenTypes.Keyword, + "CONTINUE_KW": SemanticTokenTypes.Keyword, + "NEXT_KW": SemanticTokenTypes.Keyword, + "FUNC_KW": SemanticTokenTypes.Keyword, + "RETURN_KW": SemanticTokenTypes.Keyword, + "ENDFUNC_KW": SemanticTokenTypes.Keyword, + "END_KW": SemanticTokenTypes.Keyword, + "TRUE_KW": SemanticTokenTypes.Keyword, + "FALSE_KW": SemanticTokenTypes.Keyword, + "RESET_KW": SemanticTokenTypes.Keyword, + "WAIT_KW": SemanticTokenTypes.Keyword, + "CALL_KW": SemanticTokenTypes.Keyword, + "EXTERN_KW": SemanticTokenTypes.Keyword, + "FROM_KW": SemanticTokenTypes.Keyword, + "KEY_MOD": SemanticTokenTypes.Keyword, + "IDENT": SemanticTokenTypes.Macro, + "VAR": SemanticTokenTypes.Variable, + "CONST": SemanticTokenTypes.Variable, + "EX_VAR": SemanticTokenTypes.Variable, + "INT": SemanticTokenTypes.Number, + "NUMBER": SemanticTokenTypes.Number, + "STRING": SemanticTokenTypes.String, + "COMMENT": SemanticTokenTypes.Comment, + "BUTTON_KEY": SemanticTokenTypes.Function, + "STICK_KEY": SemanticTokenTypes.Type, + "DIRECTION_KEY": SemanticTokenTypes.EnumMember, + "LOGIC_OP": SemanticTokenTypes.Operator, + "OP": SemanticTokenTypes.Operator, + "ASSIGN_OP": SemanticTokenTypes.Operator, + "PAREN": SemanticTokenTypes.Operator, + "BRACKET": SemanticTokenTypes.Operator, + "COMMA": SemanticTokenTypes.Operator, + "COLON": SemanticTokenTypes.Operator, +} + +# Map token type string to index in legend + + +def _build_type_index() -> dict[str, int]: + return {t: i for i, t in enumerate(TOKEN_TYPES)} + + +_TYPE_INDEX = _build_type_index() + + +def get_semantic_token_type(token_name: str) -> int | None: + """Return LSP semantic token type index, or None to skip.""" + sem_type = TOKEN_TYPE_MAP.get(token_name) + if sem_type is None: + return None + return _TYPE_INDEX.get(sem_type) + + +def build_token_legend() -> tuple[list[str], list[str]]: + """Return (token_types, token_modifiers) for server capabilities.""" + return [t.value for t in TOKEN_TYPES], [] diff --git a/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/PKG-INFO b/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/PKG-INFO new file mode 100644 index 00000000..05e9becb --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/PKG-INFO @@ -0,0 +1,23 @@ +Metadata-Version: 2.4 +Name: ecs-language-server +Version: 0.1.0 +Summary: Language Server for EasyCon Script (.ecs) — parser, AST, and LSP server +Author: EasyCon Team +License: MIT +Keywords: lsp,language-server,easycon,ecs,parser +Classifier: Development Status :: 3 - Alpha +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +Requires-Dist: lark>=1.1.0 +Requires-Dist: pygls>=2.1.0 +Requires-Dist: lsprotocol>=2025.0.0 +Provides-Extra: dev +Requires-Dist: pytest>=7.0; extra == "dev" diff --git a/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/SOURCES.txt b/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/SOURCES.txt new file mode 100644 index 00000000..9ac292e3 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/SOURCES.txt @@ -0,0 +1,40 @@ +pyproject.toml +src/easycon_grammar/__init__.py +src/easycon_grammar/ast.py +src/easycon_grammar/compilation.py +src/easycon_grammar/parser.py +src/easycon_grammar/grammar/ecp_grammar.lark +src/easycon_script_lsp/__init__.py +src/easycon_script_lsp/__main__.py +src/easycon_script_lsp/constants.py +src/easycon_script_lsp/server.py +src/easycon_script_lsp/features/__init__.py +src/easycon_script_lsp/features/completion.py +src/easycon_script_lsp/features/definition.py +src/easycon_script_lsp/features/diagnostics.py +src/easycon_script_lsp/features/document_symbols.py +src/easycon_script_lsp/features/formatting.py +src/easycon_script_lsp/features/hover.py +src/easycon_script_lsp/features/semantic_tokens.py +src/easycon_script_lsp/utils/__init__.py +src/easycon_script_lsp/utils/ast_walker.py +src/easycon_script_lsp/utils/token_map.py +src/ecs_language_server.egg-info/PKG-INFO +src/ecs_language_server.egg-info/SOURCES.txt +src/ecs_language_server.egg-info/dependency_links.txt +src/ecs_language_server.egg-info/entry_points.txt +src/ecs_language_server.egg-info/requires.txt +src/ecs_language_server.egg-info/top_level.txt +tests/test_compilation.py +tests/test_integration.py +tests/test_lsp_completions.py +tests/test_lsp_definition.py +tests/test_lsp_diagnostics.py +tests/test_lsp_document_symbols.py +tests/test_lsp_formatting.py +tests/test_lsp_hover.py +tests/test_lsp_semantic_tokens.py +tests/test_parser_errors.py +tests/test_parser_expressions.py +tests/test_parser_statements.py +tests/test_tokenization.py \ No newline at end of file diff --git a/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/dependency_links.txt b/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/dependency_links.txt new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/entry_points.txt b/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/entry_points.txt new file mode 100644 index 00000000..7f6dc349 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +ecs-lsp = easycon_script_lsp.server:main diff --git a/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/requires.txt b/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/requires.txt new file mode 100644 index 00000000..8e630559 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/requires.txt @@ -0,0 +1,6 @@ +lark>=1.1.0 +pygls>=2.1.0 +lsprotocol>=2025.0.0 + +[dev] +pytest>=7.0 diff --git a/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/top_level.txt b/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/top_level.txt new file mode 100644 index 00000000..b530b3f4 --- /dev/null +++ b/vscode-plugin/ecs-language-server/src/ecs_language_server.egg-info/top_level.txt @@ -0,0 +1,2 @@ +easycon_grammar +easycon_script_lsp diff --git a/vscode-plugin/ecs-language-server/tests/__init__.py b/vscode-plugin/ecs-language-server/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vscode-plugin/ecs-language-server/tests/conftest.py b/vscode-plugin/ecs-language-server/tests/conftest.py new file mode 100644 index 00000000..285c0294 --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/conftest.py @@ -0,0 +1,32 @@ +"""Shared fixtures and helpers for ECS language server tests.""" + +from __future__ import annotations +import pytest +from easycon_grammar.parser import Parser +from easycon_grammar.compilation import Compilation + + +@pytest.fixture(scope="function") +def parser() -> Parser: + return Parser() + + +@pytest.fixture(scope="function") +def parse(parser: Parser): + def _parse(source: str): + return parser.parse(source) + return _parse + + +@pytest.fixture(scope="function") +def tokenize(parser: Parser): + def _tokenize(source: str): + return parser.tokenize(source) + return _tokenize + + +@pytest.fixture(scope="function") +def compile_source(): + def _compile(source: str): + return Compilation(source).compile() + return _compile diff --git a/vscode-plugin/ecs-language-server/tests/test_compilation.py b/vscode-plugin/ecs-language-server/tests/test_compilation.py new file mode 100644 index 00000000..01336aea --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_compilation.py @@ -0,0 +1,53 @@ +"""Compilation tests — Compilation.compile() pipeline.""" + +from __future__ import annotations +from easycon_grammar import ast + + +def test_compile_valid_program(compile_source): + result = compile_source("$a = 1\nA\n") + assert result.has_errors is False + assert result.program is not None + assert isinstance(result.program, ast.Program) + assert len(result.program.statements) == 2 + + +def test_compile_with_import(compile_source): + result = compile_source('IMPORT "lib.ecs"\n$b = 2\n') + assert result.has_errors is False + assert isinstance(result.program.statements[0], ast.ImportStmt) + + +def test_compile_parse_error(compile_source): + result = compile_source("$a = =\n") + assert result.has_errors is True + assert result.program is None + assert len(result.errors) >= 1 + + +def test_compile_unknown_button(compile_source): + """Semantic validation catches unknown button names.""" + result = compile_source("Z 100\n") + assert result.has_errors is True + + +def test_compile_unknown_func(compile_source): + """Semantic validation catches unknown function calls.""" + result = compile_source("NONEXISTENT()\n") + assert result.has_errors is True + + +def test_compile_complex_valid(compile_source): + """A non-trivial valid program compiles without errors.""" + source = ( + "FUNC add($a, $b):INT\n" + "RETURN $a + $b\n" + "ENDFUNC\n" + "$result = add(3, 4)\n" + "IF $result > 0\n" + "PRINT result is positive\n" + "ENDIF\n" + ) + result = compile_source(source) + assert result.has_errors is False + assert result.program is not None diff --git a/vscode-plugin/ecs-language-server/tests/test_integration.py b/vscode-plugin/ecs-language-server/tests/test_integration.py new file mode 100644 index 00000000..3effa657 --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_integration.py @@ -0,0 +1,260 @@ +"""Integration tests — full multi-feature examples from docs/Script.md.""" + +from __future__ import annotations +from easycon_grammar import ast +from easycon_script_lsp.features.formatting import format_document + + +def _strip_trailing_newline(s: str) -> str: + if s.endswith("\n"): + return s[:-1] + return s + + +# --------------------------------------------------------------------------- +# 安全等待函数 (Script.md lines 560-564) +# --------------------------------------------------------------------------- + +def test_safe_wait(parse): + source = ( + "FUNC safe_wait($ms:INT)\n" + "IF $ms > 0\n" + "WAIT $ms\n" + "ENDIF\n" + "ENDFUNC\n" + ) + prog = parse(source) + assert len(prog.statements) == 1 + func = prog.statements[0] + assert isinstance(func, ast.FuncDeclStmt) + assert func.name == "safe_wait" + assert len(func.body) == 1 + if_stmt = func.body[0] + assert isinstance(if_stmt, ast.IfStmt) + + +# --------------------------------------------------------------------------- +# 最大值函数 (Script.md lines 567-574) +# --------------------------------------------------------------------------- + +def test_max_function(parse): + source = ( + "FUNC max($a:INT, $b:INT):INT\n" + "IF $a > $b\n" + "RETURN $a\n" + "ELSE\n" + "RETURN $b\n" + "ENDIF\n" + "ENDFUNC\n" + ) + prog = parse(source) + func = prog.statements[0] + assert isinstance(func, ast.FuncDeclStmt) + assert func.name == "max" + if_stmt = func.body[0] + assert isinstance(if_stmt, ast.IfStmt) + assert len(if_stmt.else_body) == 1 + assert isinstance(if_stmt.else_body[0], ast.ReturnStmt) + + +# --------------------------------------------------------------------------- +# 等待图像出现 (Script.md lines 605-618) +# --------------------------------------------------------------------------- + +def test_wait_for_image(parse): + source = ( + "$max_attempts = 30\n" + "FOR $i = 1 TO $max_attempts\n" + "$match = @dialog_confirm\n" + "IF $match > 90\n" + 'PRINT dialog appeared\n' + "BREAK\n" + "ENDIF\n" + "WAIT 1000\n" + "NEXT\n" + ) + prog = parse(source) + assert len(prog.statements) == 2 + for_stmt = prog.statements[1] + assert isinstance(for_stmt, ast.ForStmt) + # Body: assignment, if_block, wait — 3 items (IF body is inside the IfStmt) + assert len(for_stmt.body) == 3 + + +# --------------------------------------------------------------------------- +# 循环检测闪光 (Script.md lines 625-637) +# --------------------------------------------------------------------------- + +def test_shiny_check_loop(parse): + source = ( + "FOR 1000\n" + "$shiny = @shiny_feature\n" + "IF $shiny > 95\n" + 'ALERT found shiny\n' + 'PRINT shiny confidence: & $shiny\n' + "BREAK\n" + "ENDIF\n" + "A\n" + "WAIT 2000\n" + "NEXT\n" + ) + prog = parse(source) + for_stmt = prog.statements[0] + assert isinstance(for_stmt, ast.ForStmt) + # Body[0] is the shiny assignment, Body[1] is the IF block + assert isinstance(for_stmt.body[1], ast.IfStmt) + if_stmt = for_stmt.body[1] + # IF body: ALERT, PRINT, BREAK — break is at index 2 + assert isinstance(if_stmt.body[2], ast.BreakStmt) + + +# --------------------------------------------------------------------------- +# 综合应用 自动刷闪光 (Script.md lines 844-870) +# --------------------------------------------------------------------------- + +def test_full_shiny_script(parse): + source = ( + "FUNC check_shiny\n" + "$match = @shiny_feature\n" + "IF $match > 95\n" + "RETURN TRUE\n" + "ELSE\n" + "RETURN FALSE\n" + "ENDIF\n" + "ENDFUNC\n" + "FOR 100\n" + "A\n" + "WAIT 2000\n" + "IF check_shiny()\n" + 'ALERT found shiny\n' + "BREAK\n" + "ENDIF\n" + "HOME\n" + "WAIT 1000\n" + "NEXT\n" + ) + prog = parse(source) + func = prog.statements[0] + assert isinstance(func, ast.FuncDeclStmt) + assert func.name == "check_shiny" + for_stmt = prog.statements[1] + assert isinstance(for_stmt, ast.ForStmt) + + +# --------------------------------------------------------------------------- +# 多阶段识别 (Script.md lines 642-657) +# --------------------------------------------------------------------------- + +def test_multi_stage_detection(parse): + source = ( + "IF @rough_feature > 75\n" + 'PRINT found area\n' + "IF @precise_feature > 90\n" + 'PRINT confirmed\n' + "A\n" + "WAIT 100\n" + "ELSE\n" + 'PRINT not matching\n' + "ENDIF\n" + "ELSE\n" + 'PRINT not found\n' + "ENDIF\n" + ) + prog = parse(source) + outer = prog.statements[0] + assert isinstance(outer, ast.IfStmt) + assert len(outer.else_body) == 1 + + +# --------------------------------------------------------------------------- +# FFI 声明 (Script.md lines 774-786) +# --------------------------------------------------------------------------- + +def test_ffi_declarations(parse): + source = ( + 'EXTERN FUNC Sleep($ms:INT):VOID FROM "kernel32.dll"\n' + 'EXTERN FUNC GetForegroundWindow():PTR FROM "user32.dll"\n' + 'EXTERN FUNC MessageBoxW($hwnd:PTR, $text:STRING, $caption:STRING, $flags:INT):INT FROM "user32.dll"\n' + 'EXTERN FUNC sqrt($x:DOUBLE):DOUBLE FROM "msvcrt.dll"\n' + ) + prog = parse(source) + assert len(prog.statements) == 4 + assert all(isinstance(s, ast.ExternFuncStmt) for s in prog.statements) + assert prog.statements[0].name == "Sleep" + assert prog.statements[1].name == "GetForegroundWindow" + assert prog.statements[2].name == "MessageBoxW" + assert prog.statements[3].name == "sqrt" + + +# --------------------------------------------------------------------------- +# WHILE loop +# --------------------------------------------------------------------------- + +def test_while_loop(parse): + source = ( + "WHILE $a > 0\n" + "PRINT counting\n" + "$a -= 1\n" + "END\n" + ) + prog = parse(source) + while_stmt = prog.statements[0] + assert isinstance(while_stmt, ast.WhileStmt) + assert len(while_stmt.body) == 2 + + +# --------------------------------------------------------------------------- +# Format round-trip for a complex script +# --------------------------------------------------------------------------- + +def test_complex_format_roundtrip(parse): + """Full round-trip test for the shiny hunting script.""" + source = ( + "FUNC check_shiny\n" + "$match = @shiny_feature\n" + "IF $match > 95\n" + "RETURN TRUE\n" + "ELSE\n" + "RETURN FALSE\n" + "ENDIF\n" + "ENDFUNC\n" + "FOR 100\n" + "A\n" + "WAIT 2000\n" + "IF check_shiny()\n" + 'ALERT found shiny\n' + "BREAK\n" + "ENDIF\n" + "HOME\n" + "WAIT 1000\n" + "NEXT\n" + ) + prog1 = parse(source) + formatted = format_document(prog1) + prog2 = parse(formatted) + assert len(prog1.statements) == len(prog2.statements) + assert prog2.statements[0].name == "check_shiny" + + +# --------------------------------------------------------------------------- +# Nested loops with BREAK level (Script.md lines 475-482) +# --------------------------------------------------------------------------- + +def test_nested_break_level(parse): + source = ( + "FOR $i = 1 TO 10\n" + "FOR $j = 1 TO 10\n" + "IF $emergency\n" + "BREAK 2\n" + "ENDIF\n" + "NEXT\n" + "NEXT\n" + ) + prog = parse(source) + outer = prog.statements[0] + assert isinstance(outer, ast.ForStmt) + inner = outer.body[0] + assert isinstance(inner, ast.ForStmt) + break_stmt = inner.body[0].body[0] + assert isinstance(break_stmt, ast.BreakStmt) + assert break_stmt.level == 2 diff --git a/vscode-plugin/ecs-language-server/tests/test_lsp_completions.py b/vscode-plugin/ecs-language-server/tests/test_lsp_completions.py new file mode 100644 index 00000000..3faf0d05 --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_lsp_completions.py @@ -0,0 +1,62 @@ +"""LSP completion tests.""" + +from __future__ import annotations +from lsprotocol.types import CompletionItemKind +from easycon_script_lsp.features.completion import get_completions +from easycon_script_lsp.constants import ECS_KEYWORDS, ECS_BUILTIN_FUNCTIONS, ECS_FFI_TYPES + + +def test_all_keywords_present(): + items = get_completions(None).items + labels = {item.label for item in items} + for kw in ECS_KEYWORDS: + assert kw in labels, f"Keyword {kw} missing from completions" + + +def test_all_builtins_present(): + items = get_completions(None).items + for item in items: + if item.label in ECS_BUILTIN_FUNCTIONS and item.kind == CompletionItemKind.Function: + assert item.detail == "内置函数" + + +def test_ffi_types_present(): + items = get_completions(None).items + labels = {item.label for item in items} + for ffi in ECS_FFI_TYPES: + assert ffi in labels, f"FFI type {ffi} missing from completions" + + +def test_variables_from_ast(parse): + prog = parse("$count = 0\n$a = 1\n") + items = get_completions(prog).items + var_labels = {item.label for item in items if item.kind == CompletionItemKind.Variable} + assert "$count" in var_labels + assert "$a" in var_labels + + +def test_constants_from_ast(parse): + prog = parse("_MAX = 100\n") + items = get_completions(prog).items + const_labels = {item.label for item in items if item.kind == CompletionItemKind.Constant} + assert "_MAX" in const_labels + + +def test_functions_from_ast(parse): + prog = parse("FUNC foo\nA\nENDFUNC\n") + items = get_completions(prog).items + func_labels = {item.label for item in items if item.kind == CompletionItemKind.Function} + assert "foo" in func_labels + + +def test_no_duplicates(): + items = get_completions(None).items + labels = [item.label for item in items] + assert len(labels) == len(set(labels)) + + +def test_empty_program(): + items = get_completions(None).items + # Should still return keywords and builtins + assert len(items) > 0 + assert any(item.label == "IF" for item in items) diff --git a/vscode-plugin/ecs-language-server/tests/test_lsp_definition.py b/vscode-plugin/ecs-language-server/tests/test_lsp_definition.py new file mode 100644 index 00000000..1a03b5da --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_lsp_definition.py @@ -0,0 +1,56 @@ +"""LSP go-to-definition tests.""" + +from __future__ import annotations +from lsprotocol.types import Position +from easycon_script_lsp.features.definition import get_definition + + +def test_def_variable(parse): + prog = parse("$count = 0\nPRINT $count\n") + source = "$count = 0\nPRINT $count\n" + loc = get_definition(prog, source, Position(line=1, character=8)) + assert loc is not None + assert loc.range.start.line == 0 # declaration on line 0 + + +def test_def_constant(parse): + prog = parse("_MAX = 100\nPRINT _MAX\n") + source = "_MAX = 100\nPRINT _MAX\n" + loc = get_definition(prog, source, Position(line=1, character=8)) + assert loc is not None + assert loc.range.start.line == 0 + + +def test_def_function(parse): + prog = parse("FUNC greet\nPRINT hi\nENDFUNC\ngreet\n") + source = "FUNC greet\nPRINT hi\nENDFUNC\ngreet\n" + loc = get_definition(prog, source, Position(line=3, character=1)) + assert loc is not None + assert loc.range.start.line == 0 + + +def test_def_builtin(parse): + """Built-in functions have no declaration location.""" + prog = parse("WAIT 100\n") + source = "WAIT 100\n" + loc = get_definition(prog, source, Position(line=0, character=1)) + assert loc is None + + +def test_def_image_var(parse): + """External variables (@image) have no declaration in source.""" + prog = parse("$x = @target\n") + source = "$x = @target\n" + loc = get_definition(prog, source, Position(line=0, character=7)) + assert loc is None + + +def test_def_whitespace(parse): + """Cursor at a position without a word (e.g. on whitespace between tokens).""" + prog = parse("$a = 1\n") + source = "$a = 1\n" + # Position at column 2 is the space between "=" and "1" + loc = get_definition(prog, source, Position(line=0, character=2)) + # Space may or may not resolve to a word depending on regex behavior + # The important thing is that the function doesn't crash + assert loc is None or isinstance(loc, object) diff --git a/vscode-plugin/ecs-language-server/tests/test_lsp_diagnostics.py b/vscode-plugin/ecs-language-server/tests/test_lsp_diagnostics.py new file mode 100644 index 00000000..61f99f7d --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_lsp_diagnostics.py @@ -0,0 +1,35 @@ +"""LSP diagnostics tests.""" + +from __future__ import annotations +from lsprotocol.types import DiagnosticSeverity +from easycon_script_lsp.features.diagnostics import parse_error_to_diagnostic + + +def test_extract_line_from_blocks_format(): + diag = parse_error_to_diagnostic("语句块没有正确结束 (line 5)") + assert diag.range.start.line == 4 # 0-based + assert diag.severity == DiagnosticSeverity.Error + assert diag.source == "ecs-lsp" + + +def test_extract_line_from_lark_format(): + diag = parse_error_to_diagnostic("Unexpected token at line 10 col 3") + assert diag.range.start.line == 9 # 0-based + + +def test_diagnostic_message_preserved(): + msg = "导入只能在脚本开头 (line 3)" + diag = parse_error_to_diagnostic(msg) + assert diag.message == msg + + +def test_diagnostic_with_source_lines(): + diag = parse_error_to_diagnostic("error (line 1)", source_lines=["A 100", "B 200"]) + assert diag.range.start.line == 0 + assert diag.range.start.character == 0 + assert diag.range.end.character > 0 + + +def test_diagnostic_no_line_info(): + diag = parse_error_to_diagnostic("Some generic error") + assert diag.range.start.line == 0 diff --git a/vscode-plugin/ecs-language-server/tests/test_lsp_document_symbols.py b/vscode-plugin/ecs-language-server/tests/test_lsp_document_symbols.py new file mode 100644 index 00000000..0df748f7 --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_lsp_document_symbols.py @@ -0,0 +1,60 @@ +"""LSP document symbols tests.""" + +from __future__ import annotations +from lsprotocol.types import SymbolKind +from easycon_script_lsp.features.document_symbols import get_document_symbols + + +def test_symbol_constant(parse): + prog = parse("_MAX = 100\n") + symbols = get_document_symbols(prog) + const_symbols = [s for s in symbols if s.kind == SymbolKind.Constant] + assert len(const_symbols) == 1 + assert const_symbols[0].name == "_MAX" + + +def test_symbol_variable(parse): + prog = parse("$count = 0\n") + symbols = get_document_symbols(prog) + var_symbols = [s for s in symbols if s.kind == SymbolKind.Variable] + assert len(var_symbols) >= 1 + assert any(s.name == "$count" for s in var_symbols) + + +def test_symbol_function(parse): + prog = parse("FUNC foo\nA\nENDFUNC\n") + symbols = get_document_symbols(prog) + func_symbols = [s for s in symbols if s.kind == SymbolKind.Function] + assert len(func_symbols) >= 1 + assert any(s.name == "foo" for s in func_symbols) + + +def test_symbol_function_params(parse): + prog = parse("FUNC greet($name)\nPRINT $name\nENDFUNC\n") + symbols = get_document_symbols(prog) + func_sym = next(s for s in symbols if s.name == "greet") + assert func_sym.children is not None + param_names = [c.name for c in func_sym.children] + assert "$name" in param_names + + +def test_symbol_extern_func(parse): + prog = parse('EXTERN FUNC Sleep($ms:INT):VOID FROM "kernel32.dll"\n') + symbols = get_document_symbols(prog) + func_symbols = [s for s in symbols if s.kind == SymbolKind.Function] + assert any(s.name == "Sleep" for s in func_symbols) + + +def test_symbol_empty_program(parse): + """Empty program returns empty list.""" + prog = parse("# comment\n") + symbols = get_document_symbols(prog) + assert symbols == [] + + +def test_symbol_ordering(parse): + """Symbols preserve declaration order.""" + prog = parse("_A = 1\n$b = 2\nFUNC c\nA\nENDFUNC\n") + symbols = get_document_symbols(prog) + names = [s.name for s in symbols] + assert names == ["_A", "$b", "c"] diff --git a/vscode-plugin/ecs-language-server/tests/test_lsp_formatting.py b/vscode-plugin/ecs-language-server/tests/test_lsp_formatting.py new file mode 100644 index 00000000..07c8863f --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_lsp_formatting.py @@ -0,0 +1,155 @@ +"""LSP document formatting tests.""" + +from __future__ import annotations +from easycon_script_lsp.features.formatting import format_document + + +def _strip_trailing_newline(s: str) -> str: + if s.endswith("\n"): + return s[:-1] + return s + + +def test_format_simple(parse): + prog = parse("A\n") + result = format_document(prog) + assert _strip_trailing_newline(result) == "A" + + +def test_format_if_block(parse): + prog = parse("IF $a > 0\nPRINT yes\nENDIF\n") + result = format_document(prog) + lines = result.splitlines() + assert lines[0] == "IF $a > 0" + # PRINT args are string-escaped by the formatter + assert "PRINT" in lines[1] + assert lines[1].startswith(" ") + assert lines[2] == "ENDIF" + + +def test_format_if_elif_else(parse): + source = ( + "IF $score >= 90\nPRINT A\n" + "ELIF $score >= 60\nPRINT B\n" + "ELSE\nPRINT C\n" + "ENDIF\n" + ) + prog = parse(source) + result = format_document(prog) + lines = result.splitlines() + assert lines[0].startswith("IF") + assert "PRINT" in lines[1] + assert lines[2].startswith("ELIF") + assert lines[4].startswith("ELSE") + assert lines[6] == "ENDIF" + + +def test_format_for_loop(parse): + prog = parse("FOR 10\nA\nNEXT\n") + result = format_document(prog) + lines = result.splitlines() + assert lines[0] == "FOR 10" + assert lines[1] == " A" + assert lines[2] == "NEXT" + + +def test_format_nested_blocks(parse): + source = ( + "FOR $i = 1 TO 3\n" + "IF $i > 1\nPRINT big\nENDIF\n" + "NEXT\n" + ) + prog = parse(source) + result = format_document(prog) + lines = result.splitlines() + assert lines[0] == "FOR $i = 1 TO 3" + assert lines[1].startswith(" IF $i > 1") + assert lines[2].startswith(" PRINT") + assert lines[3] == " ENDIF" + assert lines[4] == "NEXT" + + +def test_format_assignment(parse): + prog = parse("$a = 1\n") + result = format_document(prog) + assert _strip_trailing_newline(result) == "$a = 1" + + +def test_format_compound_assignment(parse): + prog = parse("$a += 5\n") + result = format_document(prog) + assert _strip_trailing_newline(result) == "$a += 5" + + +def test_format_func_decl(parse): + prog = parse("FUNC foo\nA\nENDFUNC\n") + result = format_document(prog) + lines = result.splitlines() + assert lines[0] == "FUNC foo" + assert lines[1] == " A" + assert lines[2] == "ENDFUNC" + + +def test_format_comment(parse): + prog = parse("# header comment\nA\n") + result = format_document(prog) + lines = result.splitlines() + assert lines[0] == "# header comment" + + +def test_format_inline_comment(parse): + prog = parse("A # press A\n") + result = format_document(prog) + # Formatter normalizes to single space before comment + assert "A" in result + assert "# press A" in result + + +def test_format_empty_program(parse): + """Empty source should produce empty output.""" + prog = parse("# just a comment\n") + result = format_document(prog) + assert result == "# just a comment\n" + + +def test_format_roundtrip(parse): + """Parse -> format -> re-parse produces equivalent AST structure.""" + source = ( + "FUNC max($a, $b):INT\n" + "IF $a > $b\n" + "RETURN $a\n" + "ELSE\n" + "RETURN $b\n" + "ENDIF\n" + "ENDFUNC\n" + ) + prog1 = parse(source) + formatted = format_document(prog1) + prog2 = parse(formatted) + # Both programs should have the same structure + assert len(prog1.statements) == len(prog2.statements) + assert isinstance(prog1.statements[0], type(prog2.statements[0])) + + +def test_format_beep(parse): + prog = parse("BEEP 1000, 200\n") + result = format_document(prog) + assert "BEEP" in result + assert "1000" in result + assert "200" in result + + +def test_format_amiibo(parse): + prog = parse("AMIIBO 0\n") + result = format_document(prog) + assert "AMIIBO" in result + assert "0" in result + + +def test_format_for_step(parse): + prog = parse("FOR $i = 0 TO 100 STEP 10\nA\nNEXT\n") + result = format_document(prog) + lines = result.splitlines() + assert "STEP 10" in lines[0] + assert lines[1] == " A" + assert lines[2] == "NEXT" diff --git a/vscode-plugin/ecs-language-server/tests/test_lsp_hover.py b/vscode-plugin/ecs-language-server/tests/test_lsp_hover.py new file mode 100644 index 00000000..54f64f24 --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_lsp_hover.py @@ -0,0 +1,73 @@ +"""LSP hover tests.""" + +from __future__ import annotations +from lsprotocol.types import Position +from easycon_script_lsp.features.hover import get_hover + + +def test_hover_keyword(): + result = get_hover(None, "IF") + assert result is not None + assert result.contents is not None + + +def test_hover_button_key(): + result = get_hover(None, "A") + assert result is not None + + +def test_hover_stick_key(): + result = get_hover(None, "LS") + assert result is not None + + +def test_hover_direction(): + result = get_hover(None, "UP") + assert result is not None + + +def test_hover_builtin(): + result = get_hover(None, "PRINT") + assert result is not None + + +def test_hover_unknown_word(): + result = get_hover(None, "XYZZY") + assert result is None + + +def test_hover_constant(parse): + prog = parse("_MAX = 100\n") + result = get_hover(prog, "_MAX") + assert result is not None + + +def test_hover_variable(parse): + prog = parse("$count = 0\n") + result = get_hover(prog, "$count") + assert result is not None + + +def test_hover_function(parse): + prog = parse("FUNC foo\nA\nENDFUNC\n") + result = get_hover(prog, "foo") + assert result is not None + + +def test_hover_extern_func(parse): + prog = parse('EXTERN FUNC Sleep($ms:INT):VOID FROM "kernel32.dll"\n') + result = get_hover(prog, "Sleep") + assert result is not None + + +def test_hover_with_position(parse): + """Hover with AST-based position lookup.""" + prog = parse("WAIT 100\n") + result = get_hover(prog, "WAIT", Position(line=0, character=1)) + assert result is not None + + +def test_hover_empty_program(): + """Empty program falls back to word matching.""" + result = get_hover(None, "TRUE") + assert result is not None diff --git a/vscode-plugin/ecs-language-server/tests/test_lsp_semantic_tokens.py b/vscode-plugin/ecs-language-server/tests/test_lsp_semantic_tokens.py new file mode 100644 index 00000000..db474a17 --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_lsp_semantic_tokens.py @@ -0,0 +1,39 @@ +"""LSP semantic tokens tests.""" + +from __future__ import annotations +from easycon_script_lsp.features.semantic_tokens import get_semantic_tokens + + +def test_semantic_tokens_not_empty(): + tokens = get_semantic_tokens("A\nB\n") + assert tokens is not None + assert len(tokens.data) > 0 + + +def test_semantic_tokens_keywords(): + """Keywords should have non-zero token type index.""" + tokens = get_semantic_tokens("IF $a > 0\nENDIF\n") + assert len(tokens.data) > 0 + # data is encoded as [deltaLine, deltaCol, length, tokenType, tokenModifiers] quintuples + assert len(tokens.data) % 5 == 0 + + +def test_semantic_tokens_variables(): + tokens = get_semantic_tokens("$count = 0\n") + assert len(tokens.data) > 0 + + +def test_semantic_tokens_comments(): + tokens = get_semantic_tokens("# comment\nA\n") + assert len(tokens.data) > 0 + + +def test_semantic_tokens_with_ast_override(): + """AST overrides should reclassify buttons as Function type.""" + tokens = get_semantic_tokens("A\n", None) + assert len(tokens.data) > 0 + + +def test_semantic_tokens_empty(): + tokens = get_semantic_tokens("") + assert tokens.data == [] diff --git a/vscode-plugin/ecs-language-server/tests/test_parser_errors.py b/vscode-plugin/ecs-language-server/tests/test_parser_errors.py new file mode 100644 index 00000000..d86f705a --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_parser_errors.py @@ -0,0 +1,117 @@ +"""Parser error tests — verify error detection for malformed programs.""" + +from __future__ import annotations +import pytest +from easycon_grammar.parser import ParseError + + +def _parse_and_expect_error(parse, source: str, fragment: str): + """Assert that parsing source raises ParseError containing fragment.""" + with pytest.raises(ParseError, match=fragment): + parse(source) + + +# --------------------------------------------------------------------------- +# IMPORT placement +# --------------------------------------------------------------------------- + +def test_import_not_at_top(parse): + _parse_and_expect_error(parse, "A\nIMPORT \"mod.ecs\"\n", "导入") + + +def test_import_after_statement(parse): + _parse_and_expect_error( + parse, + 'IMPORT "a.ecs"\nA\nIMPORT "b.ecs"\n', + "导入", + ) + + +# --------------------------------------------------------------------------- +# Duplicate / misplaced ELSE / ELIF +# --------------------------------------------------------------------------- + +def test_duplicate_else(parse): + _parse_and_expect_error( + parse, + "IF $a > 0\nA\nELSE\nB\nELSE\nC\nENDIF\n", + "Else", + ) + + +def test_elif_after_else(parse): + _parse_and_expect_error( + parse, + "IF $a > 0\nA\nELSE\nB\nELIF $a > 5\nC\nENDIF\n", + "Elif", + ) + + +# --------------------------------------------------------------------------- +# Unclosed blocks +# --------------------------------------------------------------------------- + +def test_unclosed_if(parse): + _parse_and_expect_error(parse, "IF $a > 0\nA\n", "没有正确结束") + + +def test_unclosed_for(parse): + _parse_and_expect_error(parse, "FOR 10\nA\n", "没有正确结束") + + +def test_unclosed_func(parse): + _parse_and_expect_error(parse, "FUNC foo\nA\n", "没有正确结束") + + +# --------------------------------------------------------------------------- +# Unmatched closers +# --------------------------------------------------------------------------- + +def test_unmatched_endif(parse): + _parse_and_expect_error(parse, "A\nENDIF\n", "多余的结束语句") + + +def test_unmatched_next(parse): + _parse_and_expect_error(parse, "A\nNEXT\n", "多余的结束语句") + + +# --------------------------------------------------------------------------- +# FUNC / EXTERN inside blocks +# --------------------------------------------------------------------------- + +def test_func_inside_block(parse): + _parse_and_expect_error( + parse, + "IF $a > 0\nFUNC foo\nA\nENDFUNC\nENDIF\n", + "函数必须在顶层", + ) + + +def test_extern_inside_block(parse): + _parse_and_expect_error( + parse, + 'IF $a > 0\nEXTERN FUNC foo():VOID FROM "dll"\nENDIF\n', + "EXTERN FUNC只能在顶层", + ) + + +# --------------------------------------------------------------------------- +# ELIF/ELSE without IF +# --------------------------------------------------------------------------- + +def test_elif_no_if(parse): + _parse_and_expect_error(parse, "A\nELIF $a > 0\nB\nENDIF\n", "ELIF需要对应的If") + + +def test_else_no_if(parse): + _parse_and_expect_error(parse, "A\nELSE\nB\nENDIF\n", "ELSE需要对应的If") + + +# --------------------------------------------------------------------------- +# Syntax errors (from Lark) +# --------------------------------------------------------------------------- + +def test_invalid_syntax(parse): + """Malformed expression should produce a parse error.""" + with pytest.raises(ParseError): + parse("$a = =\n") diff --git a/vscode-plugin/ecs-language-server/tests/test_parser_expressions.py b/vscode-plugin/ecs-language-server/tests/test_parser_expressions.py new file mode 100644 index 00000000..df33208a --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_parser_expressions.py @@ -0,0 +1,287 @@ +"""Parser expression tests — verify AST expression tree structure.""" + +from __future__ import annotations +import pytest +from easycon_grammar import ast + + +def _unwrap(stmt): + """Extract value expression from an assignment statement.""" + return stmt.value + + +# --------------------------------------------------------------------------- +# Literals +# --------------------------------------------------------------------------- + +def test_literal_int(parse): + prog = parse("$x = 42\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.LiteralExpr) + assert expr.value == 42 + + +def test_negative_int_literal(parse): + """-42 is tokenized as a single INT (-42) by Lark, not as unary minus on 42.""" + prog = parse("$x = -42\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.LiteralExpr) + assert expr.value == -42 + + +def test_literal_bool_true(parse): + prog = parse("$x = TRUE\n") + expr = _unwrap(prog.statements[0]) + assert expr.value is True + + +def test_literal_bool_false(parse): + prog = parse("$x = FALSE\n") + expr = _unwrap(prog.statements[0]) + assert expr.value is False + + +def test_literal_string(parse): + prog = parse('$x = "hello"\n') + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.LiteralExpr) + assert expr.value == "hello" + + +# --------------------------------------------------------------------------- +# Variables and constants +# --------------------------------------------------------------------------- + +def test_variable_expr(parse): + prog = parse("$x = $y\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.VariableExpr) + assert expr.name == "$y" + + +def test_const_var_expr(parse): + prog = parse("$x = _MAX\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.ConstVarExpr) + assert expr.name == "_MAX" + + +def test_ext_var_expr(parse): + prog = parse("$x = @image\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.ExtVarExpr) + assert expr.name == "image" + + +# --------------------------------------------------------------------------- +# Array definition +# --------------------------------------------------------------------------- + +def test_array_def(parse): + prog = parse("$x = [1, 2, 3]\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.IndexDefExpr) + assert len(expr.items) == 3 + + +def test_empty_array(parse): + prog = parse("$x = []\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.IndexDefExpr) + assert len(expr.items) == 0 + + +# --------------------------------------------------------------------------- +# Arithmetic operators +# --------------------------------------------------------------------------- + +ARITHMETIC_TESTS = [ + ("10 + 5", "+", 10, 5), + ("10 - 3", "-", 10, 3), + ("6 * 7", "*", 6, 7), + ("20 / 4", "/", 20, 4), + ("20 \\ 3", "\\", 20, 3), + ("10 % 3", "%", 10, 3), + ("2 ^ 8", "^", 2, 8), +] + + +@pytest.mark.parametrize("expr_str,expected_op,left_val,right_val", ARITHMETIC_TESTS) +def test_arithmetic(parse, expr_str, expected_op, left_val, right_val): + prog = parse(f"$x = {expr_str}\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.BinaryExpr) + assert expr.op == expected_op + assert expr.left.value == left_val + assert expr.right.value == right_val + + +# --------------------------------------------------------------------------- +# Comparison operators +# --------------------------------------------------------------------------- + +COMPARISON_TESTS = [ + ("$a > $b", ">"), + ("$a >= $b", ">="), + ("$a < $b", "<"), + ("$a <= $b", "<="), + ("$a == $b", "=="), + ("$a != $b", "!="), +] + + +@pytest.mark.parametrize("expr_str,expected_op", COMPARISON_TESTS) +def test_comparison(parse, expr_str, expected_op): + prog = parse(f"$x = {expr_str}\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.BinaryExpr) + assert expr.op == expected_op + + +# --------------------------------------------------------------------------- +# Logical operators +# --------------------------------------------------------------------------- + +def test_logical_and(parse): + prog = parse("$x = $a and $b\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.BinaryExpr) + assert expr.op == "and" + + +def test_logical_or(parse): + prog = parse("$x = $a or $b\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.BinaryExpr) + assert expr.op == "or" + + +def test_logical_not(parse): + prog = parse("$x = not $a\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.UnaryExpr) + assert expr.op == "not" + + +# --------------------------------------------------------------------------- +# Bitwise operators +# --------------------------------------------------------------------------- + +BITWISE_TESTS = [ + ("$a & $b", "&"), + ("$a | $b", "|"), + ("$a ^ $b", "^"), + ("$a << 2", "<<"), + ("$a >> 2", ">>"), +] + + +@pytest.mark.parametrize("expr_str,expected_op", BITWISE_TESTS) +def test_bitwise_binary(parse, expr_str, expected_op): + prog = parse(f"$x = {expr_str}\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.BinaryExpr) + assert expr.op == expected_op + + +def test_bitwise_not(parse): + prog = parse("$x = ~$a\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.UnaryExpr) + assert expr.op == "~" + + +# --------------------------------------------------------------------------- +# Parenthesized expression +# --------------------------------------------------------------------------- + +def test_paren_expr(parse): + prog = parse("$x = ($a + $b) * $c\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.BinaryExpr) + assert expr.op == "*" + assert isinstance(expr.left, ast.ParenExpr) + + +# --------------------------------------------------------------------------- +# String concatenation +# --------------------------------------------------------------------------- + +def test_string_concat(parse): + prog = parse('$x = "hello" & "world"\n') + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.BinaryExpr) + assert expr.op == "&" + + +# --------------------------------------------------------------------------- +# Operator precedence +# --------------------------------------------------------------------------- + +def test_precedence_mul_before_add(parse): + """* binds tighter than +: 1 + 2 * 3 → (1 + (2 * 3))""" + prog = parse("$x = 1 + 2 * 3\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.BinaryExpr) + assert expr.op == "+" + assert isinstance(expr.right, ast.BinaryExpr) + assert expr.right.op == "*" + + +def test_precedence_add_before_compare(parse): + """+ binds tighter than ==: 1 + 2 == 3 → ((1 + 2) == 3)""" + prog = parse("$x = 1 + 2 == 3\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.BinaryExpr) + assert expr.op == "==" + assert isinstance(expr.left, ast.BinaryExpr) + assert expr.left.op == "+" + + +def test_precedence_compare_before_and(parse): + """> binds tighter than and: a > 1 and b < 2 → ((a > 1) and (b < 2))""" + prog = parse("$x = $a > 1 and $b < 2\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.BinaryExpr) + assert expr.op == "and" + + +def test_precedence_and_before_or(parse): + """and binds tighter than or: a or b and c → (a or (b and c))""" + prog = parse("$x = $a or $b and $c\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.BinaryExpr) + assert expr.op == "or" + + +# --------------------------------------------------------------------------- +# Complex conditions (from Script.md examples) +# --------------------------------------------------------------------------- + +def test_complex_condition(parse): + prog = parse("$x = ($score >= 60) and ($attendance >= 80)\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.BinaryExpr) + assert expr.op == "and" + assert isinstance(expr.left, ast.ParenExpr) + assert isinstance(expr.right, ast.ParenExpr) + + +# --------------------------------------------------------------------------- +# Function call expressions +# --------------------------------------------------------------------------- + +def test_call_expr(parse): + prog = parse("$result = add(10, 20)\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.CallExpr) + assert expr.name == "add" + assert len(expr.args) == 2 + + +def test_call_expr_no_args(parse): + prog = parse("$t = TIME()\n") + expr = _unwrap(prog.statements[0]) + assert isinstance(expr, ast.CallExpr) + assert expr.name == "TIME" + assert len(expr.args) == 0 diff --git a/vscode-plugin/ecs-language-server/tests/test_parser_statements.py b/vscode-plugin/ecs-language-server/tests/test_parser_statements.py new file mode 100644 index 00000000..37ada9d2 --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_parser_statements.py @@ -0,0 +1,526 @@ +"""Parser statement tests — verify AST node types and fields for all statements.""" + +from __future__ import annotations +import pytest +from easycon_grammar import ast + + +# --------------------------------------------------------------------------- +# Button press +# --------------------------------------------------------------------------- + +def test_button_press_simple(parse): + prog = parse("A\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.KeyPressStmt) + assert stmt.key == "A" + assert stmt.duration is None + + +def test_button_press_with_duration(parse): + prog = parse("A 100\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.KeyPressStmt) + assert stmt.key == "A" + assert stmt.duration.value == 100 + + +BUTTON_NAMES = [ + "A", "B", "X", "Y", "L", "R", "ZL", "ZR", + "HOME", "CAPTURE", "PLUS", "MINUS", "LCLICK", "RCLICK", +] + + +@pytest.mark.parametrize("button", BUTTON_NAMES) +def test_all_button_keys(parse, button): + prog = parse(f"{button}\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.KeyPressStmt) + assert stmt.key.upper() == button.upper() + + +# --------------------------------------------------------------------------- +# Button state +# --------------------------------------------------------------------------- + +def test_button_down(parse): + prog = parse("HOME DOWN\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.KeyActStmt) + assert stmt.key == "HOME" + assert stmt.up is False + + +def test_button_up(parse): + prog = parse("HOME UP\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.KeyActStmt) + assert stmt.key == "HOME" + assert stmt.up is True + + +# --------------------------------------------------------------------------- +# Stick control +# --------------------------------------------------------------------------- + +def test_stick_direction(parse): + """LS UP without duration returns StickActStmt.""" + prog = parse("LS UP\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.StickActStmt) + assert stmt.key == "LS" + assert stmt.direction == "UP" + + +def test_stick_angle(parse): + """LS 90 — numeric stick angle.""" + prog = parse("LS 90\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.StickActStmt) + assert stmt.key == "LS" + assert stmt.direction == "90" + + +def test_stick_timing(parse): + """LS UP,100 with duration returns StickPressStmt.""" + prog = parse("LS UP,100\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.StickPressStmt) + assert stmt.key == "LS" + assert stmt.direction == "UP" + assert stmt.duration.value == 100 + + +def test_stick_angle_timing(parse): + """RS 45,200 — numeric angle with duration.""" + prog = parse("RS 45,200\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.StickPressStmt) + assert stmt.key == "RS" + assert stmt.direction == "45" + assert stmt.duration.value == 200 + + +def test_stick_reset(parse): + prog = parse("LS RESET\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.StickActStmt) + assert stmt.key == "LS" + + +# --------------------------------------------------------------------------- +# PRINT / ALERT +# --------------------------------------------------------------------------- + +def test_print_bare_text(parse): + prog = parse("PRINT Hello World\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.CallStmt) + assert stmt.name == "PRINT" + assert len(stmt.args) == 1 # Preprocessed into quoted string + + +def test_print_quoted(parse): + prog = parse('PRINT "Hello World"\n') + stmt = prog.statements[0] + assert isinstance(stmt, ast.CallStmt) + assert stmt.name == "PRINT" + + +def test_print_variable(parse): + prog = parse("PRINT $count\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.CallStmt) + assert stmt.name == "PRINT" + + +def test_print_concat(parse): + prog = parse("PRINT A & B\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.CallStmt) + assert stmt.name == "PRINT" + + +def test_alert(parse): + prog = parse("ALERT Done\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.CallStmt) + assert stmt.name == "ALERT" + + +# --------------------------------------------------------------------------- +# WAIT +# --------------------------------------------------------------------------- + +def test_wait(parse): + prog = parse("WAIT 100\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.WaitStmt) + assert stmt.duration.value == 100 + assert stmt.omitted is False + + +def test_wait_omitted(parse): + prog = parse("100\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.WaitStmt) + assert stmt.omitted is True + assert stmt.duration.value == 100 + + +# --------------------------------------------------------------------------- +# IF statements +# --------------------------------------------------------------------------- + +def test_if_simple(parse): + prog = parse("IF $a > 0\nPRINT yes\nENDIF\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.IfStmt) + assert len(stmt.body) == 1 + assert len(stmt.elifs) == 0 + assert len(stmt.else_body) == 0 + + +def test_if_else(parse): + prog = parse("IF $a > 0\nPRINT pos\nELSE\nPRINT neg\nENDIF\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.IfStmt) + assert len(stmt.body) == 1 + assert len(stmt.else_body) == 1 + + +def test_if_elif_else(parse): + prog = parse( + "IF $score >= 90\nPRINT A\n" + "ELIF $score >= 60\nPRINT B\n" + "ELSE\nPRINT C\n" + "ENDIF\n" + ) + stmt = prog.statements[0] + assert isinstance(stmt, ast.IfStmt) + assert len(stmt.elifs) == 1 + assert len(stmt.else_body) == 1 + + +def test_if_nested(parse): + prog = parse( + "IF $a > 0\n" + "IF $a < 100\nPRINT ok\nENDIF\n" + "ENDIF\n" + ) + outer = prog.statements[0] + assert isinstance(outer, ast.IfStmt) + inner = outer.body[0] + assert isinstance(inner, ast.IfStmt) + + +# --------------------------------------------------------------------------- +# FOR loops +# --------------------------------------------------------------------------- + +def test_for_count(parse): + prog = parse("FOR 10\nA\nNEXT\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.ForStmt) + assert stmt.upper.value == 10 + assert stmt.iter_name is None + assert len(stmt.body) == 1 + + +def test_for_range(parse): + prog = parse("FOR $i = 1 TO 10\nA\nNEXT\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.ForStmt) + assert stmt.iter_name == "$i" + assert stmt.lower.value == 1 + assert stmt.upper.value == 10 + + +def test_for_variable_count(parse): + prog = parse("FOR $count\nA\nNEXT\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.ForStmt) + assert stmt.iter_name is None + + +def test_for_step(parse): + prog = parse("FOR $i = 0 TO 100 STEP 10\nA\nNEXT\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.ForStmt) + assert stmt.iter_name == "$i" + assert stmt.lower.value == 0 + assert stmt.upper.value == 100 + assert stmt.step is not None + assert stmt.step.value == 10 + + +def test_for_count_with_step(parse): + prog = parse("FOR 10 STEP 2\nA\nNEXT\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.ForStmt) + assert stmt.upper.value == 10 + assert stmt.step is not None + assert stmt.step.value == 2 + + +def test_for_var_with_step(parse): + prog = parse("$n = 5\nFOR $n STEP 3\nA\nNEXT\n") + stmt = prog.statements[1] + assert isinstance(stmt, ast.ForStmt) + assert isinstance(stmt.upper, ast.VariableExpr) + assert stmt.step is not None + assert stmt.step.value == 3 + + +def test_for_const_with_step(parse): + prog = parse("_N = 10\nFOR _N STEP 2\nA\nNEXT\n") + stmt = prog.statements[1] + assert isinstance(stmt, ast.ForStmt) + assert isinstance(stmt.upper, ast.ConstVarExpr) + assert stmt.step is not None + assert stmt.step.value == 2 + + +def test_for_infinite(parse): + prog = parse("FOR\nBREAK\nNEXT\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.ForStmt) + assert stmt.infinite is True + + +def test_for_nested(parse): + prog = parse( + "FOR $i = 1 TO 3\n" + "FOR $j = 1 TO 3\nA\nNEXT\n" + "NEXT\n" + ) + outer = prog.statements[0] + assert isinstance(outer, ast.ForStmt) + inner = outer.body[0] + assert isinstance(inner, ast.ForStmt) + + +# --------------------------------------------------------------------------- +# BREAK / CONTINUE +# --------------------------------------------------------------------------- + +def test_break(parse): + prog = parse("FOR 10\nBREAK\nNEXT\n") + body = prog.statements[0].body + assert isinstance(body[0], ast.BreakStmt) + assert body[0].level == 1 + + +def test_break_level(parse): + prog = parse("FOR 10\nBREAK 2\nNEXT\n") + body = prog.statements[0].body + assert isinstance(body[0], ast.BreakStmt) + assert body[0].level == 2 + + +def test_continue(parse): + prog = parse("FOR 10\nCONTINUE\nNEXT\n") + body = prog.statements[0].body + assert isinstance(body[0], ast.ContinueStmt) + + +# --------------------------------------------------------------------------- +# Variable assignment +# --------------------------------------------------------------------------- + +def test_assignment_simple(parse): + prog = parse("$a = 1\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.AssignmentStmt) + assert stmt.op == "=" + assert isinstance(stmt.target, ast.VariableExpr) + assert stmt.target.name == "$a" + + +COMPOUND_ASSIGN_TESTS = [ + ("$a += 5", "+="), + ("$a -= 3", "-="), + ("$a *= 2", "*="), + ("$a /= 4", "/="), + ("$a %= 3", "%="), +] + + +@pytest.mark.parametrize("line,expected_op", COMPOUND_ASSIGN_TESTS) +def test_compound_assignment(parse, line, expected_op): + prog = parse(f"{line}\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.AssignmentStmt) + assert stmt.op == expected_op + + +# --------------------------------------------------------------------------- +# Constant declaration +# --------------------------------------------------------------------------- + +def test_constant_decl(parse): + prog = parse("_MAX = 100\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.ConstantDeclStmt) + assert stmt.name == "_MAX" + assert stmt.value.value == 100 + + +# --------------------------------------------------------------------------- +# Image variables +# --------------------------------------------------------------------------- + +def test_img_var_assignment(parse): + prog = parse("$match = @闪光度\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.AssignmentStmt) + assert isinstance(stmt.value, ast.ExtVarExpr) + assert stmt.value.name == "闪光度" + + +def test_img_var_condition(parse): + prog = parse("IF @target > 90\nA\nENDIF\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.IfStmt) + cond = stmt.condition + assert isinstance(cond, ast.BinaryExpr) + assert isinstance(cond.left, ast.ExtVarExpr) + assert cond.left.name == "target" + + +# --------------------------------------------------------------------------- +# Function declaration +# --------------------------------------------------------------------------- + +def test_func_decl_no_params(parse): + prog = parse("FUNC say_hello\nPRINT hello\nENDFUNC\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.FuncDeclStmt) + assert stmt.name == "say_hello" + assert len(stmt.params) == 0 + assert len(stmt.body) == 1 + + +def test_func_decl_params(parse): + prog = parse("FUNC greet($name)\nPRINT $name\nENDFUNC\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.FuncDeclStmt) + assert stmt.name == "greet" + assert stmt.params == ["$name"] + + +def test_func_decl_return(parse): + prog = parse("FUNC add($a, $b):INT\nRETURN $a + $b\nENDFUNC\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.FuncDeclStmt) + assert stmt.name == "add" + + +# --------------------------------------------------------------------------- +# Function call +# --------------------------------------------------------------------------- + +def test_call_keyword(parse): + prog = parse("CALL say_hello\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.CallStmt) + assert stmt.name == "say_hello" + assert stmt.is_call is True + + +def test_func_call_direct(parse): + prog = parse('say_hello "World"\n') + stmt = prog.statements[0] + assert isinstance(stmt, ast.CallStmt) + assert stmt.name == "say_hello" + + +# --------------------------------------------------------------------------- +# IMPORT +# --------------------------------------------------------------------------- + +def test_import_stmt(parse): + prog = parse('IMPORT "module.ecs"\nA\n') + stmt = prog.statements[0] + assert isinstance(stmt, ast.ImportStmt) + assert stmt.module == "module.ecs" + + +# --------------------------------------------------------------------------- +# EXTERN FUNC (FFI) +# --------------------------------------------------------------------------- + +def test_extern_func_no_params(parse): + prog = parse('EXTERN FUNC GetForegroundWindow():PTR FROM "user32.dll"\n') + stmt = prog.statements[0] + assert isinstance(stmt, ast.ExternFuncStmt) + assert stmt.name == "GetForegroundWindow" + assert stmt.return_type == "PTR" + assert stmt.library == "user32.dll" + assert len(stmt.params) == 0 + + +def test_extern_func_with_params(parse): + prog = parse( + 'EXTERN FUNC MessageBoxW($hwnd:PTR, $text:STRING, $caption:STRING, $flags:INT):INT FROM "user32.dll"\n' + ) + stmt = prog.statements[0] + assert isinstance(stmt, ast.ExternFuncStmt) + assert stmt.name == "MessageBoxW" + assert stmt.return_type == "INT" + assert stmt.library == "user32.dll" + assert len(stmt.params) == 4 + assert stmt.params[0].name == "$hwnd" + assert stmt.params[0].type == "PTR" + + +def test_extern_func_void_return(parse): + prog = parse('EXTERN FUNC Sleep($ms:INT):VOID FROM "kernel32.dll"\n') + stmt = prog.statements[0] + assert isinstance(stmt, ast.ExternFuncStmt) + assert stmt.return_type == "VOID" + + +# --------------------------------------------------------------------------- +# Comments attached to statements +# --------------------------------------------------------------------------- + +def test_comment_on_statement(parse): + prog = parse("A # press A\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.KeyPressStmt) + assert stmt.comment == "# press A" + + +# --------------------------------------------------------------------------- +# BEEP / AMIIBO +# --------------------------------------------------------------------------- + +def test_beep_stmt(parse): + prog = parse("BEEP 1000, 200\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.BeepStmt) + assert stmt.frequency.value == 1000 + assert stmt.duration.value == 200 + + +def test_beep_with_vars(parse): + prog = parse("$freq = 1000\nBEEP $freq, 200\n") + stmt = prog.statements[1] + assert isinstance(stmt, ast.BeepStmt) + assert isinstance(stmt.frequency, ast.VariableExpr) + + +def test_amiibo_stmt(parse): + prog = parse("AMIIBO 0\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.AmiiboStmt) + assert stmt.index.value == 0 + + +def test_amiibo_with_var(parse): + prog = parse("AMIIBO $index\n") + stmt = prog.statements[0] + assert isinstance(stmt, ast.AmiiboStmt) + assert isinstance(stmt.index, ast.VariableExpr) + assert stmt.index.name == "$index" diff --git a/vscode-plugin/ecs-language-server/tests/test_tokenization.py b/vscode-plugin/ecs-language-server/tests/test_tokenization.py new file mode 100644 index 00000000..b6412dc1 --- /dev/null +++ b/vscode-plugin/ecs-language-server/tests/test_tokenization.py @@ -0,0 +1,310 @@ +"""Tokenization tests — verify lexer produces correct token types.""" + +from __future__ import annotations +import pytest + + +# --------------------------------------------------------------------------- +# Keywords +# --------------------------------------------------------------------------- + +KEYWORD_TESTS = [ + ("IMPORT", "IMPORT_KW"), + ("IF", "IF_KW"), + ("ELIF", "ELIF_KW"), + ("ELSE", "ELSE_KW"), + ("ENDIF", "ENDIF_KW"), + ("WHILE", "WHILE_KW"), + ("FOR", "FOR_KW"), + ("TO", "TO_KW"), + ("IN", "IN_KW"), + ("STEP", "STEP_KW"), + ("BREAK", "BREAK_KW"), + ("CONTINUE", "CONTINUE_KW"), + ("NEXT", "NEXT_KW"), + ("FUNC", "FUNC_KW"), + ("RETURN", "RETURN_KW"), + ("ENDFUNC", "ENDFUNC_KW"), + ("END", "END_KW"), + ("TRUE", "TRUE_KW"), + ("FALSE", "FALSE_KW"), + ("RESET", "RESET_KW"), + ("WAIT", "WAIT_KW"), + ("CALL", "CALL_KW"), + ("EXTERN", "EXTERN_KW"), + ("FROM", "FROM_KW"), + ("BEEP", "BEEP_KW"), + ("AMIIBO", "AMIIBO_KW"), +] + + +@pytest.mark.parametrize("source,expected_type", KEYWORD_TESTS) +def test_keyword_token(tokenize, source, expected_type): + tokens = tokenize(source) + kw_tokens = [t for t in tokens if t["type"] == expected_type] + assert len(kw_tokens) >= 1, f"Expected token type {expected_type} for {source}, got {[t['type'] for t in tokens]}" + assert kw_tokens[0]["value"] == source + + +# --------------------------------------------------------------------------- +# Buttons +# --------------------------------------------------------------------------- + +BUTTON_TESTS = [ + "A", "B", "X", "Y", + "L", "R", "ZL", "ZR", + "HOME", "CAPTURE", "PLUS", "MINUS", + "LCLICK", "RCLICK", +] + + +@pytest.mark.parametrize("button", BUTTON_TESTS) +def test_button_key_token(tokenize, button): + tokens = tokenize(button) + btn_tokens = [t for t in tokens if t["type"] == "BUTTON_KEY"] + assert len(btn_tokens) >= 1, f"Expected BUTTON_KEY for {button}" + assert btn_tokens[0]["value"].upper() == button.upper() + + +# --------------------------------------------------------------------------- +# Stick keys +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("stick", ["LS", "RS"]) +def test_stick_key_token(tokenize, stick): + tokens = tokenize(stick) + stick_tokens = [t for t in tokens if t["type"] == "STICK_KEY"] + assert len(stick_tokens) >= 1 + + +# --------------------------------------------------------------------------- +# Direction keys +# --------------------------------------------------------------------------- + +DIRECTION_TESTS = [ + "UP", "DOWN", "LEFT", "RIGHT", + "UPLEFT", "UPRIGHT", "DOWNLEFT", "DOWNRIGHT", +] + + +@pytest.mark.parametrize("direction", DIRECTION_TESTS) +def test_direction_key_token(tokenize, direction): + tokens = tokenize(direction) + dir_tokens = [t for t in tokens if t["type"] == "DIRECTION_KEY"] + assert len(dir_tokens) >= 1, f"Expected DIRECTION_KEY for {direction}" + + +# --------------------------------------------------------------------------- +# Variables, constants, external vars +# --------------------------------------------------------------------------- + +def test_variable_token(tokenize): + tokens = tokenize("$a") + var_tokens = [t for t in tokens if t["type"] == "VAR"] + assert len(var_tokens) == 1 + assert var_tokens[0]["value"] == "$a" + + +def test_global_variable_token(tokenize): + tokens = tokenize("$$global") + var_tokens = [t for t in tokens if t["type"] == "VAR"] + assert len(var_tokens) == 1 + assert var_tokens[0]["value"] == "$$global" + + +def test_constant_token(tokenize): + tokens = tokenize("_MAX") + const_tokens = [t for t in tokens if t["type"] == "CONST"] + assert len(const_tokens) == 1 + assert const_tokens[0]["value"] == "_MAX" + + +def test_ex_var_token(tokenize): + tokens = tokenize("@image") + ex_tokens = [t for t in tokens if t["type"] == "EX_VAR"] + assert len(ex_tokens) == 1 + assert ex_tokens[0]["value"] == "@image" + + +# --------------------------------------------------------------------------- +# Strings and numbers +# --------------------------------------------------------------------------- + +def test_double_quoted_string(tokenize): + tokens = tokenize('"hello"') + str_tokens = [t for t in tokens if t["type"] == "STRING"] + assert len(str_tokens) == 1 + + +def test_single_quoted_string(tokenize): + tokens = tokenize("'hello'") + str_tokens = [t for t in tokens if t["type"] == "STRING"] + assert len(str_tokens) == 1 + + +def test_integer_token(tokenize): + tokens = tokenize("42") + int_tokens = [t for t in tokens if t["type"] == "INT"] + assert len(int_tokens) == 1 + assert int_tokens[0]["value"] == "42" + + +def test_negative_integer_token(tokenize): + tokens = tokenize("-42") + int_tokens = [t for t in tokens if t["type"] == "INT"] + assert len(int_tokens) == 1 + assert int_tokens[0]["value"] == "-42" + + +def test_float_token(tokenize): + tokens = tokenize("3.14") + num_tokens = [t for t in tokens if t["type"] == "NUMBER"] + assert len(num_tokens) == 1 + + +# --------------------------------------------------------------------------- +# Identifiers +# --------------------------------------------------------------------------- + +def test_identifier_token(tokenize): + tokens = tokenize("myFunc") + id_tokens = [t for t in tokens if t["type"] == "IDENT"] + assert len(id_tokens) == 1 + assert id_tokens[0]["value"] == "myFunc" + + +def test_chinese_identifier(tokenize): + tokens = tokenize("测试") + id_tokens = [t for t in tokens if t["type"] == "IDENT"] + assert len(id_tokens) >= 1 + + +# --------------------------------------------------------------------------- +# Comments +# --------------------------------------------------------------------------- + +def test_comment_token(tokenize): + tokens = tokenize("# this is a comment") + comment_tokens = [t for t in tokens if t["type"] == "COMMENT"] + assert len(comment_tokens) == 1 + + +# --------------------------------------------------------------------------- +# Operators +# --------------------------------------------------------------------------- + +OPERATOR_TESTS = [ + ("+", "OP"), + ("*", "OP"), + ("/", "OP"), + ("\\", "OP"), + ("%", "OP"), + ("^", "OP"), + ("&", "OP"), + ("|", "OP"), + ("<", "OP"), + (">", "OP"), + ("==", "OP"), + ("!=", "OP"), + ("<=", "OP"), + (">=", "OP"), + ("<<", "OP"), + (">>", "OP"), + ("=", "ASSIGN_OP"), + ("+=", "ASSIGN_OP"), + ("-=", "ASSIGN_OP"), + ("*=", "ASSIGN_OP"), + ("/=", "ASSIGN_OP"), + ("%=", "ASSIGN_OP"), +] + + +@pytest.mark.parametrize("op_str,expected_type", OPERATOR_TESTS) +def test_operator_token(tokenize, op_str, expected_type): + source = f"$a{op_str}1" + tokens = tokenize(source) + op_tokens = [t for t in tokens if t["type"] == expected_type and t["value"] == op_str] + assert len(op_tokens) >= 1, f"Expected {expected_type} for {op_str} in {source}, tokens: {[(t['type'], t['value']) for t in tokens]}" + + +# --------------------------------------------------------------------------- +# Logical operators +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("logic_op", ["and", "or", "not"]) +def test_logical_operator_token(tokenize, logic_op): + tokens = tokenize(f"$a {logic_op} $b") + logic_tokens = [t for t in tokens if t["type"] == "LOGIC_OP" and t["value"] == logic_op] + assert len(logic_tokens) == 1 + + +# --------------------------------------------------------------------------- +# Punctuation +# --------------------------------------------------------------------------- + +def test_paren_tokens(tokenize): + tokens = tokenize("( )") + paren_tokens = [t for t in tokens if t["type"] == "PAREN"] + assert len(paren_tokens) == 2 + + +def test_bracket_tokens(tokenize): + tokens = tokenize("[ ]") + bracket_tokens = [t for t in tokens if t["type"] == "BRACKET"] + assert len(bracket_tokens) == 2 + + +def test_comma_token(tokenize): + tokens = tokenize("1, 2") + comma_tokens = [t for t in tokens if t["type"] == "COMMA"] + assert len(comma_tokens) == 1 + + +def test_colon_token(tokenize): + tokens = tokenize(":INT") + colon_tokens = [t for t in tokens if t["type"] == "COLON"] + assert len(colon_tokens) == 1 + + +# --------------------------------------------------------------------------- +# Newlines +# --------------------------------------------------------------------------- + +def test_newline_token(tokenize): + tokens = tokenize("A\nB") + nl_tokens = [t for t in tokens if t["type"] == "_NL"] + assert len(nl_tokens) == 1 + + +def test_windows_newline(tokenize): + tokens = tokenize("A\r\nB") + nl_tokens = [t for t in tokens if t["type"] == "_NL"] + assert len(nl_tokens) == 1 + assert nl_tokens[0]["value"] == "\r\n" + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + +def test_empty_source(tokenize): + tokens = tokenize("") + assert tokens == [] + + +def test_only_whitespace(tokenize): + tokens = tokenize(" \t ") + assert tokens == [] + + +def test_only_newlines(tokenize): + tokens = tokenize("\n\n\n") + nl_tokens = [t for t in tokens if t["type"] == "_NL"] + assert len(nl_tokens) == 3 + + +def test_mixed_case_button(tokenize): + """Button keys are case-insensitive in the tokenizer.""" + tokens = tokenize("a") + btn = [t for t in tokens if t["type"] == "BUTTON_KEY"] + assert len(btn) == 1 diff --git a/vscode-plugin/package.json b/vscode-plugin/package.json index 47ae8456..03649211 100644 --- a/vscode-plugin/package.json +++ b/vscode-plugin/package.json @@ -2,7 +2,7 @@ "name": "vscode-ecs", "displayName": "EasyCon for VS Code", "description": "EasyCon伊机控vscode插件,纯AI打造", - "version": "0.0.2", + "version": "0.0.3", "publisher": "easycon", "icon": "images/favicon.ico", "repository": { @@ -10,12 +10,18 @@ "url": "https://github.com/EasyConNS/EasyCon" }, "engines": { - "vscode": "^1.75.0" + "vscode": "^1.82.0" }, "categories": [ "Programming Languages" ], + "activationEvents": [ + "onLanguage:easycon-script" + ], "main": "src/extension.js", + "dependencies": { + "vscode-languageclient": "^9.0.1" + }, "contributes": { "languages": [ { @@ -67,6 +73,17 @@ "key": "f5", "when": "resourceExtname == .ecs" } - ] + ], + "configuration": { + "type": "object", + "title": "EasyCon Script", + "properties": { + "easycon.languageServer.path": { + "type": "string", + "default": "", + "description": "Path to the ecs-lsp executable. Leave empty to use 'python -m easycon_script_lsp'." + } + } + } } } \ No newline at end of file diff --git a/vscode-plugin/src/extension.js b/vscode-plugin/src/extension.js index 1c8104e3..6e403976 100644 --- a/vscode-plugin/src/extension.js +++ b/vscode-plugin/src/extension.js @@ -2,6 +2,7 @@ const vscode = require('vscode'); const fs = require('fs'); const path = require('path'); const { exec } = require('child_process'); +const { LanguageClient, TransportKind } = require('vscode-languageclient/node'); const KEYWORDS = [ 'IMPORT', @@ -34,69 +35,143 @@ const SEARCH_METHOD_MAP = { const imgLabelCache = new Map(); const imgLabelWatchers = new Map(); -const funcNameCache = new Map(); let imgLabelStatusBarItem = null; let outputChannel = vscode.window.createOutputChannel(projName); -const diagnosticCollection = vscode.languages.createDiagnosticCollection('easycon-script'); +let lspClient = null; + +// --------------------------------------------------------------------------- // +// LSP client +// --------------------------------------------------------------------------- // + +function getBundledServerPath(context) { + const platform = process.platform; + const binaryName = platform === 'win32' ? 'ecs-lsp.exe' : 'ecs-lsp'; + const bundled = path.join(context.extensionPath, 'bin', binaryName); + if (fs.existsSync(bundled)) { + return bundled; + } + return undefined; +} + +function startLspClient(context) { + const config = vscode.workspace.getConfiguration('easycon'); + const customPath = config.get('languageServer.path', ''); + + let serverOptions; + + if (customPath) { + serverOptions = { + command: customPath, + transport: TransportKind.stdio, + }; + } else { + const bundled = getBundledServerPath(context); + if (bundled) { + serverOptions = { + command: bundled, + transport: TransportKind.stdio, + }; + } else { + serverOptions = { + command: 'python', + args: ['-m', 'easycon_script_lsp'], + options: { + cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + }, + }; + } + } + + const clientOptions = { + documentSelector: [{ scheme: 'file', language: 'easycon-script' }], + synchronize: { + fileEvents: vscode.workspace.createFileSystemWatcher('**/*.ecs'), + }, + outputChannel: vscode.window.createOutputChannel('EasyCon Script LSP'), + }; + + lspClient = new LanguageClient( + 'ecs-lsp', + 'EasyCon Script Language Server', + serverOptions, + clientOptions + ); + + context.subscriptions.push(lspClient); + lspClient.start(); +} + +// --------------------------------------------------------------------------- // +// Activation +// --------------------------------------------------------------------------- // function activate(context) { context.subscriptions.push(outputChannel); - context.subscriptions.push(diagnosticCollection); // 确保插件关闭时自动清理 outputChannel.appendLine('EasyCon Script extension is now active!'); + // Start LSP client + startLspClient(context); + + // ImgLabel initialization initImgLabelCompletions(); vscode.workspace.onDidChangeWorkspaceFolders(() => { initImgLabelCompletions(); }); - vscode.workspace.onDidChangeTextDocument((event) => { - if (event.document.languageId === 'easycon-script') { - funcNameCache.set(event.document.uri.toString(), collectFuncNames(event.document)); - } - }); - + // Format on save via LSP vscode.workspace.onDidSaveTextDocument((document) => { if (document.languageId === 'easycon-script') { - formatDocument(document); + const formatOnSave = vscode.workspace.getConfiguration('editor', document.uri).get('formatOnSave'); + if (!formatOnSave) { + vscode.commands.executeCommand('editor.action.formatDocument'); + } } }); + // Status bar: version const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); statusBarItem.command = 'easycon.showVersion'; context.subscriptions.push(statusBarItem); updateVersionStatusBar(statusBarItem); + // Status bar: ImgLabel count imgLabelStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 90); imgLabelStatusBarItem.command = 'easycon.refreshImgLabel'; context.subscriptions.push(imgLabelStatusBarItem); updateImgLabelStatusBar(imgLabelStatusBarItem); + // Command: refresh ImgLabel let refreshImgLabel = vscode.commands.registerCommand('easycon.refreshImgLabel', () => { imgLabelCache.clear(); initImgLabelCompletions(); }); context.subscriptions.push(refreshImgLabel); + // Command: show version let showVersion = vscode.commands.registerCommand('easycon.showVersion', () => { updateVersionStatusBar(statusBarItem, true); }); context.subscriptions.push(showVersion); + // Command: format document let formatDocumentCmd = vscode.commands.registerCommand('easycon.formatDocument', () => { const editor = vscode.window.activeTextEditor; if (!editor) return; const document = editor.document; if (document.languageId !== 'easycon-script') return; - + if (document.isDirty) { - document.save().then(() => formatDocument(document)); + document.save().then(() => { + vscode.commands.executeCommand('editor.action.formatDocument'); + }); } else { - formatDocument(document); + vscode.commands.executeCommand('editor.action.formatDocument'); } }); context.subscriptions.push(formatDocumentCmd); + // Command: run script let runScript = vscode.commands.registerCommand('easycon.runScript', () => { const editor = vscode.window.activeTextEditor; if (!editor) { @@ -113,46 +188,27 @@ function activate(context) { }); context.subscriptions.push(runScript); + // ImgLabel completion provider (@ trigger only) context.subscriptions.push(vscode.languages.registerCompletionItemProvider('easycon-script', { provideCompletionItems(document, position) { const line = document.lineAt(position.line).text; const textBefore = line.substring(0, position.character); const completionItems = []; - if (textBefore.endsWith('$')) { - const variables = extractVariables(document); - variables.forEach(v => { - const varName = v.substring(1); - const item = new vscode.CompletionItem(varName, vscode.CompletionItemKind.Variable); - item.insertText = varName; - completionItems.push(item); - }); - } else if (textBefore.endsWith('_')) { - const constants = extractConstants(document); - constants.forEach(c => { - const item = new vscode.CompletionItem(c, vscode.CompletionItemKind.Constant); - item.insertText = c; - completionItems.push(item); - }); - } else if (textBefore.endsWith('@')) { + if (textBefore.endsWith('@')) { const imgLabels = getImgLabelCompletions(document); imgLabels.forEach(c => { const item = new vscode.CompletionItem(c, vscode.CompletionItemKind.Reference); item.insertText = c; completionItems.push(item); }); - } else { - KEYWORDS.forEach(kw => { - const item = new vscode.CompletionItem(kw, vscode.CompletionItemKind.Keyword); - item.insertText = kw; - completionItems.push(item); - }); } return completionItems; } - }, '$', '_', '@')); + }, '@')); + // ImgLabel hover provider context.subscriptions.push(vscode.languages.registerHoverProvider('easycon-script', { provideHover(document, position) { const line = document.lineAt(position.line).text; @@ -182,100 +238,11 @@ function activate(context) { } } })); - - function collectFuncNames(document) { - const names = new Map(); - for (let i = 0; i < document.lineCount; i++) { - const lineText = document.lineAt(i).text; - const match = lineText.match(/^\s*FUNC\s+([\w\u4e00-\u9fff]+)/i); - if (match) { - names.set(match[1], i); - } - } - return names; - } - - context.subscriptions.push(vscode.languages.registerDefinitionProvider('easycon-script', { - provideDefinition(document, position, token) { - const wordRange = document.getWordRangeAtPosition(position, /[\$_]?[\w\u4e00-\u9fff]+/); - if (!wordRange) return null; - - const word = document.getText(wordRange); - - const isVar = word.startsWith('$'); - const isConst = word.startsWith('_'); - - if (isVar || isConst) { - const name = word.substring(1); - for (let i = 0; i < document.lineCount; i++) { - const lineText = document.lineAt(i).text; - const match = lineText.match(new RegExp(`^\\s*${isVar ? '\\$' : '_'}${name}\\s*=`)); - if (match) { - const range = new vscode.Range(i, 0, i, lineText.length); - return new vscode.Location(document.uri, range); - } - } - return null; - } - - const uriKey = document.uri.toString(); - let funcMap = funcNameCache.get(uriKey); - if (!funcMap) { - funcMap = collectFuncNames(document); - funcNameCache.set(uriKey, funcMap); - } - const funcName = word; - if (funcMap.has(funcName)) { - const lineNum = funcMap.get(funcName); - const lineText = document.lineAt(lineNum).text; - const range = new vscode.Range(lineNum, 0, lineNum, lineText.length); - return new vscode.Location(document.uri, range); - } - return null; - } - })); - - context.subscriptions.push(vscode.languages.registerDocumentFormattingEditProvider('easycon-script', { - provideDocumentFormattingEdits(document) { - return new Promise((resolve, reject) => { - const filePath = document.uri.fsPath; - runFormatCommand(filePath).then(formattedContent => { - if (!formattedContent) { - resolve([]); - return; - } - const fullRange = new vscode.Range(0, 0, document.lineCount, 0); - const edit = new vscode.TextEdit(fullRange, formattedContent); - resolve([edit]); - }).catch(reject); - }); - } - })); } -function extractVariables(document) { - const variables = new Set(); - for (let i = 0; i < document.lineCount; i++) { - const line = document.lineAt(i).text; - const match = line.match(/\$([\w\u4e00-\u9fff]+)/g); - if (match) { - match.forEach(m => variables.add(m)); - } - } - return Array.from(variables); -} - -function extractConstants(document) { - const constants = new Set(); - for (let i = 0; i < document.lineCount; i++) { - const line = document.lineAt(i).text; - const match = line.match(/_([\w\u4e00-\u9fff]+)/g); - if (match) { - match.forEach(m => constants.add(m)); - } - } - return Array.from(constants); -} +// --------------------------------------------------------------------------- // +// ImgLabel functions +// --------------------------------------------------------------------------- // function getImgLabelCompletions(document) { const scriptDir = path.dirname(document.uri.fsPath); @@ -342,7 +309,7 @@ function initImgLabelCompletions() { } function updateImgLabelStatusBarAll() { - outputChannel.appendLine(`开始更新搜图标签`); + outputChannel.appendLine('开始更新搜图标签'); if (!imgLabelStatusBarItem) return; let totalCount = 0; imgLabelCache.forEach(completions => { @@ -353,6 +320,10 @@ function updateImgLabelStatusBarAll() { imgLabelStatusBarItem.show(); } +// --------------------------------------------------------------------------- // +// Script execution / config +// --------------------------------------------------------------------------- // + function getEzconPath() { const ezconRoot = process.env.EASYCON_ROOT; if (ezconRoot) { @@ -376,7 +347,7 @@ function parseToml(content) { result[currentSection] = {}; continue; } - const kvMatch = line.match(/^([\w\u4e00-\u9fff]+)\s*=\s*(.+)$/); + const kvMatch = line.match(/^([\w一-鿿]+)\s*=\s*(.+)$/); if (kvMatch && currentSection) { let value = kvMatch[2].trim(); if ((value.startsWith('"') && value.endsWith('"')) || @@ -419,7 +390,7 @@ function executeScript(filePath) { } outputChannel.show(); outputChannel.appendLine(`[${new Date().toLocaleTimeString()}] 执行脚本: ${path.basename(filePath)}`); - + const terminal = vscode.window.activeTerminal || vscode.window.createTerminal(projName); const ezcon = getEzconPath(); const config = loadConfig(filePath); @@ -431,6 +402,10 @@ function executeScript(filePath) { terminal.sendText(cmd); } +// --------------------------------------------------------------------------- // +// Status bar +// --------------------------------------------------------------------------- // + function updateVersionStatusBar(statusBarItem, showMessage = false) { exec(`${getEzconPath()} --version`, (error, stdout) => { if (error) { @@ -459,95 +434,9 @@ function updateImgLabelStatusBar(statusBarItem) { statusBarItem.show(); } -function formatDocument(document) { - const filePath = document.uri.fsPath; - outputChannel.appendLine(`格式化: ${path.basename(filePath)}`); - - const ezcon = getEzconPath(); - const cmd = `"${ezcon}" format "${filePath}"`; - - exec(cmd, { cwd: path.dirname(filePath) }, (error, stdout, stderr) => { - // 每次开始前先清空旧的报错 - diagnosticCollection.delete(document.uri); - if (error) { - const errorText = stderr || error.message; - outputChannel.appendLine(`检测到错误: ${errorText}`); - - // 调用上面写的解析函数,将错误推送到 Problems 面板 - updateDiagnostics(document, errorText); - return; - }else{ - const formattedContent = stdout.trim(); - if (!formattedContent) { - outputChannel.appendLine('格式化结果为空'); - return; - } - } - vscode.workspace.openTextDocument(document.uri).then(doc => { - const edit = new vscode.WorkspaceEdit(); - const fullRange = new vscode.Range(0, 0, doc.lineCount, 0); - edit.replace(document.uri, fullRange, formattedContent); - vscode.workspace.applyEdit(edit).then(() => { - doc.save(); - outputChannel.appendLine('格式化完成'); - }); - }); - }); -} - -function runFormatCommand(filePath) { - return new Promise((resolve, reject) => { - const ezcon = getEzconPath(); - const cmd = `"${ezcon}" format "${filePath}"`; - - exec(cmd, { cwd: path.dirname(filePath) }, (error, stdout, stderr) => { - // 每次开始前先清空旧的报错 - diagnosticCollection.delete(document.uri); - if (error) { - const errorText = stderr || error.message; - outputChannel.appendLine(`检测到错误: ${errorText}`); - - // 调用上面写的解析函数,将错误推送到 Problems 面板 - updateDiagnostics(document, errorText); - reject(error); - return; - } - const formattedContent = stdout.trim(); - if (!formattedContent) { - outputChannel.appendLine('格式化结果为空'); - resolve(''); - return; - } - resolve(formattedContent); - }); - }); -} - -function updateDiagnostics(document, errorOutput) { - const diagnostics = []; - - // 假设报错格式为: Error at line (数字): (错误信息) - // 你需要根据 ezcon 实际的报错文本修改这个正则表达式 - const errorRegex = /line\s+(\d+):\s+(.*)/gi; - let match; - - while ((match = errorRegex.exec(errorOutput)) !== null) { - const line = parseInt(match[1]) - 1; // VS Code 行号从 0 开始 - const message = match[2]; - - const range = new vscode.Range(line, 0, line, 100); // 选中整行 - const diagnostic = new vscode.Diagnostic( - range, - message, - vscode.DiagnosticSeverity.Error - ); - diagnostic.source = projName; - diagnostics.push(diagnostic); - } - - // 将诊断信息关联到当前文档 - diagnosticCollection.set(document.uri, diagnostics); -} +// --------------------------------------------------------------------------- // +// Deactivation +// --------------------------------------------------------------------------- // function deactivate() { imgLabelWatchers.forEach(watcher => { @@ -555,6 +444,10 @@ function deactivate() { }); imgLabelWatchers.clear(); imgLabelCache.clear(); + + if (lspClient) { + return lspClient.stop(); + } } -module.exports = { activate, deactivate }; \ No newline at end of file +module.exports = { activate, deactivate }; diff --git a/vscode-plugin/syntaxes/ecs.tmLanguage.json b/vscode-plugin/syntaxes/ecs.tmLanguage.json index 64f67d0e..1c17f0c3 100644 --- a/vscode-plugin/syntaxes/ecs.tmLanguage.json +++ b/vscode-plugin/syntaxes/ecs.tmLanguage.json @@ -2,17 +2,21 @@ "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", "name": "EasyCon Script", "scopeName": "source.ecs", - "fileTypes": ["ecs"], + "fileTypes": [".ecs"], "patterns": [ { "include": "#comments" }, { "include": "#strings" }, + { "include": "#extern_funcs" }, + { "include": "#func_refs" }, + { "include": "#keywords" }, + { "include": "#numbers" }, { "include": "#variables" }, + { "include": "#externals" }, { "include": "#operators" }, - { "include": "#numbers" }, - { "include": "#keywords" }, - { "include": "#gamepad" }, - { "include": "#support" }, - { "include": "#identifiers" } + { "include": "#buttons" }, + { "include": "#sticks" }, + { "include": "#directions" }, + { "include": "#builtins" } ], "repository": { "comments": { @@ -27,7 +31,7 @@ "patterns": [ { "match": "(?i)\\b(TODO|FIXME|HACK|UNDONE)\\b", - "name": "comment.line.number-sign.todo.bold.ecs markup.bold.ecs" + "name": "markup.bold.ecs" } ] } @@ -44,7 +48,13 @@ }, "endCaptures": { "0": { "name": "punctuation.definition.string.end.ecs" } - } + }, + "patterns": [ + { + "name": "constant.character.escape.ecs", + "match": "\\\\." + } + ] }, { "begin": "'", @@ -55,55 +65,71 @@ }, "endCaptures": { "0": { "name": "punctuation.definition.string.end.ecs" } - } - } - ] - }, - "variables": { - "patterns": [ - { - "match": "(?i)\\$[a-zA-Z0-9_\u4e00-\u9fff]+\\b", - "name": "variable.other.ecs" - }, - { - "match": "(?i)@[a-zA-Z0-9_\u4e00-\u9fff]+\\b", - "name": "variable.other.capture.ecs" - }, - { - "match": "(?i)_[a-zA-Z0-9_\u4e00-\u9fff]+\\b", - "name": "constant.other.ecs" + }, + "patterns": [ + { + "name": "constant.character.escape.ecs", + "match": "\\\\." + } + ] } ] }, - "numbers": { + "extern_funcs": { "patterns": [ { - "match": "(?i)\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?", - "name": "constant.numeric.ecs" + "begin": "(?i)\\b(EXTERN)\\s+(FUNC)\\s+([a-zA-Z_一-龥][a-zA-Z0-9_一-龥!]*)", + "end": "(?=$|\\n)", + "beginCaptures": { + "1": { "name": "keyword.other.extern.ecs" }, + "2": { "name": "storage.function.declaration.ecs" }, + "3": { "name": "entity.name.function.ecs" } + }, + "patterns": [ + { + "match": "(?i)\\b(INT|BOOL|STRING|PTR|DOUBLE|VOID)\\b", + "name": "storage.type.ecs" + }, + { + "match": "\"[^\"]*\"", + "name": "string.quoted.double.ecs" + }, + { + "match": "(?i)\\b(FROM)\\b", + "name": "keyword.other.extern.ecs" + }, + { + "match": "\\$\\$?[a-zA-Z0-9_一-龥][a-zA-Z0-9_一-龥]*", + "name": "variable.other.ecs" + } + ] } ] }, - "keywords": { + "func_refs": { "patterns": [ - { - "match": "(?i)\\b(EXTERN|FROM)\\b", - "name": "keyword.other.extern.ecs" - }, - { - "match": "(?i)\\b(ENDFUNC)\\b", - "name": "storage.function.declaration.ecs" - }, { "begin": "(?i)\\b(FUNC)\\b", "end": "(?=$|\\n)", "name": "storage.function.declaration.ecs", + "beginCaptures": { + "1": { "name": "storage.function.declaration.ecs" } + }, "patterns": [ { - "match": "(?i)\\b[a-zA-Z_][a-zA-Z0-9_\u4e00-\u9fff]*\\b(?=\\s*\\()", - "name": "entity.name.function.ecs" + "match": "\\b([a-zA-Z_一-龥][a-zA-Z0-9_一-龥!]*)\\s*(?=\\()", + "captures": { + "1": { "name": "entity.name.function.ecs" } + } }, { - "match": "\\$[a-zA-Z0-9_\u4e00-\u9fff]+\\b", + "match": "\\b([a-zA-Z_一-龥][a-zA-Z0-9_一-龥!]*)\\b", + "captures": { + "1": { "name": "entity.name.function.ecs" } + } + }, + { + "match": "\\$\\$?[a-zA-Z0-9_一-龥][a-zA-Z0-9_一-龥]*", "name": "variable.other.ecs" }, { @@ -116,6 +142,25 @@ } ] }, + { + "match": "\\b(CALL)\\s+([a-zA-Z_一-龥][a-zA-Z0-9_一-龥!]*)", + "captures": { + "1": { "name": "keyword.control.ecs" }, + "2": { "name": "entity.name.function.ecs" } + } + } + ] + }, + "keywords": { + "patterns": [ + { + "match": "(?i)\\b(EXTERN|FROM)\\b", + "name": "keyword.other.extern.ecs" + }, + { + "match": "(?i)\\b(ENDFUNC)\\b", + "name": "storage.function.declaration.ecs" + }, { "match": "(?i)\\b(BREAK|CONTINUE|RETURN)\\b", "name": "keyword.control.flow.ecs" @@ -131,6 +176,34 @@ { "match": "(?i)\\b(INT|BOOL|STRING|PTR|DOUBLE|VOID)\\b", "name": "storage.type.ecs" + }, + { + "match": "(?i)\\b(IMPORT|TO|IN|STEP|TRUE|FALSE|RESET|WAIT|CALL)\\b", + "name": "keyword.control.ecs" + } + ] + }, + "numbers": { + "patterns": [ + { + "match": "(?i)\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?", + "name": "constant.numeric.ecs" + } + ] + }, + "variables": { + "patterns": [ + { + "match": "\\$\\$?[a-zA-Z0-9_一-龥][a-zA-Z0-9_一-龥]*", + "name": "variable.other.ecs" + } + ] + }, + "externals": { + "patterns": [ + { + "match": "@[a-zA-Z_一-龥][a-zA-Z0-9_一-龥]*", + "name": "variable.language.ecs" } ] }, @@ -166,27 +239,35 @@ } ] }, - "gamepad": { + "buttons": { + "patterns": [ + { + "name": "entity.name.tag", + "match": "\\b(?i:LCLICK|RCLICK|CAPTURE|MINUS|HOME|PLUS|ZL|ZR|UP|DOWN|LEFT|RIGHT|L|R|A|B|X|Y)\\b" + } + ] + }, + "sticks": { "patterns": [ { - "match": "(?i)\\b(A|B|X|Y|L|R|ZL|ZR|PLUS|MINUS|LCLICK|RCLICK|HOME|CAPTURE|UP|UPRIGHT|RIGHT|DOWNRIGHT|DOWN|DOWNLEFT|LEFT|UPLEFT|LS|RS)\\b", - "name": "constant.language.gamepad.ecs" + "name": "storage.type", + "match": "\\b(?i:LS|RS)\\b" } ] }, - "identifiers": { + "directions": { "patterns": [ { - "match": "(?i)\\b[a-zA-Z][a-zA-Z0-9_\u4e00-\u9fff]*\\b", - "name": "entity.name.function.ecs" + "name": "constant.language", + "match": "\\b(?i:DOWNLEFT|DOWNRIGHT|UPLEFT|UPRIGHT)\\b" } ] }, - "support": { + "builtins": { "patterns": [ { "comment": "Built-in functions", - "match": "(?i)\\b(RAND|TIME|PRINT|ALERT|WAIT|AMIIBO|BEEP)\\b", + "match": "\\b(?i:RAND|TIME|PRINT|ALERT|WAIT|AMIIBO|BEEP)\\b", "name": "support.function.builtin.ecs" } ]