1#!/usr/bin/env python3 2# -*- coding: utf-8 -*- 3# 4# Copyright (c) 2024 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", "sts"] 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 = "sts" 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_mode: bool | None = None 63 debugger_eval_panda_files: list[Path] | None = None 64 debugger_eval_source: Path | None = None 65 debugger_eval_line: int | None = None 66 log_level: str | None = None 67 68 @staticmethod 69 def arg_to_str(arg: bool | list[Path] | Any) -> str: 70 if isinstance(arg, bool): 71 return str(arg).lower() 72 if isinstance(arg, list): 73 return ",".join(str(f) for f in arg) 74 return str(arg) 75 76 def to_arguments_list(self) -> list[str]: 77 result = [] 78 for key, value in vars(self).items(): 79 if value is not None: 80 result.append(f"--{key.replace('_', '-')}={CompilerArguments.arg_to_str(value)}") 81 return result 82 83 84@dataclass 85class EvaluateCompileExpressionArgs: 86 ets_expression: str | Path 87 eval_panda_files: list[Path] | None = None 88 eval_source: Path | None = None 89 eval_line: int | None = None 90 eval_log_level: CompileLogLevel | None = None 91 ast_parser: AstParser | None = None 92 name: str = "evaluated_expression" 93 extension: CompilerExtensions | None = None 94 95 96class CompileError(Exception): 97 def __init__(self, stdout: str) -> None: 98 super().__init__([stdout]) 99 self.stdout = stdout 100 101 102class Compiler: 103 """ 104 Controls the ``es2panda``. 105 """ 106 107 def __init__(self, options: Options) -> None: 108 self.options = options 109 110 @staticmethod 111 def _compile( 112 args: list[str], 113 cwd: Path = Path(), 114 ) -> tuple[str, str]: 115 LOG.info("Compile %s", args) 116 proc = subprocess.run( # noqa: S603 117 args=args, 118 cwd=cwd, 119 text=True, 120 capture_output=True, 121 ) 122 if proc.returncode == 1: 123 raise CompileError(f"Stdout:\n{proc.stdout}\nStderr:\n{proc.stderr}") 124 proc.check_returncode() 125 return proc.stdout, proc.stderr 126 127 def compile( 128 self, 129 source_file: Path, 130 output_file: Path, 131 cwd: Path = Path(), 132 arguments: CompilerArguments | None = None, 133 ) -> tuple[str, str]: 134 args = [ 135 str(self.options.app_path), 136 f"--arktsconfig={self.options.arktsconfig}", 137 f"--output={output_file}", 138 ] 139 opt_args = arguments if arguments is not None else CompilerArguments() 140 args += opt_args.to_arguments_list() 141 args.append(str(source_file)) 142 143 return Compiler._compile(args=args, cwd=cwd) 144 145 146@fixture 147def ark_compiler( 148 ark_compiler_options: Options, 149) -> Compiler: 150 """ 151 Return :class:`Compiler` instans that controls ``es2panda`` process. 152 """ 153 return Compiler(ark_compiler_options) 154 155 156@fixture 157def ast_parser() -> AstParser: 158 def parser(compiler_output: str) -> dict[str, Any]: 159 return json.loads(compiler_output) 160 161 return parser 162 163 164def ark_compile( 165 source_file: Path, 166 tmp_path: Path, 167 ark_compiler: Compiler, 168 arguments: CompilerArguments | None = None, 169 ast_parser: AstParser | None = None, 170): 171 if not source_file.exists(): 172 raise FileNotFoundError(source_file) 173 if not tmp_path.exists(): 174 raise FileNotFoundError(tmp_path) 175 176 panda_file = tmp_path / f"{source_file.stem}.abc" 177 try: 178 stdout, stderr = ark_compiler.compile( 179 source_file=source_file, 180 output_file=panda_file, 181 cwd=tmp_path, 182 arguments=arguments, 183 ) 184 if stderr: 185 LOG.debug("ES2PANDA STDERR:\n%s", stderr) 186 187 except CompileError as e: 188 LOG.error("Compile error", rich=syntax(source_file.read_text(), start_line=1)) 189 if e.stdout: 190 LOG.error("STDOUT\n%s", e.stdout) 191 raise e 192 except subprocess.CalledProcessError as e: 193 LOG.error("Compiler failed with %s code", e.returncode, rich=syntax(source_file.read_text(), start_line=1)) 194 if e.stdout: 195 LOG.error("STDOUT\n%s", e.stdout) 196 if e.stderr: 197 LOG.error("STDERR\n%s", e.stderr) 198 raise e 199 200 ast: dict[str, Any] | None = None 201 if ast_parser and arguments and arguments.dump_dynamic_ast: 202 ast = ast_parser(stdout) 203 return ScriptFile(source_file, panda_file, ast=ast) 204 205 206class StringCodeCompiler: 207 208 def __init__(self, tmp_path: Path, ark_compiler: Compiler) -> None: 209 self._tmp_path = tmp_path 210 self._ark_compiler = ark_compiler 211 212 def compile( 213 self, 214 source_code: str, 215 name: str = "test_string", 216 arguments: CompilerArguments | None = None, 217 ast_parser: AstParser | None = None, 218 ) -> ScriptFile: 219 """ 220 Compiles the ``sts``-file and returns the :class:`ScriptFile` instance. 221 """ 222 args = arguments if arguments else CompilerArguments() 223 source_file = self._write_into_file(source_code, name, args.extension) 224 return ark_compile( 225 source_file=source_file, 226 tmp_path=self._tmp_path, 227 ark_compiler=self._ark_compiler, 228 arguments=args, 229 ast_parser=ast_parser, 230 ) 231 232 def compile_expression( 233 self, 234 eval_args: EvaluateCompileExpressionArgs, 235 ) -> ScriptFile: 236 args = CompilerArguments( 237 ets_module=True, 238 opt_level=0, 239 dump_dynamic_ast=ast_parser is not None, 240 debugger_eval_mode=True, 241 debugger_eval_panda_files=eval_args.eval_panda_files, 242 debugger_eval_source=eval_args.eval_source, 243 debugger_eval_line=eval_args.eval_line, 244 log_level=eval_args.eval_log_level if eval_args.eval_log_level is not None else "error", 245 ) 246 if eval_args.extension is not None: 247 args.extension = eval_args.extension 248 249 ets_file: Path 250 if isinstance(eval_args.ets_expression, str): 251 ets_file = self._write_into_file(eval_args.ets_expression, eval_args.name, args.extension) 252 else: 253 ets_file = eval_args.ets_expression 254 if not ets_file.exists(): 255 raise FileNotFoundError(ets_file) 256 257 return ark_compile( 258 ets_file, 259 self._tmp_path, 260 self._ark_compiler, 261 arguments=args, 262 ast_parser=eval_args.ast_parser, 263 ) 264 265 def _write_into_file(self, source_code: str, filename: str, extension: str) -> Path: 266 ets_file = self._tmp_path / f"{filename}.{extension}" 267 ets_file.write_text(source_code) 268 return ets_file 269 270 271@fixture 272def code_compiler( 273 tmp_path: Path, 274 ark_compiler: Compiler, 275) -> StringCodeCompiler: 276 """ 277 Return :class:`StringCodeCompiler` instance that can compile ``sts``-file. 278 """ 279 return StringCodeCompiler( 280 tmp_path=tmp_path, 281 ark_compiler=ark_compiler, 282 ) 283