Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
## [Unreleased]

### Fixed
- **`NinjaBackend` could not generate anything.** `_object_path()` is called by
both `_write_ninja()` and `_write_compile_commands()`, but the method itself
was dropped in a merge, so every `generate()` raised
`AttributeError: 'NinjaBackend' object has no attribute '_object_path'`.
The method and its regression tests are restored: objects are named
`obj/<target>/<source>.o`, so a source listed by two targets compiles once
per target instead of both targets claiming one output — which ninja rejects
with `multiple rules generate ...` (`ebuild/build/ninja_backend.py`).
- **`ebuild.build.dispatch` was unimportable.** Two branches independently added
an unhandled-backend `else` clause to `BackendDispatcher.configure()`; the
merge kept both, leaving a second `else` after the first and a `SyntaxError`
Expand Down
16 changes: 16 additions & 0 deletions ebuild/build/ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,22 @@ def generate(self) -> None:
self._write_ninja()
self._write_compile_commands()

def _object_path(self, target, src: str) -> Path:
"""Object file path for *src* as compiled by *target*.

Object paths are namespaced by target name. Two targets may legitimately
list the same source: a library and a test binary sharing a helper, or
one source built twice with different defines. Each needs its own
object, because each compiles with its own cflags. Keying only on the
source made both targets claim one output, which ninja rejects with
"multiple rules generate ...".

Example:
>>> backend._object_path(target, "src/main.c") # target.name == "app"
PosixPath('_build/obj/app/src/main.o')
"""
return (self.build_dir / "obj" / target.name / src).with_suffix(".o")

def _get_toolchain_cflags(self) -> List[str]:
"""Return toolchain-level cflags including sysroot.

Expand Down
129 changes: 128 additions & 1 deletion tests/unit/test_ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@

"""Unit tests for ebuild.build.ninja_backend.NinjaBackend."""

import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace

from ebuild.build.ninja_backend import NinjaBackend, _ninja_path
import pytest

from ebuild.build.ninja_backend import NinjaBackend
from ebuild.build.toolchain import ResolvedToolchain
from ebuild.core.config import ProjectConfig, TargetConfig


Expand Down Expand Up @@ -117,5 +122,127 @@ def test_every_build_statement_has_one_unescaped_colon(self):
self.assertEqual(stripped.count(":"), 1, line)


@pytest.mark.ebuild
class TestObjectPathNamespacing:
"""Object files are namespaced by target: ``obj/<target>/<source>.o``.

Two targets may legitimately list the same source -- a library and a test
binary sharing a helper, or one source built twice with different defines.
Naming an object from the source alone makes both targets claim one
output, which ninja rejects with "multiple rules generate ...", and
silently drops one target's cflags before it ever gets there.
"""

@staticmethod
def _shared_source_config(tmp_path, app_sources):
return ProjectConfig(
name="shared_source",
version="1.0.0",
targets=[
TargetConfig(
name="util",
target_type="static_library",
sources=["src/util.c"],
defines=["BUILD_LIB=1"],
),
TargetConfig(
name="app",
target_type="executable",
sources=app_sources,
defines=["BUILD_APP=1"],
),
],
source_dir=tmp_path,
)

def test_shared_source_gets_one_object_per_target(self, tmp_path):
"""A source used by two targets must compile to two distinct objects."""
config = self._shared_source_config(tmp_path, ["src/main.c", "src/util.c"])

build_dir = tmp_path / "_build"
NinjaBackend(config, build_dir, ResolvedToolchain()).generate()

ninja_content = (build_dir / "build.ninja").read_text(encoding="utf-8")

# Split on the rule separator rather than the first colon: on Windows
# the object path starts with a drive letter.
outputs = [
line[len("build "):].split(": cc ", 1)[0].strip()
for line in ninja_content.splitlines()
if line.startswith("build ") and ": cc " in line
]
assert len(outputs) == 3, f"expected 3 compile edges, got {outputs}"
assert len(set(outputs)) == 3, f"duplicate object outputs: {outputs}"

# Each target's defines must survive onto its own object.
assert "-DBUILD_LIB=1" in ninja_content
assert "-DBUILD_APP=1" in ninja_content

def test_shared_source_manifest_is_valid_ninja(self, tmp_path, monkeypatch):
"""The generated manifest must load in real ninja, not just look right.

Ninja treats two edges producing one output as an error, so this is
the check that actually proves the generated build is usable.

The build directory is relative and the working directory is the
project root, mirroring how ``ebuild build`` invokes the backend.
"""
pytest.importorskip("ninja", reason="ninja package not installed")

src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "util.c").write_text(
"int util_answer(void) { return 42; }\n", encoding="utf-8"
)
(src_dir / "main.c").write_text(
"int util_answer(void);\n"
"int main(void) { return util_answer() == 42 ? 0 : 1; }\n",
encoding="utf-8",
)

config = self._shared_source_config(tmp_path, ["src/main.c", "src/util.c"])

monkeypatch.chdir(tmp_path)
build_dir = Path("_build")
NinjaBackend(config, build_dir, ResolvedToolchain()).generate()

# -n parses and validates the manifest without running the compiler,
# so this test needs no toolchain on the machine running it.
result = subprocess.run(
[sys.executable, "-m", "ninja", "-f", str(build_dir / "build.ninja"), "-n"],
cwd=str(tmp_path),
capture_output=True,
text=True,
)
assert result.returncode == 0, (
"ninja rejected the generated manifest:\n"
f"{result.stdout}\n{result.stderr}"
)

def test_compile_commands_distinguishes_shared_source_entries(self, tmp_path):
"""compile_commands.json entries for a shared source must differ.

Two targets compiling one file legitimately produce two entries; each
needs its own -o so a consumer can tell them apart.
"""
config = self._shared_source_config(tmp_path, ["src/util.c"])

build_dir = tmp_path / "_build"
NinjaBackend(config, build_dir, ResolvedToolchain()).generate()

cc_data = json.loads(
(build_dir / "compile_commands.json").read_text(encoding="utf-8")
)
shared = [e for e in cc_data if e["file"] == "src/util.c"]
assert len(shared) == 2
assert shared[0]["command"] != shared[1]["command"]

# Differing commands alone are not enough -- the per-target defines
# would differ regardless. It is the object each entry names that has
# to be distinct, which is what a consumer keys on.
objects = [e["command"].split(" -o ", 1)[1].strip() for e in shared]
assert len(set(objects)) == 2, f"entries name the same object: {objects}"


if __name__ == "__main__":
unittest.main()
Loading