• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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