1#!/usr/bin/env python3 2# -*- coding: utf-8 -*- 3# 4# Copyright (c) 2025 Huawei Device Co., Ltd. 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16# 17 18import json 19import subprocess # noqa: S404 20from dataclasses import dataclass 21from pathlib import Path 22from typing import Any, Literal, Protocol 23 24from pytest import fixture 25 26from arkdb.runnable_module import ScriptFile, syntax 27 28from .logs import logger 29 30LOG = logger(__name__) 31 32CompilerExtensions = Literal["js", "ts", "as", "ets"] 33 34CompileLogLevel = Literal["debug", "info", "warning", "error", "fatal"] 35 36 37class AstParser(Protocol): 38 def __call__( 39 self, 40 compiler_output: str, 41 ) -> dict[str, Any]: 42 pass 43 44 45@dataclass 46class Options: 47 """ 48 Options for ``es2panda`` execution. 49 """ 50 51 app_path: Path = Path("bin", "es2panda") 52 arktsconfig: Path = Path("bin", "arktsconfig.json") 53 54 55@dataclass 56class CompilerArguments: 57 extension: CompilerExtensions = "ets" 58 ets_module: bool = False 59 opt_level: int = 0 60 debug_info: bool = True 61 dump_dynamic_ast: bool | None = None 62 debugger_eval_panda_files: list[Path] | None = None 63 debugger_eval_source: Path | None = None 64 debugger_eval_line: int | None = None 65 log_level: str | None = None 66 67 @staticmethod 68 def arg_to_str(arg: bool | list[Path] | Any) -> str: 69 if isinstance(arg, bool): 70 return str(arg).lower() 71 if isinstance(arg, list): 72 return ":".join(str(f) for f in arg) 73 return str(arg) 74 75 @staticmethod 76 def to_cli_key(key: str) -> str: 77 if key.startswith("debugger_eval_"): 78 return f"--debugger-eval:{key[len('debugger_eval_'):].replace('_', '-')}" 79 return f"--{key.replace('_', '-')}" 80 81 def to_arguments_list(self) -> list[str]: 82 result = [] 83 for key, value in vars(self).items(): 84 if value is not None: 85 result.append(f"{CompilerArguments.to_cli_key(key)}={CompilerArguments.arg_to_str(value)}") 86 return result 87 88 89@dataclass 90class EvaluateCompileExpressionArgs: 91 ets_expression: str | Path 92 eval_panda_files: list[Path] | None = None 93 eval_source: Path | None = None 94 eval_line: int | None = None 95 eval_log_level: CompileLogLevel | None = None 96 ast_parser: AstParser | None = None 97 name: str = "evaluated_expression" 98 extension: CompilerExtensions | None = None 99 100 101class CompileError(Exception): 102 def __init__(self, stdout: str) -> None: 103 super().__init__([stdout]) 104 self.stdout = stdout 105 106 107class Compiler: 108 """ 109 Controls the ``es2panda``. 110 """ 111 112 def __init__(self, options: Options) -> None: 113 self.options = options 114 115 @staticmethod 116 def _compile( 117 args: list[str], 118 cwd: Path = Path(), 119 ) -> tuple[str, str]: 120 LOG.info("Compile %s", args) 121 proc = subprocess.run( # noqa: S603 122 args=args, 123 cwd=cwd, 124 text=True, 125 capture_output=True, 126 ) 127 if proc.returncode == 1: 128 raise CompileError(f"Stdout:\n{proc.stdout}\nStderr:\n{proc.stderr}") 129 proc.check_returncode() 130 return proc.stdout, proc.stderr 131 132 def compile( 133 self, 134 source_file: Path, 135 output_file: Path, 136 cwd: Path = Path(), 137 arguments: CompilerArguments | None = None, 138 ) -> tuple[str, str]: 139 args = [ 140 str(self.options.app_path), 141 f"--arktsconfig={self.options.arktsconfig}", 142 f"--output={output_file}", 143 ] 144 opt_args = arguments if arguments is not None else CompilerArguments() 145 args += opt_args.to_arguments_list() 146 args.append(str(source_file)) 147 148 return Compiler._compile(args=args, cwd=cwd) 149 150 151@fixture 152def ark_compiler( 153 ark_compiler_options: Options, 154) -> Compiler: 155 """ 156 Return :class:`Compiler` instans that controls ``es2panda`` process. 157 """ 158 return Compiler(ark_compiler_options) 159 160 161@fixture 162def ast_parser() -> AstParser: 163 def parser(compiler_output: str) -> dict[str, Any]: 164 return json.loads(compiler_output) 165 166 return parser 167 168 169def ark_compile( 170 source_file: Path, 171 tmp_path: Path, 172 ark_compiler: Compiler, 173 arguments: CompilerArguments | None = None, 174 ast_parser: AstParser | None = None, 175): 176 if not source_file.exists(): 177 raise FileNotFoundError(source_file) 178 if not tmp_path.exists(): 179 raise FileNotFoundError(tmp_path) 180 181 panda_file = tmp_path / f"{source_file.stem}.abc" 182 try: 183 stdout, stderr = ark_compiler.compile( 184 source_file=source_file, 185 output_file=panda_file, 186 cwd=tmp_path, 187 arguments=arguments, 188 ) 189 if stderr: 190 LOG.debug("ES2PANDA STDERR:\n%s", stderr) 191 192 except CompileError as e: 193 LOG.error("Compile error", rich=syntax(source_file.read_text(), start_line=1)) 194 if e.stdout: 195 LOG.error("STDOUT\n%s", e.stdout) 196 raise e 197 except subprocess.CalledProcessError as e: 198 LOG.error("Compiler failed with %s code", e.returncode, rich=syntax(source_file.read_text(), start_line=1)) 199 if e.stdout: 200 LOG.error("STDOUT\n%s", e.stdout) 201 if e.stderr: 202 LOG.error("STDERR\n%s", e.stderr) 203 raise e 204 205 ast: dict[str, Any] | None = None 206 if ast_parser and arguments and arguments.dump_dynamic_ast: 207 ast = ast_parser(stdout) 208 return ScriptFile(source_file, panda_file, ast=ast) 209 210 211class StringCodeCompiler: 212 213 def __init__(self, tmp_path: Path, ark_compiler: Compiler) -> None: 214 self._tmp_path = tmp_path 215 self._ark_compiler = ark_compiler 216 217 def compile( 218 self, 219 source_code: str, 220 name: str = "test_string", 221 arguments: CompilerArguments | None = None, 222 ast_parser: AstParser | None = None, 223 ) -> ScriptFile: 224 """ 225 Compiles the ``ets``-file and returns the :class:`ScriptFile` instance. 226 """ 227 args = arguments if arguments else CompilerArguments() 228 source_file = self._write_into_file(source_code, name, args.extension) 229 return ark_compile( 230 source_file=source_file, 231 tmp_path=self._tmp_path, 232 ark_compiler=self._ark_compiler, 233 arguments=args, 234 ast_parser=ast_parser, 235 ) 236 237 def compile_expression( 238 self, 239 eval_args: EvaluateCompileExpressionArgs, 240 ) -> ScriptFile: 241 args = CompilerArguments( 242 ets_module=True, 243 opt_level=0, 244 dump_dynamic_ast=ast_parser is not None, 245 debugger_eval_panda_files=eval_args.eval_panda_files, 246 debugger_eval_source=eval_args.eval_source, 247 debugger_eval_line=eval_args.eval_line, 248 log_level=eval_args.eval_log_level if eval_args.eval_log_level is not None else "error", 249 ) 250 if eval_args.extension is not None: 251 args.extension = eval_args.extension 252 253 ets_file: Path 254 if isinstance(eval_args.ets_expression, str): 255 ets_file = self._write_into_file(eval_args.ets_expression, eval_args.name, args.extension) 256 else: 257 ets_file = eval_args.ets_expression 258 if not ets_file.exists(): 259 raise FileNotFoundError(ets_file) 260 261 return ark_compile( 262 ets_file, 263 self._tmp_path, 264 self._ark_compiler, 265 arguments=args, 266 ast_parser=eval_args.ast_parser, 267 ) 268 269 def _write_into_file(self, source_code: str, filename: str, extension: str) -> Path: 270 ets_file = self._tmp_path / f"{filename}.{extension}" 271 ets_file.write_text(source_code) 272 return ets_file 273 274 275@fixture 276def code_compiler( 277 tmp_path: Path, 278 ark_compiler: Compiler, 279) -> StringCodeCompiler: 280 """ 281 Return :class:`StringCodeCompiler` instance that can compile ``ets``-file. 282 """ 283 return StringCodeCompiler( 284 tmp_path=tmp_path, 285 ark_compiler=ark_compiler, 286 ) 287