diff --git a/.gitignore b/.gitignore index 97e2ef4..0cdf3cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ working/ -reports/ \ No newline at end of file +reports/ +*__pycache__* +*.venv +.doit.db diff --git a/dodo.py b/dodo.py new file mode 100644 index 0000000..ae3feb5 --- /dev/null +++ b/dodo.py @@ -0,0 +1,78 @@ +from pathlib import Path +from tasks.generate_prj import generate_prj +from tasks.generate_scr import generate_scr +from tools.config import parse_project_cfg +from tools.defaults import with_defaults +import subprocess +import os +from pathlib import Path + +HERE = Path(__file__).parent.resolve() # Verzeichnis, in dem dodo.py liegt +CFG_PATH = HERE.parent / "project.cfg" +TOML_PATH = HERE.parent / "vhdl_ls.toml" + + +def _get_project_name(cfg_path: Path) -> str: + return with_defaults(parse_project_cfg(cfg_path))["PROJECT"] + + +def task_generate_prj(): + cfg_path = Path(CFG_PATH) + toml_path = Path(TOML_PATH) + project = _get_project_name(cfg_path) + prj_path = Path("working") / f"{project}.prj" + + return { + "actions": [(generate_prj, [prj_path, cfg_path, toml_path])], + "file_dep": [cfg_path, toml_path], + "targets": [prj_path], + "verbosity": 2, + } + + +def task_generate_scr(): + cfg_path = Path(CFG_PATH) + project = _get_project_name(cfg_path) + scr_path = Path("working") / f"{project}.scr" + + return { + "actions": [(generate_scr, [scr_path, cfg_path])], + "file_dep": [cfg_path], + "targets": [scr_path], + "verbosity": 2, + } + + +def task_xst(): + """Run XST synthesizer""" + cfg_path = Path(CFG_PATH) + cfg = with_defaults(parse_project_cfg(cfg_path)) + + build_dir = Path(cfg["BUILD_DIR"]) + project = cfg["PROJECT"] + scr_file = f"{project}.scr" + scr_path = build_dir / scr_file + prj_path = build_dir / f"{project}.prj" + xilinx_path = Path(cfg["XILINX"]) + xilinx_platform = "lin64" # TODO: dynamisch ermitteln, falls nötig + + xst_exe = xilinx_path / "bin" / xilinx_platform / "xst" + common_opts = cfg.get("COMMON_OPTS", "") + + def action(): + print(f"============ Running XST ============") + print(f"> {xst_exe} {common_opts} -ifn {scr_file}") + subprocess.run( + [str(xst_exe), "-ifn", scr_file], + cwd=build_dir, + check=True + ) + + + return { + "actions": [action], + "file_dep": [scr_path, prj_path], # <== beides! + "targets": [Path(cfg["REPORT_DIR"]) / f"{project}.SynthesisReport"], + "verbosity": 2, + "task_dep": ["generate_scr", "generate_prj"], + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..20c407f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +toml +pytest +doit diff --git a/tasks/generate_prj.py b/tasks/generate_prj.py new file mode 100644 index 0000000..9c42734 --- /dev/null +++ b/tasks/generate_prj.py @@ -0,0 +1,14 @@ +import os +from tools.config import get_vhdl_sources_and_tests +from tools.paths import REL_FROM_WORKING_TO_ROOT as REL_ROOT, ROOT, WORKING +from pathlib import Path + +def generate_prj(prj_path: Path, *_): + sources, _ = get_vhdl_sources_and_tests() + prj_path.parent.mkdir(parents=True, exist_ok=True) + + with prj_path.open("w", encoding="utf-8") as f: + for libname, files in sources.items(): + for file in files: + rel_path = os.path.relpath(ROOT / file, WORKING) + f.write(f"vhdl {libname} {rel_path}\n") diff --git a/tasks/generate_scr.py b/tasks/generate_scr.py new file mode 100644 index 0000000..46067eb --- /dev/null +++ b/tasks/generate_scr.py @@ -0,0 +1,33 @@ +from tools.config import parse_project_cfg +from pathlib import Path + + +def generate_scr(scr_path: Path, cfg_path: Path): + cfg = parse_project_cfg(cfg_path) + + project = cfg["PROJECT"] + top = cfg.get("TOPLEVEL", project) + target_part = cfg["TARGET_PART"] + xst_opts = cfg.get("XST_OPTS", "") + + # SCR-Datei vollständig in einer Zeile, wie im Makefile + scr_parts = [ + "run", + f"-ifn {project}.prj", + f"-ofn {project}.ngc", + "-ifmt mixed", + ] + + # Optional: Optionen aus XST_OPTS hinzufügen + if xst_opts.strip(): + scr_parts += xst_opts.strip().split() + + scr_parts += [ + f"-top {top}", + "-ofmt NGC", + f"-p {target_part}", + ] + + scr_line = " ".join(scr_parts).replace("\n", " ").replace("\r", " ") + scr_path.parent.mkdir(parents=True, exist_ok=True) + scr_path.write_text(scr_line, encoding="utf-8") diff --git a/tasks/run_xst.py b/tasks/run_xst.py new file mode 100644 index 0000000..a81242e --- /dev/null +++ b/tasks/run_xst.py @@ -0,0 +1,32 @@ +import subprocess +from tools.paths import WORKING, REPORTS, PROJECT_CFG +from tools.config import parse_project_cfg +from tools.defaults import with_defaults +from pathlib import Path + + +def run_xst(): + cfg = with_defaults(parse_project_cfg(PROJECT_CFG)) + + project = cfg["PROJECT"] + scr_file = f"{project}.scr" + scr_path = WORKING / scr_file + xilinx_path = Path(cfg["XILINX"]) + xilinx_platform = "lin64" # TODO: Optional dynamisch machen + xst_exe = xilinx_path / "bin" / xilinx_platform / "xst" + common_opts = cfg.get("COMMON_OPTS", "") + + if not xst_exe.exists(): + raise FileNotFoundError(f"xst executable not found at {xst_exe}") + + if not scr_path.exists(): + raise FileNotFoundError(f"SCR file not found: {scr_path}") + + print(f"\n============ Running XST ============\n") + print(f"> {xst_exe} {common_opts} -ifn {scr_file}\n") + + subprocess.run( + [str(xst_exe), *common_opts.split(), "-ifn", scr_file], + cwd=WORKING, + check=True + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..30645a1 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,72 @@ +from tools.config import parse_vhdl_ls_toml +from pathlib import Path +import tempfile +import textwrap +import pytest + + +def test_parsing_skips_third_party_libs(): + content = textwrap.dedent(""" + [libraries.lib] + files = ["src/main.vhd"] + + [libraries.XILINX] + files = ["/opt/Xilinx/abc.vhd"] + is_third_party = true + """) + with tempfile.NamedTemporaryFile("w+", suffix=".toml", delete=False) as f: + f.write(content) + f.flush() + result = parse_vhdl_ls_toml(Path(f.name)) + + assert "work" in result + assert "XILINX" not in result + assert result["work"] == [Path("src/main.vhd")] + + +def test_parsing_multiple_libraries(): + content = textwrap.dedent(""" + [libraries.lib] + files = ["src/foo.vhd", "src/bar.vhd"] + + [libraries.myip] + files = ["ipcore/top.vhd"] + """) + with tempfile.NamedTemporaryFile("w+", suffix=".toml", delete=False) as f: + f.write(content) + f.flush() + result = parse_vhdl_ls_toml(Path(f.name)) + + assert "work" in result + assert "myip" in result + assert result["work"] == [Path("src/foo.vhd"), Path("src/bar.vhd")] + assert result["myip"] == [Path("ipcore/top.vhd")] + + +def test_empty_toml_file(): + with tempfile.NamedTemporaryFile("w+", suffix=".toml", delete=False) as f: + f.write("") # Empty file + f.flush() + result = parse_vhdl_ls_toml(Path(f.name)) + + assert result == {} + + +def test_missing_file_raises_error(): + with pytest.raises(FileNotFoundError): + parse_vhdl_ls_toml(Path("nonexistent.toml")) + + +def test_libname_translation_to_work(): + content = textwrap.dedent(""" + [libraries.lib] + files = ["src/xyz.vhd"] + """) + with tempfile.NamedTemporaryFile("w+", suffix=".toml", delete=False) as f: + f.write(content) + f.flush() + result = parse_vhdl_ls_toml(Path(f.name)) + + assert "work" in result + assert "lib" not in result + assert result["work"] == [Path("src/xyz.vhd")] diff --git a/tests/test_generate_prj.py b/tests/test_generate_prj.py new file mode 100644 index 0000000..1c93189 --- /dev/null +++ b/tests/test_generate_prj.py @@ -0,0 +1,47 @@ +import tempfile +from pathlib import Path +from tasks.generate_prj import generate_prj + + +def write_file(path: Path, content: str): + path.write_text(content.strip(), encoding="utf-8") + + +def test_generate_prj_single_lib(tmp_path): + cfg_path = tmp_path / "project.cfg" + toml_path = tmp_path / "vhdl_ls.toml" + prj_path = tmp_path / "working" / "MyProject.prj" + + write_file(cfg_path, "PROJECT = MyProject\nTARGET_PART = dummy\nXILINX = /some/path") + write_file(toml_path, """ + [libraries.lib] + files = ["src/main.vhd"] + """) + + generate_prj(prj_path, cfg_path, toml_path) + + assert prj_path.exists() + content = prj_path.read_text() + assert "vhdl work ../src/main.vhd" in content + + +def test_generate_prj_multiple_libs(tmp_path): + cfg_path = tmp_path / "project.cfg" + toml_path = tmp_path / "vhdl_ls.toml" + prj_path = tmp_path / "working" / "MyProject.prj" + + write_file(cfg_path, "PROJECT = MyProject\nTARGET_PART = dummy\nXILINX = /some/path") + write_file(toml_path, """ + [libraries.lib] + files = ["src/A.vhd", "src/B.vhd"] + + [libraries.otherlib] + files = ["src/C.vhd"] + """) + + generate_prj(prj_path, cfg_path, toml_path) + + content = prj_path.read_text() + assert "vhdl work ../src/A.vhd" in content + assert "vhdl work ../src/B.vhd" in content + assert "vhdl otherlib ../src/C.vhd" in content diff --git a/tests/test_generate_scr.py b/tests/test_generate_scr.py new file mode 100644 index 0000000..d61a1be --- /dev/null +++ b/tests/test_generate_scr.py @@ -0,0 +1,44 @@ +from pathlib import Path +from tasks.generate_scr import generate_scr + + +def write_file(path: Path, content: str): + path.write_text(content.strip(), encoding="utf-8") + + +def test_generate_scr_basic(tmp_path): + cfg_path = tmp_path / "project.cfg" + scr_path = tmp_path / "working" / "MyProject.scr" + + write_file(cfg_path, """ + PROJECT = MyProject + TARGET_PART = xc3s50-4-pq208 + XILINX = /some/path + """) + + generate_scr(scr_path, cfg_path) + + content = scr_path.read_text() + assert "-ifn MyProject.prj" in content + assert "-ofn MyProject.ngc" in content + assert "-top MyProject" in content + assert "-p xc3s50-4-pq208" in content + + +def test_generate_scr_with_top_and_opts(tmp_path): + cfg_path = tmp_path / "project.cfg" + scr_path = tmp_path / "working" / "MyProject.scr" + + write_file(cfg_path, """ + PROJECT = MyProject + TARGET_PART = xc3s200-5-ft256 + TOPLEVEL = TopModule + XST_OPTS = -opt_mode Speed -opt_level 2 + XILINX = /some/path + """) + + generate_scr(scr_path, cfg_path) + + content = scr_path.read_text() + assert "-top TopModule" in content + assert "-opt_mode Speed -opt_level 2" in content diff --git a/tests/test_project_cfg.py b/tests/test_project_cfg.py new file mode 100644 index 0000000..75836f1 --- /dev/null +++ b/tests/test_project_cfg.py @@ -0,0 +1,51 @@ +from build.tools.config import parse_project_cfg +from pathlib import Path +import tempfile +import textwrap + + +def test_parse_complex_project_cfg_correctly(): + cfg = textwrap.dedent(""" + ## Main settings.. ## + PROJECT = SpriteChannel + TARGET_PART = xc3s1200e-4-fg320 + XILINX = /opt/Xilinx/14.7/ISE_DS/ISE + TOPLEVEL = SpriteChannel + CONSTRAINTS = src/SpriteChannel.ucf + + # Sources (should be ignored) + VHDSOURCE += src/GenericCounter.vhd + VSOURCE += src/something.v + VHDTEST += test/SpriteChannel_tb.vhd + + # Options + XST_OPTS += -opt_mode Speed + XST_OPTS += -opt_level 2 + MAP_OPTS = -detail -timing -ol high + PAR_OPTS = -ol high + BITGEN_OPTS = -g StartupClk:JtagClk + + # Programmer + PROGRAMMER = digilent + """) + + with tempfile.NamedTemporaryFile("w+", suffix=".cfg", delete=False) as f: + f.write(cfg) + f.flush() + result = parse_project_cfg(Path(f.name)) + + assert result["PROJECT"] == "SpriteChannel" + assert result["TARGET_PART"] == "xc3s1200e-4-fg320" + assert result["XILINX"] == "/opt/Xilinx/14.7/ISE_DS/ISE" + assert result["TOPLEVEL"] == "SpriteChannel" + assert result["CONSTRAINTS"] == "src/SpriteChannel.ucf" + assert result["XST_OPTS"] == "-opt_mode Speed -opt_level 2" + assert result["MAP_OPTS"] == "-detail -timing -ol high" + assert result["PAR_OPTS"] == "-ol high" + assert result["BITGEN_OPTS"] == "-g StartupClk:JtagClk" + assert result["PROGRAMMER"] == "digilent" + + # Sicherstellen, dass SOURCE-Felder ignoriert wurden + assert "VHDSOURCE" not in result + assert "VSOURCE" not in result + assert "VHDTEST" not in result diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/config.py b/tools/config.py new file mode 100644 index 0000000..95d920b --- /dev/null +++ b/tools/config.py @@ -0,0 +1,88 @@ +from tools.paths import ROOT, PROJECT_CFG, VHDL_LS_TOML +from pathlib import Path +from typing import Dict, List, Optional +import toml +import re +from typing import Tuple +from tools.defaults import with_defaults + +def parse_vhdl_ls_toml(toml_path: Optional[Path] = None) -> Dict[str, List[Path]]: + if toml_path is None: + toml_path = VHDL_LS_TOML + + if not toml_path.exists(): + raise FileNotFoundError(f"{toml_path} not found.") + + with open(toml_path, "r", encoding="utf-8") as f: + raw = toml.load(f) + + libraries = raw.get("libraries", {}) + parsed: Dict[str, List[Path]] = {} + + for libname, content in libraries.items(): + if content.get("is_third_party", False): + continue + files = content.get("files", []) + actual_libname = "work" if libname == "lib" else libname + parsed[actual_libname] = [Path(f) for f in files] + + return parsed + + +def parse_project_cfg(cfg_path: Optional[Path] = None) -> Dict[str, str]: + cfg_path = cfg_path or PROJECT_CFG + + if not cfg_path.exists(): + raise FileNotFoundError(f"{cfg_path} not found.") + + result: Dict[str, str] = {} + ignored_keys = {"VHDSOURCE", "VSOURCE", "VHDTEST", "VTEST"} + + with cfg_path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + + # Kommentare und Leerzeilen überspringen + if not line or line.startswith("#"): + continue + + if "+=" in line: + key, value = map(str.strip, line.split("+=", 1)) + if key in ignored_keys: + continue + if key in result: + result[key] += f" {value}" + else: + result[key] = value + elif "=" in line: + key, value = map(str.strip, line.split("=", 1)) + if key in ignored_keys: + continue + result[key] = value + + return result + +def get_vhdl_sources_and_tests() -> Tuple[Dict[str, List[Path]], Dict[str, List[Path]]]: + cfg = with_defaults(parse_project_cfg()) + test_filter = cfg.get("TEST_FILTER", r"^tests/|_tb\.vhd$") # Default-Fallback + + try: + pattern = re.compile(test_filter) + except re.error as e: + raise ValueError(f"Invalid TEST_FILTER regex: {e}") + + all_sources = parse_vhdl_ls_toml() + + normal_sources: Dict[str, List[Path]] = {} + test_sources: Dict[str, List[Path]] = {} + + for lib, files in all_sources.items(): + for file in files: + # relative Pfade als Strings prüfen + rel_path = str(file) + if pattern.search(rel_path): + test_sources.setdefault(lib, []).append(file) + else: + normal_sources.setdefault(lib, []).append(file) + + return normal_sources, test_sources \ No newline at end of file diff --git a/tools/defaults.py b/tools/defaults.py new file mode 100644 index 0000000..da65fbc --- /dev/null +++ b/tools/defaults.py @@ -0,0 +1,43 @@ +# tools/defaults.py +from typing import Dict + +def get_default_config() -> Dict[str, str]: + return { + "TOPLEVEL": "", # wird dynamisch ersetzt + "CONSTRAINTS": "", + "BUILD_DIR": "working", + "REPORT_DIR": "reports", + "COMMON_OPTS": "-intstyle xflow", + "XST_OPTS": "", + "NGDBUILD_OPTS": "", + "MAP_OPTS": "-detail", + "PAR_OPTS": "", + "BITGEN_OPTS": "", + "TRACE_OPTS": "-v 3 -n 3", + "FUSE_OPTS": "-incremental", + "ISIM_OPTS": "-gui", + "ISIM_CMD": "", + "PROGRAMMER": "none", + "PROGRAMMER_PRE": "", + "IMPACT_OPTS": "-batch impact.cmd", + "DJTG_EXE": "djtgcfg", + "DJTG_DEVICE": "DJTG_DEVICE-NOT-SET", + "DJTG_INDEX": "0", + "DJTG_FLASH_INDEX": "1", + "XC3SPROG_EXE": "xc3sprog", + "XC3SPROG_CABLE": "none", + "XC3SPROG_OPTS": "", + } + +def with_defaults(project_cfg: Dict[str, str]) -> Dict[str, str]: + merged = get_default_config() + merged.update(project_cfg) + + # Fallbacks sicherstellen (kein leerer oder None-Wert bleibt erhalten) + if not merged.get("TOPLEVEL"): + merged["TOPLEVEL"] = merged.get("PROJECT", "") + if not merged.get("CONSTRAINTS"): + merged["CONSTRAINTS"] = f"{merged.get('PROJECT', '')}.ucf" + + # Alle Felder final auf gültigen string casten (zur Sicherheit) + return {k: (v if isinstance(v, str) else str(v)) for k, v in merged.items()} diff --git a/tools/paths.py b/tools/paths.py new file mode 100644 index 0000000..a571964 --- /dev/null +++ b/tools/paths.py @@ -0,0 +1,21 @@ +import os +from pathlib import Path +import sys + +# Ermittle absoluten Pfad dieser Datei +HERE = Path(__file__).resolve() + +# Gehe zurück von tools/ → build/ → Projekt-Root +ROOT = HERE.parent.parent.parent + +# Abgeleitete Pfade +BUILD = ROOT / "build" +WORKING = BUILD / "working" +REPORTS = BUILD / "reports" + +# Relativer Pfad von WORKING zurück zur Projektwurzel +REL_FROM_WORKING_TO_ROOT = Path(os.path.relpath(ROOT, WORKING)) + +# Standard-Konfigurationsdateien +PROJECT_CFG = ROOT / "project.cfg" +VHDL_LS_TOML = ROOT / "vhdl_ls.toml"