278 lines
7.7 KiB
Python
278 lines
7.7 KiB
Python
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import os
|
|
import shutil
|
|
|
|
from ..cli import CLI
|
|
from ..config import Config, ConfigError, load_config
|
|
from ..paths import get_templates_path
|
|
from ..utils import zip_dir
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CompileContext:
|
|
config: Config
|
|
project_root: Path
|
|
build_dir: Path
|
|
entry_path: Path
|
|
output_name: str
|
|
|
|
class BuildError(Exception):
|
|
pass
|
|
|
|
class AstParser:
|
|
def __init__(self, file: Path):
|
|
self.file = self.read_file(file)
|
|
self.labels = []
|
|
|
|
def read_file(self, file: Path) -> str:
|
|
with file.open("r") as f:
|
|
return f.read()
|
|
|
|
def parse_labels(self):
|
|
self.labels = []
|
|
current_label = None
|
|
current_sub_label = None
|
|
|
|
for line in self.file.splitlines():
|
|
stripped_line = line.strip()
|
|
|
|
if stripped_line.endswith(":"):
|
|
label_name = stripped_line[:-1].strip()
|
|
if not label_name:
|
|
continue
|
|
|
|
if label_name.startswith("."):
|
|
if current_label is None:
|
|
raise BuildError(f"Sub-label '{label_name}:' found before any parent label.")
|
|
|
|
sub_label_name = label_name[1:].strip()
|
|
if not sub_label_name:
|
|
continue
|
|
|
|
current_sub_label = {
|
|
"name": sub_label_name,
|
|
"lines": [],
|
|
}
|
|
current_label["sub_labels"].append(current_sub_label)
|
|
continue
|
|
|
|
if current_label is not None:
|
|
self.labels.append(current_label)
|
|
|
|
current_label = {
|
|
"name": label_name,
|
|
"lines": [],
|
|
"sub_labels": [],
|
|
}
|
|
current_sub_label = None
|
|
continue
|
|
|
|
if current_label is None:
|
|
continue
|
|
|
|
if stripped_line == "":
|
|
continue
|
|
|
|
if current_sub_label is not None:
|
|
current_sub_label["lines"].append(stripped_line)
|
|
else:
|
|
current_label["lines"].append(stripped_line)
|
|
|
|
if current_label is not None:
|
|
self.labels.append(current_label)
|
|
|
|
return self.labels
|
|
|
|
def parse(self):
|
|
return self.parse_labels()
|
|
|
|
|
|
class Compiler:
|
|
def __init__(self, context: CompileContext):
|
|
self.context = context
|
|
|
|
def run(self) -> None:
|
|
return None
|
|
|
|
|
|
def _resolve_project_path(project_root: Path, value: Path) -> Path:
|
|
if value.is_absolute():
|
|
return value
|
|
return (project_root / value).resolve()
|
|
|
|
|
|
def _format_to_filename(name: str) -> str:
|
|
formatted = name.strip().lower().replace(" ", "_")
|
|
if not formatted.endswith(".nutsi"):
|
|
formatted += ".nutsi"
|
|
return formatted
|
|
|
|
|
|
def _clear_directory(directory: Path) -> None:
|
|
for child in directory.iterdir():
|
|
if child.is_dir():
|
|
shutil.rmtree(child)
|
|
else:
|
|
child.unlink()
|
|
|
|
|
|
def _is_chipnut_workspace(path: str = ".") -> bool:
|
|
return os.path.isfile(os.path.join(path, "Nutfile"))
|
|
|
|
|
|
def prepare_build_directory(build_dir: Path) -> Path:
|
|
if build_dir.exists() and not build_dir.is_dir():
|
|
raise RuntimeError(f"Build path exists but is not a directory: {build_dir}")
|
|
|
|
build_dir.mkdir(parents=True, exist_ok=True)
|
|
_clear_directory(build_dir)
|
|
|
|
builtins_path = get_templates_path() / "builtins"
|
|
if not builtins_path.is_dir():
|
|
raise RuntimeError(
|
|
"Builtins path does not exist: "
|
|
f"{builtins_path}. Installation may be corrupted."
|
|
)
|
|
|
|
shutil.copytree(
|
|
builtins_path,
|
|
build_dir / "builtins",
|
|
dirs_exist_ok=True,
|
|
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
|
|
)
|
|
|
|
return build_dir
|
|
|
|
|
|
def stage_project_sources(project_root: Path, entry_path: Path, build_dir: Path) -> None:
|
|
if entry_path.is_absolute():
|
|
raise RuntimeError(
|
|
f"build.entry must be a relative path, got absolute path: {entry_path}"
|
|
)
|
|
|
|
source_entry = (project_root / entry_path).resolve()
|
|
if not source_entry.is_file():
|
|
raise RuntimeError(
|
|
f"build.entry does not point to a file: {entry_path} (resolved: {source_entry})"
|
|
)
|
|
|
|
entry_parent = entry_path.parent
|
|
if str(entry_parent) == ".":
|
|
destination_file = build_dir / source_entry.name
|
|
destination_file.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source_entry, destination_file)
|
|
return
|
|
|
|
source_dir = (project_root / entry_parent).resolve()
|
|
destination_dir = build_dir / entry_parent
|
|
|
|
shutil.copytree(
|
|
source_dir,
|
|
destination_dir,
|
|
dirs_exist_ok=True,
|
|
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.nutsi"),
|
|
)
|
|
|
|
|
|
def generate_nutsi_file(build_dir: Path, file_name: str) -> Path:
|
|
output_path = build_dir / file_name
|
|
zip_dir(
|
|
str(build_dir),
|
|
str(output_path),
|
|
ignore_patterns=[
|
|
"__pycache__",
|
|
"*.pyc",
|
|
"*.nutsi",
|
|
],
|
|
)
|
|
return output_path
|
|
|
|
|
|
def _create_context(config: Config, build_folder: Optional[str], file_name: Optional[str]) -> CompileContext:
|
|
project_root = config.path.parent.resolve()
|
|
entry_path = Path(config.build.entry)
|
|
|
|
selected_folder = build_folder or config.build.build_folder
|
|
build_dir = _resolve_project_path(project_root, Path(selected_folder))
|
|
|
|
selected_name = file_name or config.build.name
|
|
output_name = _format_to_filename(selected_name)
|
|
|
|
if build_dir == project_root:
|
|
raise RuntimeError("buildFolder cannot point to project root")
|
|
|
|
return CompileContext(
|
|
config=config,
|
|
project_root=project_root,
|
|
build_dir=build_dir,
|
|
entry_path=entry_path,
|
|
output_name=output_name,
|
|
)
|
|
|
|
|
|
def compile(config: Config, build_folder: Optional[str] = None, file_name: Optional[str] = None) -> Path:
|
|
context = _create_context(config, build_folder=build_folder, file_name=file_name)
|
|
prepared_dir = prepare_build_directory(context.build_dir)
|
|
stage_project_sources(context.project_root, context.entry_path, prepared_dir)
|
|
Compiler(context).run()
|
|
return generate_nutsi_file(prepared_dir, context.output_name)
|
|
|
|
|
|
def main(args, flags) -> int:
|
|
config_path = flags["config"]
|
|
build_folder_override = flags["build_folder"]
|
|
file_name_override = flags["name"]
|
|
|
|
if config_path == "Nutfile" and not _is_chipnut_workspace():
|
|
print("Error: No Nutfile found. Please run 'chipnut init' to create a workspace.")
|
|
return 1
|
|
|
|
try:
|
|
config = load_config(config_path)
|
|
except FileNotFoundError:
|
|
print(f"Error: Config file not found: {config_path}")
|
|
return 1
|
|
except ConfigError as error:
|
|
print(f"Error: Invalid Nutfile: {error}")
|
|
return 1
|
|
|
|
try:
|
|
compile(
|
|
config,
|
|
build_folder=build_folder_override,
|
|
file_name=file_name_override,
|
|
)
|
|
except (RuntimeError, OSError) as error:
|
|
print(f"Error: Build failed: {error}")
|
|
return 1
|
|
|
|
return 0
|
|
|
|
|
|
def setup(cli: CLI):
|
|
cli.command(
|
|
"build",
|
|
help="Compile the project",
|
|
description=(
|
|
"Compile the project according to the configuration in the Nutfile. "
|
|
),
|
|
).option(
|
|
"config",
|
|
"c",
|
|
default="Nutfile",
|
|
help="Path to Nutfile (default: ./Nutfile)",
|
|
).option(
|
|
"build-folder",
|
|
"o",
|
|
default=None,
|
|
help="Override output build folder from config",
|
|
).option(
|
|
"name",
|
|
"n",
|
|
default=None,
|
|
help="Override output file name from config",
|
|
).handle(main)
|