From 8177a25f8bc405e271059f14b6393da2e921d206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abril=20Rinc=C3=B3n=20Blanco?= Date: Wed, 29 Jul 2026 16:58:17 +0200 Subject: [PATCH] Add experimental ZigDeps generator New generator to consume Conan C/C++ dependencies from a Zig `build.zig`. Zig's build system has no format for describing a prebuilt C/C++ library, and does not propagate include or library paths to consumers through linkLibrary(), so there is nothing to emit into. The generator emits Zig source instead: `conan_deps.zig`, a comptime map of every dependency, and `conan_setup.zig`, helpers that a `build.zig` calls. Modelled on CMakeConfigDeps: one target per component keyed "pkg::component", each carrying only its own unmerged information, with explicit `requires` edges that the generated code walks - doing by hand what CMake's target system does natively. Requirement resolution reuses get_transitive_requires(), so requirement traits are honoured from the consumer's perspective rather than read off the dependency. The public API takes a *std.Build.Module rather than a *Step.Compile: in Zig 0.16 every call it makes exists only on Module, and a module is not necessarily an artifact's root, so the same call works for a test module. The C++ runtime is requested for any dependency not declaring `languages = "C"`, since assuming C++ for a C package costs nothing measurable while the reverse fails the link with undefined std:: symbols. It is skipped on the MSVC ABI, where the runtime comes from MSVC itself, and both `link_libc` and `link_libcpp` are only set when the consumer has not decided, so they can be overridden either side of the call. Covers build time only: making shared dependencies loadable at run time is left to Conan's `conanrun` environment or a deployer. Marked experimental - the generated Zig API is expected to change. Tests: 30 integration tests asserting on generated content with no Zig required, and 10 functional tests running a real `zig build` against ConanCenter packages, including variants where the dependencies are built by CMake rather than by `zig cc`. Zig 0.16.0 is registered in test/conftest.py and installed on Linux, macOS and Windows CI. --- .ci/docker/conan-tests | 6 + .github/workflows/osx-tests.yml | 18 +- .github/workflows/win-tests.yml | 21 +- conan/internal/api/install/generators.py | 3 +- conan/tools/zig/__init__.py | 1 + conan/tools/zig/zigdeps.py | 537 +++++++++ test/conftest.py | 8 + test/functional/toolchains/test_zig.py | 1067 +++++++++++++++++ test/integration/toolchains/zig/__init__.py | 0 .../toolchains/zig/test_zigdeps.py | 759 ++++++++++++ 10 files changed, 2416 insertions(+), 4 deletions(-) create mode 100644 conan/tools/zig/__init__.py create mode 100644 conan/tools/zig/zigdeps.py create mode 100644 test/functional/toolchains/test_zig.py create mode 100644 test/integration/toolchains/zig/__init__.py create mode 100644 test/integration/toolchains/zig/test_zigdeps.py diff --git a/.ci/docker/conan-tests b/.ci/docker/conan-tests index 3a62407e337..e391789c3f3 100644 --- a/.ci/docker/conan-tests +++ b/.ci/docker/conan-tests @@ -27,6 +27,7 @@ ENV PY37=3.7.9 \ BAZEL_7=7.6.2 \ BAZEL_8=8.4.2 \ BAZEL_9=9.1.0 \ + ZIG=0.16.0 \ EMSDK=4.0.22 \ INTEL_ONEAPI_VERSION=2026.0 @@ -175,6 +176,11 @@ RUN wget https://github.com/premake/premake-core/releases/download/v5.0.0-beta4/ tar -xvzf premake-5.0.0-beta4-linux.tar.gz && chmod +x premake5 && mkdir /usr/share/premake && \ mv premake5 /usr/share/premake +RUN wget https://ziglang.org/download/$ZIG/zig-x86_64-linux-$ZIG.tar.xz && \ + tar -xJf zig-x86_64-linux-$ZIG.tar.xz && \ + mv zig-x86_64-linux-$ZIG /usr/share/zig-$ZIG && \ + rm zig-x86_64-linux-$ZIG.tar.xz + RUN cd /tmp && \ mkdir qbs && \ cd qbs && \ diff --git a/.github/workflows/osx-tests.yml b/.github/workflows/osx-tests.yml index 19343f5ebc3..8f0e4028efa 100644 --- a/.github/workflows/osx-tests.yml +++ b/.github/workflows/osx-tests.yml @@ -63,7 +63,8 @@ jobs: ~/Applications/bazel/7.6.2 ~/Applications/bazel/8.4.2 ~/Applications/bazel/9.1.0 - key: ${{ runner.os }}-macos26-conan-tools-cache + ~/Applications/zig/0.16.0 + key: ${{ runner.os }}-macos26-conan-tools-cache-zig0160 - name: Build CMake old versions not available for ARM if: steps.cache-tools.outputs.cache-hit != 'true' @@ -114,6 +115,18 @@ jobs: chmod +x ${HOME}/Applications/bazel/${version}/bazel done + - name: Install Zig + if: steps.cache-tools.outputs.cache-hit != 'true' + run: | + set -e + version=0.16.0 + wget -q -O zig-${version}.tar.xz https://ziglang.org/download/${version}/zig-aarch64-macos-${version}.tar.xz + tar -xJf zig-${version}.tar.xz + mkdir -p ${HOME}/Applications/zig + mv zig-aarch64-macos-${version} ${HOME}/Applications/zig/${version} + rm zig-${version}.tar.xz + ${HOME}/Applications/zig/${version}/zig version + osx_tests: needs: osx_setup runs-on: macos-26 @@ -146,7 +159,8 @@ jobs: ~/Applications/bazel/7.6.2 ~/Applications/bazel/8.4.2 ~/Applications/bazel/9.1.0 - key: ${{ runner.os }}-macos26-conan-tools-cache + ~/Applications/zig/0.16.0 + key: ${{ runner.os }}-macos26-conan-tools-cache-zig0160 - name: Select Xcode 26.4.1 run: | diff --git a/.github/workflows/win-tests.yml b/.github/workflows/win-tests.yml index 023a64766cd..3ae4529f0dc 100644 --- a/.github/workflows/win-tests.yml +++ b/.github/workflows/win-tests.yml @@ -189,7 +189,8 @@ jobs: C:\tools\bazel\7.6.2 C:\tools\bazel\8.4.2 C:\tools\bazel\9.1.0 - key: ${{ runner.os }}-conan-tools-cache + C:\tools\zig\0.16.0 + key: ${{ runner.os }}-conan-tools-cache-zig0160 - name: Install CMake versions if: steps.cache-tools.outputs.cache-hit != 'true' @@ -232,6 +233,24 @@ jobs: Remove-Item $zipFile } + - name: Install Zig + if: steps.cache-tools.outputs.cache-hit != 'true' + run: | + $version = "0.16.0" + Write-Host "Downloading Zig $version for Windows..." + $url = "https://ziglang.org/download/$version/zig-x86_64-windows-$version.zip" + $zipFile = "zig-$version-windows-x86_64.zip" + $tmpDir = "C:\tools\zig_tmp" + Invoke-WebRequest -Uri $url -OutFile $zipFile + Expand-Archive -Path $zipFile -DestinationPath $tmpDir -Force + if (-not (Test-Path "C:\tools\zig")) { + New-Item -Path "C:\tools\zig" -ItemType Directory | Out-Null + } + Move-Item -Path "$tmpDir\zig-x86_64-windows-$version" -Destination "C:\tools\zig\$version" + Remove-Item $zipFile + Remove-Item -Recurse -Force $tmpDir + & "C:\tools\zig\$version\zig.exe" version + - name: Prepare environment for functional tests run: | git config --global core.autocrlf false diff --git a/conan/internal/api/install/generators.py b/conan/internal/api/install/generators.py index c1b8ab4f840..7c1d0e91bee 100644 --- a/conan/internal/api/install/generators.py +++ b/conan/internal/api/install/generators.py @@ -37,7 +37,8 @@ "QbsDeps": "conan.tools.qbs", "QbsProfile": "conan.tools.qbs", "CPSDeps": "conan.tools.cps", - "ROSEnv": "conan.tools.ros" + "ROSEnv": "conan.tools.ros", + "ZigDeps": "conan.tools.zig" } diff --git a/conan/tools/zig/__init__.py b/conan/tools/zig/__init__.py new file mode 100644 index 00000000000..ef6b5a2bd66 --- /dev/null +++ b/conan/tools/zig/__init__.py @@ -0,0 +1 @@ +from conan.tools.zig.zigdeps import ZigDeps diff --git a/conan/tools/zig/zigdeps.py b/conan/tools/zig/zigdeps.py new file mode 100644 index 00000000000..a6dfefeef5f --- /dev/null +++ b/conan/tools/zig/zigdeps.py @@ -0,0 +1,537 @@ +import os + +from jinja2 import Environment, StrictUndefined + +from conan.errors import ConanException +from conan.internal import check_duplicated_generator +from conan.internal.model.dependencies import get_transitive_requires +from conan.internal.model.pkg_type import PackageType +from conan.tools.files import save + +# cpp_info fields Zig's build system has no injection point for: a dependency cannot push +# compiler or linker flags onto sources the consumer owns (Module.addCSourceFile applies +# flags only to files added through it, and there is no raw-linker-arg API on Module). +# They are still emitted as data so a consumer can apply them deliberately. +_UNAPPLIABLE_FLAGS = ("cflags", "cxxflags", "sharedlinkflags", "exelinkflags") + + +def _zigstr(value): + """ Escape a value so it can be embedded in a Zig double-quoted string literal """ + result = [] + for ch in str(value): + if ch == "\\": + result.append("\\\\") + elif ch == '"': + result.append('\\"') + elif ch == "\n": + result.append("\\n") + elif ch == "\r": + result.append("\\r") + elif ch == "\t": + result.append("\\t") + elif ord(ch) < 0x20: + result.append("\\x%02x" % ord(ch)) + else: + result.append(ch) + return "".join(result) + + +class ZigDeps: + """ + Generates ``conan_deps.zig``, a comptime map of dependency information (include dirs, + library locations, defines, system libs, frameworks), and ``conan_setup.zig``, a set of + helper functions to consume it from a user ``build.zig``. + + Every requirable "thing" (a package root, or one of its components) becomes a target keyed + as ``"pkgname::targetname"``, mirroring ``CMakeConfigDeps``: a target only carries its own + (unmerged) information, and depends on other targets through an explicit ``requires`` list, + since Zig's build system does not propagate this information transitively on its own. + + Executables from ``tool_requires`` (and application dependencies) are exposed separately as + a path map, since there is nothing to link for those. + + This covers build time only. Making a shared dependency loadable at run time is left to + Conan's own ``conanrun`` environment (or a deployer) rather than handled here - see the + note in the generated ``conan_setup.zig``. + """ + + def __init__(self, conanfile): + self._conanfile = conanfile + + def generate(self): + """ + This method will save the generated files to the ``conanfile.generators_folder`` folder + """ + self._conanfile.output.warning("ZigDeps is experimental, and might get " + "breaking changes in future releases", + warn_tag="experimental") + check_duplicated_generator(self, self._conanfile) + generator_files = self._content() + for generator_file, content in generator_files.items(): + save(self._conanfile, os.path.join("conan_zig_deps", generator_file), content) + + def get_transitive_requires(self, dep): + # Resolved from the consumer's perspective, as requirement traits (visible, + # transitive_headers/libs, replace_requires) live on the require edge, not on ``dep`` + return get_transitive_requires(self._conanfile, dep) + + def _content(self): + targets = {} + exes = {} + flag_deps = set() + host_req = self._conanfile.dependencies.host + test_req = self._conanfile.dependencies.test + + for require, dep in list(host_req.items()) + list(test_req.items()): + full_cpp_info = dep.cpp_info.deduce_full_cpp_info(dep) + self._add_package_targets(require, dep, full_cpp_info, targets, flag_deps) + self._add_package_exes(dep, full_cpp_info, exes) + # tool_requires: nothing to link, but a build.zig needs to be able to find the + # executables. Most tool recipes do not declare cpp_info.exe, so their bindirs are + # what is actually available - Conan itself relies on those, via PATH. + tool_dirs = {} + for _, dep in self._conanfile.dependencies.build.items(): + full_cpp_info = dep.cpp_info.deduce_full_cpp_info(dep) + self._add_package_exes(dep, full_cpp_info, exes) + bindirs = [d.replace("\\", "/") + for d in full_cpp_info.aggregated_components().bindirs] + if bindirs: + tool_dirs[dep.ref.name] = bindirs + + # A "requires" entry can point at something that was deliberately never turned into a + # target (e.g. an executable-only component - there's nothing to link there). Prune + # those instead of leaving a dangling reference that would silently resolve to nothing. + for target in targets.values(): + target["requires"] = [r for r in target["requires"] if r in targets] + + direct_targets = [f"{dep.ref.name}::{dep.ref.name}" + for _, dep in self._conanfile.dependencies.direct_host.items() + if dep.package_type is not PackageType.APP] + direct_targets = [t for t in direct_targets if t in targets] + + if flag_deps: + self._conanfile.output.warning( + "ZigDeps: Zig's build system has no way to apply a dependency's compiler or " + "linker flags to sources it does not own, so these are exposed in " + "conan_deps.zig for you to pass explicitly: " + ", ".join(sorted(flag_deps)), + warn_tag="experimental") + + env = Environment(trim_blocks=True, lstrip_blocks=True, undefined=StrictUndefined) + env.filters["zigstr"] = _zigstr + deps_template = env.from_string(_CONAN_DEPS_TEMPLATE) + context = {"targets": dict(sorted(targets.items())), + "exes": dict(sorted(exes.items())), + "tool_dirs": dict(sorted(tool_dirs.items())), + "direct_targets": direct_targets} + return {"conan_deps.zig": deps_template.render(context), + "conan_setup.zig": _CONAN_SETUP_ZIG} + + @staticmethod + def _add_package_exes(dep, full_cpp_info, exes): + pkg_name = dep.ref.name + components = full_cpp_info.components if full_cpp_info.has_components \ + else {pkg_name: full_cpp_info} + for comp_name, info in components.items(): + if info.exe or info.type is PackageType.APP: + if info.location: + exes[f"{pkg_name}::{comp_name}"] = info.location.replace("\\", "/") + + def _add_package_targets(self, require, dep, full_cpp_info, targets, flag_deps): + pkg_name = dep.ref.name + has_components = full_cpp_info.has_components + components = full_cpp_info.components if has_components else {pkg_name: full_cpp_info} + + all_target_names = [] + for comp_name, info in components.items(): + if info.exe or not (info.frameworks or info.package_framework or info.includedirs + or info.libs or info.objects or info.system_libs or info.defines + or info.requires): + continue # Nothing this target actually contributes + if any(getattr(info, f, None) for f in _UNAPPLIABLE_FLAGS): + flag_deps.add(f"{pkg_name}::{comp_name}") + target_key = f"{pkg_name}::{comp_name}" + targets[target_key] = self._target_data(require, dep, info, has_components) + all_target_names.append(target_key) + + root_key = f"{pkg_name}::{pkg_name}" + if root_key not in targets and all_target_names: + if full_cpp_info.default_components is not None: + # A default component may itself have been skipped (exe-only or empty) + requires = [f"{pkg_name}::{c}" for c in full_cpp_info.default_components] + requires = [r for r in requires if r in targets] + else: + # Every contributing component, not only the ones producing a library: + # a header-only component still carries includedirs, defines and its own + # requires, and would otherwise be unreachable from the package root. + # This is what CMakeConfigDeps' _add_root_lib_target does too. + requires = all_target_names + targets[root_key] = self._interface_target(requires) + + @staticmethod + def _empty_target(): + return {"type": "interface", "include_paths": [], "defines": [], "system_libs": [], + "frameworks": [], "framework_paths": [], "objects": [], "cflags": [], + "cxxflags": [], "link_flags": [], "lib": None, "link_libc": False, + "link_cpp": False, "requires": []} + + @classmethod + def _interface_target(cls, requires): + result = cls._empty_target() + result["requires"] = requires + return result + + @staticmethod + def _is_cpp(dep, info): + """ Whether a dependency needs the C++ runtime linked into its consumer. + + ``languages`` is authoritative when the recipe declares it, so a package saying + ``languages = "C"`` never gets the C++ runtime. It is a newer attribute that most + packages still leave unset, and C++ is the assumption for those, because the two + mistakes are not equally bad: linking the C++ runtime into something that turns out to + be pure C only adds a library that is never used, while leaving it out of a C++ + dependency fails the consumer's link with undefined ``std::`` symbols. + + CMakeConfigDeps emits nothing at all when ``languages`` is unset and lets CMake infer + linkage from the consumer's own ``project()`` languages, which Zig has no equivalent + of. A C package that has not adopted ``languages`` yet can still opt out per + dependency in the consumer's ``build.zig``. + """ + languages = info.languages or dep.languages or [] + return "C++" in languages if languages else True + + def _target_data(self, require, dep, info, has_components): + result = self._empty_target() + result["requires"] = self._requires(dep, info, has_components) + # Every Conan C/C++ package is built against libc, and Zig does not infer that from + # an object file - without it the dependency's own headers fail on things like + # malloc. (Zig only auto-detects libc from system_libs named "m", "pthread", ...) + result["link_libc"] = True + # Not gated on there being a library: the C++ runtime is needed to compile against a + # header-only C++ dependency too + result["link_cpp"] = self._is_cpp(dep, info) + result["system_libs"] = list(info.system_libs) + result["frameworks"] = list(info.frameworks) + result["framework_paths"] = [p.replace("\\", "/") for p in info.frameworkdirs] + result["cflags"] = list(info.cflags) + result["cxxflags"] = list(info.cxxflags) + result["link_flags"] = list(info.sharedlinkflags) + list(info.exelinkflags) + # ``headers`` says whether this consumer may use the dependency's headers at all; + # when it is False, its include dirs and defines must not leak in + if require.headers: + result["include_paths"] = [p.replace("\\", "/") for p in info.includedirs] + result["defines"] = self._defines(info.defines) + if info.package_framework: + # An Apple .framework bundle: link it by name, with its parent as search path + path = info.package_framework.replace("\\", "/") + result["framework_paths"].append(os.path.dirname(path)) + name = os.path.basename(path) + result["frameworks"].append(name[:-len(".framework")] + if name.endswith(".framework") else name) + if require.libs: + result["objects"] = [o.replace("\\", "/") for o in info.objects] + if info.libs: + assert info.location, f"{dep}: cpp_info.location missing for {info.libs}" + is_shared = info.type is PackageType.SHARED + # ``link_location`` is only set when it differs from ``location`` - on + # Windows, where a shared library links against its import lib, not the .dll + link_path = info.link_location or info.location + result["type"] = "shared" if is_shared else "static" + result["lib"] = link_path.replace("\\", "/") + return result + + @staticmethod + def _defines(defines): + # A list of pairs rather than a dict: duplicate names are legal and must not be + # silently collapsed, and the emitted order is the order the recipe declared + result = [] + for define in defines: + if "=" in define: + name, value = define.split("=", 1) + else: + name, value = define, "1" + result.append((name, value)) + return result + + def _requires(self, dep, info, has_components): + requires = info.parsed_requires() + pkg_name = dep.ref.name + transitive_reqs = self.get_transitive_requires(dep) + + if not requires and not has_components: + # No explicit requires: link against all of this package's own direct dependencies + return [f"{d.ref.name}::{d.ref.name}" for d in transitive_reqs.values() + if d.package_type is not PackageType.APP] + + result = [] + for req_pkg, req_comp in requires: + if req_pkg is None: # Points to a component of the same package + result.append(f"{pkg_name}::{req_comp}") + continue + try: + _, req_dep = transitive_reqs.of(req_pkg) + except KeyError: + continue # The transitive dep might have been skipped + if req_dep.package_type is PackageType.APP: + continue # It doesn't make sense to link a package that is an App + # Key off the *resolved* dependency, not the name the recipe wrote: under + # ``replace_requires`` those differ, and targets are always created from the + # resolved name, so using req_pkg here would dangle (and then be pruned away) + req_name = req_dep.ref.name + if req_dep.cpp_info.components.get(req_comp) is not None: + result.append(f"{req_name}::{req_comp}") + elif req_pkg != req_comp: + # Not a component of that package, and not the "pkg::pkg" root form either, + # so the recipe is referring to something that does not exist + raise ConanException(f"{dep} cpp_info requires '{req_pkg}::{req_comp}', but " + f"component '{req_comp}' was not found in '{req_pkg}'") + else: # It must be the interface pkgname::pkgname target + result.append(f"{req_name}::{req_name}") + return result + + +_CONAN_DEPS_TEMPLATE = """\ +// Generated by Conan, do not edit manually + +const std = @import("std"); + +pub const Define = struct { + name: []const u8, + value: []const u8, +}; + +pub const TargetKind = enum { static, shared, interface }; + +pub const Target = struct { + kind: TargetKind, + include_paths: []const []const u8, + defines: []const Define, + system_libs: []const []const u8, + frameworks: []const []const u8, + framework_paths: []const []const u8, + objects: []const []const u8, + /// Path of the library to link, if this target produces one. For a shared library on + /// Windows this is the import library, not the runtime .dll. + lib: ?[]const u8, + /// Whether the consumer needs libc / the C++ runtime linked in for this target. + link_libc: bool, + link_cpp: bool, + /// Compiler and linker flags the dependency declares. Zig has no injection point for + /// these - a dependency cannot add flags to sources it does not own - so they are NOT + /// applied automatically. Pass them yourself, e.g. to Module.addCSourceFile(.flags). + cflags: []const []const u8, + cxxflags: []const []const u8, + link_flags: []const []const u8, + requires: []const []const u8, +}; + +pub const direct_targets: []const []const u8 = &.{ +{% for name in direct_targets %} + "{{ name | zigstr }}", +{% endfor %} +}; + +/// Executables provided by dependencies (tool_requires and application packages), by +/// "pkg::name". There is nothing to link for these - use the path with b.addSystemCommand. +pub const conan_exes = std.StaticStringMap([]const u8).initComptime(.{ +{% for name, path in exes.items() %} + .{ "{{ name | zigstr }}", "{{ path | zigstr }}" }, +{% endfor %} +}); + +/// Directories holding the executables of build-context dependencies (tool_requires), by +/// package name. Conan exposes tools through PATH; this is the same information, so a +/// build.zig can locate one without depending on the ambient environment. +pub const conan_tool_dirs = std.StaticStringMap([]const []const u8).initComptime(.{ +{% for name, dirs in tool_dirs.items() %} + .{ "{{ name | zigstr }}", &.{ {% for d in dirs %}"{{ d | zigstr }}", {% endfor %} } }, +{% endfor %} +}); + +pub const conan_targets = std.StaticStringMap(Target).initComptime(.{ +{% for name, t in targets.items() %} + .{ "{{ name | zigstr }}", Target{ + .kind = .{{ t.type }}, + .include_paths = &.{ {% for p in t.include_paths %}"{{ p | zigstr }}", {% endfor %} }, + .defines = &.{ +{% for dname, dvalue in t.defines %} + .{ .name = "{{ dname | zigstr }}", .value = "{{ dvalue | zigstr }}" }, +{% endfor %} + }, + .system_libs = &.{ {% for l in t.system_libs %}"{{ l | zigstr }}", {% endfor %} }, + .frameworks = &.{ {% for f in t.frameworks %}"{{ f | zigstr }}", {% endfor %} }, + .framework_paths = &.{ {% for f in t.framework_paths %}"{{ f | zigstr }}", {% endfor %} }, + .objects = &.{ {% for o in t.objects %}"{{ o | zigstr }}", {% endfor %} }, +{% if t.lib %} + .lib = "{{ t.lib | zigstr }}", +{% else %} + .lib = null, +{% endif %} + .link_libc = {{ "true" if t.link_libc else "false" }}, + .link_cpp = {{ "true" if t.link_cpp else "false" }}, + .cflags = &.{ {% for f in t.cflags %}"{{ f | zigstr }}", {% endfor %} }, + .cxxflags = &.{ {% for f in t.cxxflags %}"{{ f | zigstr }}", {% endfor %} }, + .link_flags = &.{ {% for f in t.link_flags %}"{{ f | zigstr }}", {% endfor %} }, + .requires = &.{ {% for r in t.requires %}"{{ r | zigstr }}", {% endfor %} }, + } }, +{% endfor %} +}); +""" + +_CONAN_SETUP_ZIG = """\ +// Generated by Conan, do not edit manually + +const std = @import("std"); +const conan_deps = @import("conan_deps.zig"); + +const Module = std.Build.Module; + +/// Targets already applied to a given module, so linking the same dependency twice - whether +/// through a diamond in the requires graph or through two separate calls - applies it once. +var applied: ?std.AutoHashMap(*Module, *std.StringHashMap(void)) = null; + +fn visitedFor(module: *Module) *std.StringHashMap(void) { + const allocator = module.owner.allocator; + if (applied == null) { + applied = std.AutoHashMap(*Module, *std.StringHashMap(void)).init(allocator); + } + const entry = applied.?.getOrPut(module) catch @panic("OOM"); + if (!entry.found_existing) { + const set = allocator.create(std.StringHashMap(void)) catch @panic("OOM"); + set.* = std.StringHashMap(void).init(allocator); + entry.value_ptr.* = set; + } + return entry.value_ptr.*; +} + +fn linkTarget(module: *Module, target: conan_deps.Target) void { + for (target.include_paths) |path| { + // -isystem, not -I: warnings from a dependency's headers are not the consumer's + module.addSystemIncludePath(.{ .cwd_relative = path }); + } + for (target.defines) |define| { + module.addCMacro(define.name, define.value); + } + for (target.system_libs) |lib| { + // Conan already resolved exactly what to link, so don't let Zig second-guess it + // through pkg-config (which it would do by default, use_pkg_config = .yes). + module.linkSystemLibrary(lib, .{ .use_pkg_config = .no }); + } + for (target.framework_paths) |path| { + module.addFrameworkPath(.{ .cwd_relative = path }); + } + for (target.frameworks) |framework| { + module.linkFramework(framework, .{}); + } + for (target.objects) |object| { + module.addObjectFile(.{ .cwd_relative = object }); + } + if (target.lib) |lib| { + module.addObjectFile(.{ .cwd_relative = lib }); + } + // Both of these are ?bool, where null means "not decided yet". Only fill them in when + // the consumer has not said anything, so `mod.link_libcpp = false` opts out regardless + // of whether it is written before or after linking dependencies. + if (target.link_libc and module.link_libc == null) { + module.link_libc = true; + } + if (target.link_cpp and module.link_libcpp == null and !isMsvcAbi(module)) { + // Not on the MSVC ABI: there the C++ runtime comes from MSVC itself, pulled in by + // the /DEFAULTLIB directives its own objects carry. Zig's bundled libc++ cannot be + // built against the MSVC headers (it conflicts on std::type_info) and would be the + // wrong ABI to mix in anyway. + module.link_libcpp = true; + } +} + +fn isMsvcAbi(module: *Module) bool { + const resolved = module.resolved_target orelse return false; + return resolved.result.abi == .msvc; +} + +fn linkDependencyVisited( + module: *Module, + target_name: []const u8, + visited: *std.StringHashMap(void), +) void { + // A "requires" cycle isn't something Conan validates (unlike the package graph itself), + // so guard against it here rather than risk an unbounded recursion / stack overflow. + if (visited.contains(target_name)) return; + visited.put(target_name, {}) catch @panic("OOM"); + const target = conan_deps.conan_targets.get(target_name) orelse return; + linkTarget(module, target); + for (target.requires) |req_name| { + linkDependencyVisited(module, req_name, visited); + } +} + +/// Links a single Conan target (a package root, e.g. "zlib::zlib", or one of its +/// components, e.g. "openssl::ssl") and, transitively, everything it requires. +pub fn linkDependency(module: *Module, target_name: []const u8) void { + if (conan_deps.conan_targets.get(target_name) == null) { + // Fail loudly: a typo here would otherwise surface much later as an unrelated + // undefined-symbol error, with nothing pointing back at this call. + std.debug.print("conan: unknown target '{s}'. Available targets:\\n", .{target_name}); + for (conan_deps.conan_targets.keys()) |available| { + std.debug.print(" {s}\\n", .{available}); + } + @panic("conan: unknown target"); + } + linkDependencyVisited(module, target_name, visitedFor(module)); +} + +/// Links every direct dependency declared by the consumer (and, transitively, everything +/// they require). +pub fn linkDependencies(module: *Module) void { + const visited = visitedFor(module); + for (conan_deps.direct_targets) |name| { + linkDependencyVisited(module, name, visited); + } +} + +/// Absolute path of an executable a dependency declares through cpp_info.exe, keyed +/// "pkg::name". Note most tool recipes do not declare it - prefer toolPath() for those. +pub fn exePath(name: []const u8) []const u8 { + return conan_deps.conan_exes.get(name) orelse { + std.debug.print("conan: unknown executable '{s}'\\n", .{name}); + @panic("conan: unknown executable"); + }; +} + +/// Absolute path of a tool_requires executable, e.g. +/// b.addSystemCommand(&.{ conan.toolPath(b, "flex", "flex") }). Resolved inside the +/// package's own bindir rather than through PATH, so the build does not silently pick up a +/// different copy of the tool from the ambient environment. If a package ships more than +/// one bindir, read conan_deps.conan_tool_dirs directly. +pub fn toolPath(b: *std.Build, pkg: []const u8, exe_name: []const u8) []const u8 { + const dirs = conan_deps.conan_tool_dirs.get(pkg) orelse { + std.debug.print("conan: '{s}' is not a tool_requires here. Available:\\n", .{pkg}); + for (conan_deps.conan_tool_dirs.keys()) |available| { + std.debug.print(" {s}\\n", .{available}); + } + @panic("conan: unknown tool package"); + }; + return b.pathJoin(&.{ dirs[0], exe_name }); +} + +// NOTE ON RUNTIME DISCOVERY +// This only makes dependencies available at *build* time. Making a shared dependency +// loadable at *run* time is deliberately left to Conan rather than handled here: +// activate the "conanrun" environment Conan generates for exactly this purpose (it sets +// PATH on Windows and (DY)LD_LIBRARY_PATH elsewhere, from every dependency's directories), +// e.g. `self.run("zig build run", env="conanrun")` from a recipe, or by sourcing the +// generated conanrun script directly. `conan install ... --deploy=runtime_deploy` is the +// other option, placing the runtime artifacts in one folder at install time. +// +// Watch out: VirtualRunEnv decides whether to export the library-path variables at all by +// looking at settings.os, so a consumer recipe that declares no `settings` gets a silently +// empty conanrun environment and the libraries stay unfindable. +// +// This differs from a CMake-based consumer, where CMake adds an rpath to build-tree +// binaries by itself, so they run without conanrun (it strips that rpath again on install, +// so installed binaries need the environment either way). Zig has no equivalent behaviour. +// Reproducing it here - emitting rpaths, or copying .dlls next to the executable - was +// intentionally left out of this first version: it duplicates what conanrun already does, +// and neither mechanism has a single obviously-correct form across the platforms Conan +// supports. If real usage shows the environment is not enough, this is the place to +// revisit. +""" diff --git a/test/conftest.py b/test/conftest.py index 447872911e0..ddf16e984d5 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -203,6 +203,14 @@ "root": {"Linux": "/opt/intel/oneapi"} } }, + "zig": { + "default": "0.16.0", + "0.16.0": { + "path": {'Linux': '/usr/share/zig-0.16.0', + 'Windows': 'C:/tools/zig/0.16.0', + 'Darwin': '/Users/runner/Applications/zig/0.16.0'} + } + }, 'xcode_sdk': { "26.0": {"path": {"Darwin": "/Applications/Xcode_26.0.1.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.0.sdk"}}, "26.5": {"path": {"Darwin": "/Applications/Xcode_26.5.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.5.sdk"}}, diff --git a/test/functional/toolchains/test_zig.py b/test/functional/toolchains/test_zig.py new file mode 100644 index 00000000000..6703d17dbb8 --- /dev/null +++ b/test/functional/toolchains/test_zig.py @@ -0,0 +1,1067 @@ +import platform +import textwrap + +import pytest + +from conan.test.utils.tools import TestClient + +# Shared by every test below: generic glue that links whatever ZigDeps generated and runs it. +# Nothing here is package-specific, so a single build.zig/consumer shape covers all scenarios. +_BUILD_ZIG = textwrap.dedent(""" + const std = @import("std"); + const conan_setup = @import("conan_zig_deps/conan_setup.zig"); + + pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const mod = b.createModule(.{ .target = target, .optimize = optimize }); + mod.addCSourceFile(.{ .file = b.path("main.c"), .flags = &.{} }); + mod.link_libc = true; + + const exe = b.addExecutable(.{ .name = "app", .root_module = mod }); + conan_setup.linkDependencies(mod); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + } + """) + + +def _app_conanfile(requires): + # ``settings`` matters here beyond package_id: VirtualRunEnv reads settings.os to decide + # whether to export (DY)LD_LIBRARY_PATH at all, so a consumer that declares no settings + # gets an empty conanrun environment and its shared dependencies stay unfindable. + return textwrap.dedent(""" + from conan import ConanFile + + class App(ConanFile): + settings = "os", "compiler", "build_type", "arch" + requires = "%s" + generators = "ZigDeps" + + def build(self): + # ZigDeps only wires dependencies up for the *build*; making shared libraries + # loadable at *run* time is Conan's job, through the auto-generated conanrun + # environment (PATH on Windows, (DY)LD_LIBRARY_PATH elsewhere). Same idiom as + # test_cps.py and test_cmakeconfigdeps_new_cpp_linkage.py. + self.run("zig build run", env="conanrun") + """) % requires + + +def _main_c(function_name): + return textwrap.dedent(""" + #include + extern int %s(void); + int main(void) { + printf("%s=%%d\\n", %s()); + return 0; + } + """) % (function_name, function_name, function_name) + + +def _build_zig_single_target(target_name): + """ Like _BUILD_ZIG, but calls linkDependency() for one specific target directly instead + of linkDependencies() - which would link every direct dependency wholesale """ + return textwrap.dedent(""" + const std = @import("std"); + const conan_setup = @import("conan_zig_deps/conan_setup.zig"); + + pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const mod = b.createModule(.{ .target = target, .optimize = optimize }); + mod.addCSourceFile(.{ .file = b.path("main.c"), .flags = &.{} }); + mod.link_libc = true; + + const exe = b.addExecutable(.{ .name = "app", .root_module = mod }); + conan_setup.linkDependency(mod, "%s"); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + } + """) % target_name + + +# Conan's Windows binaries are built with MSVC, but Zig defaults to the MinGW (gnu) ABI on +# Windows - and only ships libc for that ABI. Linking an MSVC-produced object in gnu mode +# fails on the /DEFAULTLIB:MSVCRT and /DEFAULTLIB:OLDNAMES directives it carries, which lld +# then looks for under MinGW names (libMSVCRT.a). Tests whose dependencies are built by +# CMake - i.e. by MSVC on Windows - therefore have to ask for the matching ABI. Tests that +# build their dependencies with `zig cc` do not: those are gnu on both sides already. +_BUILD_ZIG_MATCHING_CONAN_ABI = _BUILD_ZIG.replace( + 'const std = @import("std");', + 'const std = @import("std");\nconst builtin = @import("builtin");', 1 +).replace( + " const target = b.standardTargetOptions(.{});", + " // Match the ABI of the Conan binaries: MSVC on Windows, native elsewhere.\n" + " var query: std.Target.Query = .{};\n" + " if (builtin.os.tag == .windows) query.abi = .msvc;\n" + " const target = b.standardTargetOptions(.{ .default_target = query });", 1) + + +@pytest.mark.slow +@pytest.mark.tool("zig") +def test_zigdeps(): + """ A real ``zig build`` links a Conan static-library dependency (with 2 components, + one requiring the other) through the glue generated by ZigDeps, and runs successfully """ + c = TestClient() + liba_conanfile = textwrap.dedent(""" + import os + from conan import ConanFile + from conan.tools.files import copy + + class LibA(ConanFile): + name = "liba" + version = "1.0" + package_type = "static-library" + exports_sources = "comp1.c", "comp1.h", "comp2.c", "comp2.h" + + def build(self): + self.run("zig cc -c comp1.c -o comp1.o") + self.run("zig cc -c comp2.c -o comp2.o") + self.run("zig ar rcs libcomp1.a comp1.o") + self.run("zig ar rcs libcomp2.a comp2.o") + + def package(self): + copy(self, "*.a", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.h", self.build_folder, os.path.join(self.package_folder, "include")) + + def package_info(self): + self.cpp_info.components["comp1"].libs = ["comp1"] + self.cpp_info.components["comp1"].requires = ["comp2"] + self.cpp_info.components["comp2"].libs = ["comp2"] + """) + comp1_h = 'int comp1_value(void);\n' + comp1_c = textwrap.dedent(""" + extern int comp2_value(void); + int comp1_value(void) { return comp2_value() + 1; } + """) + comp2_h = 'int comp2_value(void);\n' + comp2_c = 'int comp2_value(void) { return 41; }\n' + + c.save({"liba/conanfile.py": liba_conanfile, + "liba/comp1.h": comp1_h, + "liba/comp1.c": comp1_c, + "liba/comp2.h": comp2_h, + "liba/comp2.c": comp2_c, + "conanfile.py": _app_conanfile("liba/1.0"), + "build.zig": _BUILD_ZIG, + "main.c": _main_c("comp1_value")}) + c.run("create liba") + c.run("build .") + assert "comp1_value=42" in c.out + + +@pytest.mark.slow +@pytest.mark.tool("cmake") +@pytest.mark.tool("zig") +def test_zigdeps_cmake_deps(): + """ Same graph as test_zigdeps (2 static components, one requiring the other), but the + dependency is built with CMake+the system compiler instead of ``zig cc`` - only the final + consumer uses Zig, proving ZigDeps works on binaries it had no part in producing """ + c = TestClient() + liba_conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.cmake import CMake, CMakeToolchain, cmake_layout + + class LibA(ConanFile): + name = "liba" + version = "1.0" + package_type = "static-library" + settings = "os", "compiler", "build_type", "arch" + exports_sources = "CMakeLists.txt", "comp1.c", "comp1.h", "comp2.c", "comp2.h" + generators = "CMakeToolchain" + + def layout(self): + cmake_layout(self) + + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + def package(self): + cmake = CMake(self) + cmake.install() + + def package_info(self): + self.cpp_info.components["comp1"].libs = ["comp1"] + self.cpp_info.components["comp1"].requires = ["comp2"] + self.cpp_info.components["comp2"].libs = ["comp2"] + """) + liba_cmakelists = textwrap.dedent(""" + cmake_minimum_required(VERSION 3.15) + project(liba C) + add_library(comp2 STATIC comp2.c) + add_library(comp1 STATIC comp1.c) + target_link_libraries(comp1 PUBLIC comp2) + install(TARGETS comp1 comp2 + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin) + install(FILES comp1.h comp2.h DESTINATION include) + """) + comp1_h = 'int comp1_value(void);\n' + comp1_c = textwrap.dedent(""" + extern int comp2_value(void); + int comp1_value(void) { return comp2_value() + 1; } + """) + comp2_h = 'int comp2_value(void);\n' + comp2_c = 'int comp2_value(void) { return 41; }\n' + + c.save({"liba/conanfile.py": liba_conanfile, + "liba/CMakeLists.txt": liba_cmakelists, + "liba/comp1.h": comp1_h, + "liba/comp1.c": comp1_c, + "liba/comp2.h": comp2_h, + "liba/comp2.c": comp2_c, + "conanfile.py": _app_conanfile("liba/1.0"), + "build.zig": _BUILD_ZIG_MATCHING_CONAN_ABI, + "main.c": _main_c("comp1_value")}) + c.run("create liba") + c.run("build .") + assert "comp1_value=42" in c.out + + +def _shared_ext(): + system = platform.system() + if system == "Darwin": + return ".dylib" + if system == "Windows": + return ".dll" + return ".so" + + +def _shared_link_flags(libname): + """ Platform-specific flags a shared library needs at link time: an ``@rpath`` install + name on Darwin (what Conan packages normally carry, and what ``DYLD_LIBRARY_PATH`` from + conanrun resolves by leaf name), a soname on Linux/ELF as the equivalent, and an explicit + import library on Windows (LLD's MinGW-compatible COFF linker, unlike a plain ELF/Mach-O + link, doesn't produce one unless asked - matching the .a naming Conan's own + auto-deduction regex expects) """ + system = platform.system() + if system == "Darwin": + return "-install_name @rpath/lib%s%s" % (libname, _shared_ext()) + if system == "Windows": + return "-Wl,--out-implib,lib%s.a" % libname + return "-Wl,-soname,lib%s%s" % (libname, _shared_ext()) + + +def _export_header(libname, function_name): + """ A portable dllexport/dllimport header for a shared library built directly with + ``zig cc``: on Windows, symbols aren't exported by default (unlike CMake's + WINDOWS_EXPORT_ALL_SYMBOLS), so the .c file defining ``function_name`` must be compiled + with ``-D_EXPORTS`` (matching CMake's own auto-defined macro name) for this to + resolve to dllexport there; any other translation unit merely consuming the header + (without that define) correctly sees dllimport instead """ + return textwrap.dedent(""" + #if defined(_WIN32) && defined(%s_EXPORTS) + #define %s_API __declspec(dllexport) + #elif defined(_WIN32) + #define %s_API __declspec(dllimport) + #else + #define %s_API + #endif + %s_API int %s(void); + """) % (libname, libname.upper(), libname.upper(), libname.upper(), + libname.upper(), function_name) + + +@pytest.mark.slow +@pytest.mark.tool("zig") +def test_zigdeps_shared_chain(): + """ A chain of two shared libraries (shareda -> sharedb): both get linked, and both are + found at runtime through conanrun rather than through anything the generator emits """ + c = TestClient() + ext = _shared_ext() + sharedb_conanfile = textwrap.dedent(""" + import os + from conan import ConanFile + from conan.tools.files import copy + + class SharedB(ConanFile): + name = "sharedb" + version = "1.0" + package_type = "shared-library" + exports_sources = "sharedb.c", "sharedb.h" + + def build(self): + self.run("zig cc -shared -fPIC -Dsharedb_EXPORTS %s -o libsharedb%s sharedb.c") + + def package(self): + # Split by pattern, not by platform: the runtime-loadable file (.so/.dylib/ + # .dll) goes to bindirs on Windows (where Conan's own auto-deduction looks + # for it) and libdirs everywhere else; any import library (.a, Windows-only + # here) always goes to libdirs. Harmless no-ops for the patterns that don't + # apply to the current platform. + copy(self, "*.dll", self.build_folder, os.path.join(self.package_folder, "bin")) + copy(self, "*.so*", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.dylib", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.a", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.h", self.build_folder, os.path.join(self.package_folder, "include")) + + def package_info(self): + self.cpp_info.libs = ["sharedb"] + """) % (_shared_link_flags("sharedb"), ext) + shareda_conanfile = textwrap.dedent(""" + import os + from conan import ConanFile + from conan.tools.files import copy + + class SharedA(ConanFile): + name = "shareda" + version = "1.0" + package_type = "shared-library" + requires = "sharedb/1.0" + exports_sources = "shareda.c", "shareda.h" + + def build(self): + dep = self.dependencies["sharedb"].cpp_info + self.run('zig cc -c -Dshareda_EXPORTS shareda.c -I"' + + dep.includedirs[0] + '" -o shareda.o') + self.run('zig cc -shared -fPIC %s -o libshareda%s shareda.o -L"' + + dep.libdirs[0] + '" -lsharedb') + + def package(self): + copy(self, "*.dll", self.build_folder, os.path.join(self.package_folder, "bin")) + copy(self, "*.so*", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.dylib", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.a", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.h", self.build_folder, os.path.join(self.package_folder, "include")) + + def package_info(self): + self.cpp_info.libs = ["shareda"] + """) % (_shared_link_flags("shareda"), ext) + + sharedb_h = _export_header("sharedb", "shared_b_value") + sharedb_c = textwrap.dedent(""" + #include "sharedb.h" + int shared_b_value(void) { return 10; } + """) + shareda_h = _export_header("shareda", "shared_a_value") + shareda_c = textwrap.dedent(""" + #include "shareda.h" + #include "sharedb.h" + int shared_a_value(void) { return shared_b_value() + 1; } + """) + + c.save({"sharedb/conanfile.py": sharedb_conanfile, + "sharedb/sharedb.h": sharedb_h, + "sharedb/sharedb.c": sharedb_c, + "shareda/conanfile.py": shareda_conanfile, + "shareda/shareda.h": shareda_h, + "shareda/shareda.c": shareda_c, + "conanfile.py": _app_conanfile("shareda/1.0"), + "build.zig": _BUILD_ZIG, + "main.c": _main_c("shared_a_value")}) + c.run("create sharedb") + c.run("create shareda") + c.run("build .") + assert "shared_a_value=11" in c.out + + +@pytest.mark.slow +@pytest.mark.tool("cmake") +@pytest.mark.tool("zig") +def test_zigdeps_shared_chain_cmake_deps(): + """ Same graph as test_zigdeps_shared_chain, but both shared libraries are built with + CMake+the system compiler - only the final consumer uses Zig """ + c = TestClient() + sharedb_conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.cmake import CMake, CMakeToolchain, cmake_layout + + class SharedB(ConanFile): + name = "sharedb" + version = "1.0" + package_type = "shared-library" + settings = "os", "compiler", "build_type", "arch" + exports_sources = "CMakeLists.txt", "sharedb.c", "sharedb.h" + generators = "CMakeToolchain" + + def layout(self): + cmake_layout(self) + + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + def package(self): + cmake = CMake(self) + cmake.install() + + def package_info(self): + self.cpp_info.libs = ["sharedb"] + """) + sharedb_cmakelists = textwrap.dedent(""" + cmake_minimum_required(VERSION 3.15) + project(sharedb C) + add_library(sharedb SHARED sharedb.c) + set_target_properties(sharedb PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) + install(TARGETS sharedb + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin) + install(FILES sharedb.h DESTINATION include) + """) + shareda_conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.cmake import CMake, CMakeDeps, CMakeToolchain, cmake_layout + + class SharedA(ConanFile): + name = "shareda" + version = "1.0" + package_type = "shared-library" + settings = "os", "compiler", "build_type", "arch" + requires = "sharedb/1.0" + exports_sources = "CMakeLists.txt", "shareda.c", "shareda.h" + + def layout(self): + cmake_layout(self) + + def generate(self): + CMakeDeps(self).generate() + CMakeToolchain(self).generate() + + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + def package(self): + cmake = CMake(self) + cmake.install() + + def package_info(self): + self.cpp_info.libs = ["shareda"] + """) + shareda_cmakelists = textwrap.dedent(""" + cmake_minimum_required(VERSION 3.15) + project(shareda C) + find_package(sharedb REQUIRED CONFIG) + add_library(shareda SHARED shareda.c) + set_target_properties(shareda PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) + target_link_libraries(shareda PUBLIC sharedb::sharedb) + install(TARGETS shareda + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin) + install(FILES shareda.h DESTINATION include) + """) + + sharedb_h = "int shared_b_value(void);\n" + sharedb_c = "int shared_b_value(void) { return 10; }\n" + shareda_h = "int shared_a_value(void);\n" + shareda_c = textwrap.dedent(""" + #include "sharedb.h" + int shared_a_value(void) { return shared_b_value() + 1; } + """) + + c.save({"sharedb/conanfile.py": sharedb_conanfile, + "sharedb/CMakeLists.txt": sharedb_cmakelists, + "sharedb/sharedb.h": sharedb_h, + "sharedb/sharedb.c": sharedb_c, + "shareda/conanfile.py": shareda_conanfile, + "shareda/CMakeLists.txt": shareda_cmakelists, + "shareda/shareda.h": shareda_h, + "shareda/shareda.c": shareda_c, + "conanfile.py": _app_conanfile("shareda/1.0"), + "build.zig": _BUILD_ZIG_MATCHING_CONAN_ABI, + "main.c": _main_c("shared_a_value")}) + c.run("create sharedb") + c.run("create shareda") + c.run("build .") + assert "shared_a_value=11" in c.out + + +@pytest.mark.slow +@pytest.mark.tool("zig") +def test_zigdeps_static_shared_static_chain(): + """ statictop (static) -> sharedmid (shared) -> staticleaf (static, private/invisible). + sharedmid statically embeds staticleaf at its own build time, so staticleaf must NOT be + relinked by (or even visible to) the final consumer - only statictop and sharedmid should + show up in the generated ZigDeps map """ + c = TestClient() + ext = _shared_ext() + staticleaf_conanfile = textwrap.dedent(""" + import os + from conan import ConanFile + from conan.tools.files import copy + + class StaticLeaf(ConanFile): + name = "staticleaf" + version = "1.0" + package_type = "static-library" + exports_sources = "staticleaf.c", "staticleaf.h" + + def build(self): + self.run("zig cc -c staticleaf.c -o staticleaf.o") + self.run("zig ar rcs libstaticleaf.a staticleaf.o") + + def package(self): + copy(self, "*.a", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.h", self.build_folder, os.path.join(self.package_folder, "include")) + + def package_info(self): + self.cpp_info.libs = ["staticleaf"] + """) + sharedmid_conanfile = textwrap.dedent(""" + import os + from conan import ConanFile + from conan.tools.files import copy + + class SharedMid(ConanFile): + name = "sharedmid" + version = "1.0" + package_type = "shared-library" + exports_sources = "sharedmid.c", "sharedmid.h" + + def requirements(self): + # Private: fully embedded, must not propagate to further consumers + self.requires("staticleaf/1.0", visible=False) + + def build(self): + dep = self.dependencies["staticleaf"].cpp_info + self.run('zig cc -c -Dsharedmid_EXPORTS sharedmid.c -I"' + + dep.includedirs[0] + '" -o sharedmid.o') + self.run('zig cc -shared -fPIC %s -o libsharedmid%s sharedmid.o -L"' + + dep.libdirs[0] + '" -lstaticleaf') + + def package(self): + copy(self, "*.dll", self.build_folder, os.path.join(self.package_folder, "bin")) + copy(self, "*.so*", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.dylib", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.a", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.h", self.build_folder, os.path.join(self.package_folder, "include")) + + def package_info(self): + self.cpp_info.libs = ["sharedmid"] + """) % (_shared_link_flags("sharedmid"), ext) + statictop_conanfile = textwrap.dedent(""" + import os + from conan import ConanFile + from conan.tools.files import copy + + class StaticTop(ConanFile): + name = "statictop" + version = "1.0" + package_type = "static-library" + requires = "sharedmid/1.0" + exports_sources = "statictop.c", "statictop.h" + + def build(self): + dep = self.dependencies["sharedmid"].cpp_info + self.run('zig cc -c statictop.c -I"' + dep.includedirs[0] + '" -o statictop.o') + self.run("zig ar rcs libstatictop.a statictop.o") + + def package(self): + copy(self, "*.a", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.h", self.build_folder, os.path.join(self.package_folder, "include")) + + def package_info(self): + self.cpp_info.libs = ["statictop"] + """) + + staticleaf_h = "int static_leaf_value(void);\n" + staticleaf_c = "int static_leaf_value(void) { return 100; }\n" + sharedmid_h = _export_header("sharedmid", "shared_mid_value") + sharedmid_c = textwrap.dedent(""" + #include "sharedmid.h" + #include "staticleaf.h" + int shared_mid_value(void) { return static_leaf_value() + 1; } + """) + statictop_h = "int static_top_value(void);\n" + statictop_c = textwrap.dedent(""" + #include "sharedmid.h" + int static_top_value(void) { return shared_mid_value() + 1; } + """) + + c.save({"staticleaf/conanfile.py": staticleaf_conanfile, + "staticleaf/staticleaf.h": staticleaf_h, + "staticleaf/staticleaf.c": staticleaf_c, + "sharedmid/conanfile.py": sharedmid_conanfile, + "sharedmid/sharedmid.h": sharedmid_h, + "sharedmid/sharedmid.c": sharedmid_c, + "statictop/conanfile.py": statictop_conanfile, + "statictop/statictop.h": statictop_h, + "statictop/statictop.c": statictop_c, + "conanfile.py": _app_conanfile("statictop/1.0"), + "build.zig": _BUILD_ZIG, + "main.c": _main_c("static_top_value")}) + c.run("create staticleaf") + c.run("create sharedmid") + c.run("create statictop") + c.run("build .") + assert "static_top_value=102" in c.out + + deps_content = c.load("conan_zig_deps/conan_deps.zig") + assert '"statictop::statictop"' in deps_content + assert '"sharedmid::sharedmid"' in deps_content + assert "staticleaf" not in deps_content # private require, invisible to the consumer + + +@pytest.mark.slow +@pytest.mark.tool("cmake") +@pytest.mark.tool("zig") +def test_zigdeps_static_shared_static_chain_cmake_deps(): + """ Same graph as test_zigdeps_static_shared_static_chain, but all 3 dependencies are + built with CMake+the system compiler - only the final consumer uses Zig """ + c = TestClient() + staticleaf_conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.cmake import CMake, CMakeToolchain, cmake_layout + + class StaticLeaf(ConanFile): + name = "staticleaf" + version = "1.0" + package_type = "static-library" + settings = "os", "compiler", "build_type", "arch" + exports_sources = "CMakeLists.txt", "staticleaf.c", "staticleaf.h" + generators = "CMakeToolchain" + + def layout(self): + cmake_layout(self) + + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + def package(self): + cmake = CMake(self) + cmake.install() + + def package_info(self): + self.cpp_info.libs = ["staticleaf"] + """) + staticleaf_cmakelists = textwrap.dedent(""" + cmake_minimum_required(VERSION 3.15) + project(staticleaf C) + add_library(staticleaf STATIC staticleaf.c) + install(TARGETS staticleaf + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin) + install(FILES staticleaf.h DESTINATION include) + """) + sharedmid_conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.cmake import CMake, CMakeDeps, CMakeToolchain, cmake_layout + + class SharedMid(ConanFile): + name = "sharedmid" + version = "1.0" + package_type = "shared-library" + settings = "os", "compiler", "build_type", "arch" + exports_sources = "CMakeLists.txt", "sharedmid.c", "sharedmid.h" + + def requirements(self): + # Private: fully embedded, must not propagate to further consumers + self.requires("staticleaf/1.0", visible=False) + + def layout(self): + cmake_layout(self) + + def generate(self): + CMakeDeps(self).generate() + CMakeToolchain(self).generate() + + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + def package(self): + cmake = CMake(self) + cmake.install() + + def package_info(self): + self.cpp_info.libs = ["sharedmid"] + """) + sharedmid_cmakelists = textwrap.dedent(""" + cmake_minimum_required(VERSION 3.15) + project(sharedmid C) + find_package(staticleaf REQUIRED CONFIG) + add_library(sharedmid SHARED sharedmid.c) + set_target_properties(sharedmid PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) + target_link_libraries(sharedmid PRIVATE staticleaf::staticleaf) + install(TARGETS sharedmid + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin) + install(FILES sharedmid.h DESTINATION include) + """) + statictop_conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.cmake import CMake, CMakeDeps, CMakeToolchain, cmake_layout + + class StaticTop(ConanFile): + name = "statictop" + version = "1.0" + package_type = "static-library" + settings = "os", "compiler", "build_type", "arch" + requires = "sharedmid/1.0" + exports_sources = "CMakeLists.txt", "statictop.c", "statictop.h" + + def layout(self): + cmake_layout(self) + + def generate(self): + CMakeDeps(self).generate() + CMakeToolchain(self).generate() + + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + def package(self): + cmake = CMake(self) + cmake.install() + + def package_info(self): + self.cpp_info.libs = ["statictop"] + """) + statictop_cmakelists = textwrap.dedent(""" + cmake_minimum_required(VERSION 3.15) + project(statictop C) + find_package(sharedmid REQUIRED CONFIG) + add_library(statictop STATIC statictop.c) + target_link_libraries(statictop PUBLIC sharedmid::sharedmid) + install(TARGETS statictop + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin) + install(FILES statictop.h DESTINATION include) + """) + + staticleaf_h = "int static_leaf_value(void);\n" + staticleaf_c = "int static_leaf_value(void) { return 100; }\n" + sharedmid_h = "int shared_mid_value(void);\n" + sharedmid_c = textwrap.dedent(""" + #include "staticleaf.h" + int shared_mid_value(void) { return static_leaf_value() + 1; } + """) + statictop_h = "int static_top_value(void);\n" + statictop_c = textwrap.dedent(""" + #include "sharedmid.h" + int static_top_value(void) { return shared_mid_value() + 1; } + """) + + c.save({"staticleaf/conanfile.py": staticleaf_conanfile, + "staticleaf/CMakeLists.txt": staticleaf_cmakelists, + "staticleaf/staticleaf.h": staticleaf_h, + "staticleaf/staticleaf.c": staticleaf_c, + "sharedmid/conanfile.py": sharedmid_conanfile, + "sharedmid/CMakeLists.txt": sharedmid_cmakelists, + "sharedmid/sharedmid.h": sharedmid_h, + "sharedmid/sharedmid.c": sharedmid_c, + "statictop/conanfile.py": statictop_conanfile, + "statictop/CMakeLists.txt": statictop_cmakelists, + "statictop/statictop.h": statictop_h, + "statictop/statictop.c": statictop_c, + "conanfile.py": _app_conanfile("statictop/1.0"), + "build.zig": _BUILD_ZIG_MATCHING_CONAN_ABI, + "main.c": _main_c("static_top_value")}) + c.run("create staticleaf") + c.run("create sharedmid") + c.run("create statictop") + c.run("build .") + assert "static_top_value=102" in c.out + + deps_content = c.load("conan_zig_deps/conan_deps.zig") + assert '"statictop::statictop"' in deps_content + assert '"sharedmid::sharedmid"' in deps_content + assert "staticleaf" not in deps_content # private require, invisible to the consumer + + +@pytest.mark.slow +@pytest.mark.tool("zig") +def test_zigdeps_link_single_component(): + """ ``linkDependency()`` can target one specific component directly, instead of using + ``linkDependencies()`` (which links every direct dependency of the consumer wholesale) - + and that component's own transitive dependency must still be pulled in correctly, even + though it isn't the package's root target """ + c = TestClient() + extdep_conanfile = textwrap.dedent(""" + import os + from conan import ConanFile + from conan.tools.files import copy + + class ExtDep(ConanFile): + name = "extdep" + version = "1.0" + package_type = "static-library" + exports_sources = "extdep.c", "extdep.h" + + def build(self): + self.run("zig cc -c extdep.c -o extdep.o") + self.run("zig ar rcs libextdep.a extdep.o") + + def package(self): + copy(self, "*.a", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.h", self.build_folder, os.path.join(self.package_folder, "include")) + + def package_info(self): + self.cpp_info.libs = ["extdep"] + """) + multicomp_conanfile = textwrap.dedent(""" + import os + from conan import ConanFile + from conan.tools.files import copy + + class MultiComp(ConanFile): + name = "multicomp" + version = "1.0" + package_type = "static-library" + requires = "extdep/1.0" + exports_sources = "used.c", "used.h", "other.c", "other.h" + + def build(self): + dep = self.dependencies["extdep"].cpp_info + self.run('zig cc -c used.c -I"' + dep.includedirs[0] + '" -o used.o') + self.run("zig cc -c other.c -o other.o") + self.run("zig ar rcs libused.a used.o") + self.run("zig ar rcs libother.a other.o") + + def package(self): + copy(self, "*.a", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.h", self.build_folder, os.path.join(self.package_folder, "include")) + + def package_info(self): + self.cpp_info.components["used"].libs = ["used"] + self.cpp_info.components["used"].requires = ["extdep::extdep"] + self.cpp_info.components["other"].libs = ["other"] + """) + + extdep_h = "int extdep_value(void);\n" + extdep_c = "int extdep_value(void) { return 5; }\n" + used_h = "int used_value(void);\n" + used_c = textwrap.dedent(""" + #include "extdep.h" + int used_value(void) { return extdep_value() + 1; } + """) + other_h = "int other_value(void);\n" + other_c = "int other_value(void) { return 999; }\n" + + c.save({"extdep/conanfile.py": extdep_conanfile, + "extdep/extdep.h": extdep_h, + "extdep/extdep.c": extdep_c, + "multicomp/conanfile.py": multicomp_conanfile, + "multicomp/used.h": used_h, + "multicomp/used.c": used_c, + "multicomp/other.h": other_h, + "multicomp/other.c": other_c, + "conanfile.py": _app_conanfile("multicomp/1.0"), + "build.zig": _build_zig_single_target("multicomp::used"), + "main.c": _main_c("used_value")}) + c.run("create extdep") + c.run("create multicomp") + c.run("build .") + assert "used_value=6" in c.out + + +@pytest.mark.slow +@pytest.mark.tool("zig") +def test_zigdeps_cyclic_requires_does_not_hang(): + """ Regression test: cpp_info component requires can form a cycle (Conan doesn't validate + against this, unlike the package dependency graph itself) - the generated + linkDependency() must not recurse forever when that happens """ + c = TestClient() + pkg_conanfile = textwrap.dedent(""" + from conan import ConanFile + + class Pkg(ConanFile): + name = "pkg" + version = "1.0" + + def package_info(self): + self.cpp_info.components["a"].includedirs = ["include/a"] + self.cpp_info.components["a"].requires = ["b"] + self.cpp_info.components["b"].includedirs = ["include/b"] + self.cpp_info.components["b"].requires = ["a"] + """) + app_conanfile = textwrap.dedent(""" + from conan import ConanFile + + class App(ConanFile): + requires = "pkg/1.0" + generators = "ZigDeps" + + def build(self): + self.run("zig build") + """) + build_zig = textwrap.dedent(""" + const std = @import("std"); + const conan_setup = @import("conan_zig_deps/conan_setup.zig"); + + pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const mod = b.createModule(.{ .target = target, .optimize = optimize }); + mod.addCSourceFile(.{ .file = b.path("main.c"), .flags = &.{} }); + mod.link_libc = true; + const exe = b.addExecutable(.{ .name = "app", .root_module = mod }); + conan_setup.linkDependency(mod, "pkg::a"); + b.installArtifact(exe); + } + """) + main_c = "int main(void) { return 0; }\n" + + c.save({"pkg/conanfile.py": pkg_conanfile, + "conanfile.py": app_conanfile, + "build.zig": build_zig, + "main.c": main_c}) + c.run("create pkg") + c.run("build .") + + +@pytest.mark.slow +@pytest.mark.tool("zig") +def test_zigdeps_cpp_dependency(): + """ A C++ dependency must get the C++ runtime linked into the consumer. Without it the + link fails with undefined std:: symbols - which every other functional test here misses, + because they are all C-only """ + c = TestClient() + cpplib_conanfile = textwrap.dedent(""" + import os + from conan import ConanFile + from conan.tools.files import copy + + class CppLib(ConanFile): + name = "cpplib" + version = "1.0" + package_type = "static-library" + languages = "C++" + exports_sources = "cpplib.cpp", "cpplib.h" + + def build(self): + self.run("zig c++ -c cpplib.cpp -o cpplib.o") + self.run("zig ar rcs libcpplib.a cpplib.o") + + def package(self): + copy(self, "*.a", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.h", self.build_folder, os.path.join(self.package_folder, "include")) + + def package_info(self): + self.cpp_info.libs = ["cpplib"] + """) + # Uses std::string internally, so the C++ runtime is genuinely required at link time + cpplib_h = 'extern "C" int cpp_value(void);\n' + cpplib_cpp = textwrap.dedent(""" + #include "cpplib.h" + #include + extern "C" int cpp_value(void) { + std::string s = "1234"; + return static_cast(s.size()) + 38; + } + """) + + c.save({"cpplib/conanfile.py": cpplib_conanfile, + "cpplib/cpplib.h": cpplib_h, + "cpplib/cpplib.cpp": cpplib_cpp, + "conanfile.py": _app_conanfile("cpplib/1.0"), + "build.zig": _BUILD_ZIG, + "main.c": _main_c("cpp_value")}) + c.run("create cpplib") + c.run("build .") + assert "cpp_value=42" in c.out + + deps = c.load("conan_zig_deps/conan_deps.zig") + assert ".link_cpp = true" in deps + + +@pytest.mark.slow +@pytest.mark.tool("zig") +def test_zigdeps_cpp_runtime_can_be_opted_out(): + """ link_libc/link_libcpp are ?bool in Zig, where null means "not decided", so ZigDeps + only fills them in when the consumer has not. Setting link_libcpp explicitly therefore + wins regardless of whether it comes before or after linkDependencies(). + + Checked by building for real and looking at what Zig was actually told to link, rather + than at the text of the generated file. """ + c = TestClient() + # A C package: with `languages` unset it is assumed to be C++, so the C++ runtime is + # requested for it - which is exactly what the consumer may want to undo. + pkg = textwrap.dedent(""" + import os + from conan import ConanFile + from conan.tools.files import copy + + class Pkg(ConanFile): + name = "cpkg" + version = "1.0" + package_type = "static-library" + exports_sources = "val.c", "val.h" + + def build(self): + self.run("zig cc -c val.c -o val.o") + self.run("zig ar rcs libval.a val.o") + + def package(self): + copy(self, "*.a", self.build_folder, os.path.join(self.package_folder, "lib")) + copy(self, "*.h", self.build_folder, os.path.join(self.package_folder, "include")) + + def package_info(self): + self.cpp_info.libs = ["val"] + """) + build_zig = textwrap.dedent(""" + const std = @import("std"); + const conan = @import("conan_zig_deps/conan_setup.zig"); + + pub fn build(b: *std.Build) void { + const mod = b.createModule(.{ + .root_source_file = b.path("main.zig"), + .target = b.standardTargetOptions(.{}), + .optimize = b.standardOptimizeOption(.{}), + }); + %s + const exe = b.addExecutable(.{ .name = "app", .root_module = mod }); + b.installArtifact(exe); + } + """) + main_zig = textwrap.dedent(""" + const std = @import("std"); + const c = @cImport({ @cInclude("val.h"); }); + pub fn main() void { std.debug.print("{d}\\n", .{c.val()}); } + """) + app = textwrap.dedent(""" + from conan import ConanFile + + class App(ConanFile): + settings = "os", "compiler", "build_type", "arch" + requires = "cpkg/1.0" + generators = "ZigDeps" + + def build(self): + self.run("zig build --verbose", env="conanrun") + """) + + c.save({"cpkg/conanfile.py": pkg, + "cpkg/val.h": "int val(void);\n", + "cpkg/val.c": "int val(void) { return 7; }\n", + "conanfile.py": app, + "main.zig": main_zig}) + c.run("create cpkg") + + def linked_libcpp(link_block): + c.save({"build.zig": build_zig % link_block}) + c.run("build .") + # --verbose prints the compile command; look at what Zig was actually asked to link + return any(" -lc++" in line for line in c.out.splitlines()) + + assert linked_libcpp("conan.linkDependencies(mod);") is True + assert linked_libcpp("conan.linkDependencies(mod);\n " + "mod.link_libcpp = false;") is False + # …and opting out first must not be overwritten by the generator + assert linked_libcpp("mod.link_libcpp = false;\n " + "conan.linkDependencies(mod);") is False diff --git a/test/integration/toolchains/zig/__init__.py b/test/integration/toolchains/zig/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/toolchains/zig/test_zigdeps.py b/test/integration/toolchains/zig/test_zigdeps.py new file mode 100644 index 00000000000..33ecd4af61e --- /dev/null +++ b/test/integration/toolchains/zig/test_zigdeps.py @@ -0,0 +1,759 @@ +import re + +from conan.test.assets.genconanfile import GenConanfile +from conan.test.utils.tools import TestClient + + +def _targets_section(content): + """ Only the conan_targets map. Executables live in a separate conan_exes map, so + 'is this name absent' assertions must not accidentally match there (or vice versa) """ + return content.split("pub const conan_targets")[1] + + +def _exes_section(content): + return content.split("pub const conan_exes")[1].split("pub const conan_targets")[0] + + +def _target_block(content, target_name): + """ Extract a single target's own ``Target{ ... }`` body, so assertions can check that + data isn't leaking between targets (matching on a bare "pkg::name" substring is not + enough, since that name can also appear inside another target's "requires" list) """ + match = re.search(re.escape(f'.{{ "{target_name}", Target{{') + r"(.*?)\n } },", + content, re.DOTALL) + assert match, f'target "{target_name}" not found in:\n{content}' + return match.group(1) + + +def test_zigdeps_simple_package(): + """ A package without components generates a single "pkg::pkg" target, no redundant + interface indirection """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info(cpp_info={ + "libs": ["mylib"], + "location": '"/fake/pkg/lib/libmylib.a"', + "type": '"static-library"', + "defines": ["FOO=1", "BAR"], + "system_libs": ["pthread"], + }), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert content.count('.{ "pkg::pkg"') == 1 + assert '.kind = .static' in content + assert '.lib = "/fake/pkg/lib/libmylib.a"' in content + assert '.name = "FOO", .value = "1"' in content + assert '.name = "BAR", .value = "1"' in content + assert '"pthread"' in content + + setup = client.load("conan_zig_deps/conan_setup.zig") + # The public API operates on a *std.Build.Module: in Zig 0.16 every call used here + # exists only on Module, not on Step.Compile, and a Module is not necessarily an + # artifact's root module + assert "pub fn linkDependency(module: *Module" in setup + assert "pub fn linkDependencies(module: *Module" in setup + for call in ("addSystemIncludePath", "addObjectFile", "linkSystemLibrary", + "linkFramework", "addCMacro", "addFrameworkPath"): + assert f"module.{call}(" in setup + # Dependency headers are -isystem, so their warnings aren't the consumer's problem + assert "module.addIncludePath(" not in setup + # Conan already resolved what to link; don't let pkg-config override it + assert ".use_pkg_config = .no" in setup + # Runtime discovery is deliberately Conan's job (conanrun), not the generator's + assert "addRPath" not in setup + assert "addInstallFileWithDir" not in setup + + +def test_zigdeps_components_own_data_not_merged(): + """ Each component is its own target, carrying only its own includedirs/libs - not merged + with sibling components - and internal component requires resolve to "pkg::comp" """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info(cpp_info={ + "components": { + "comp1": { + "libs": ["comp1lib"], + "location": '"/fake/pkg/lib/libcomp1lib.a"', + "type": '"static-library"', + "includedirs": ["include/comp1"], + "requires": ["comp2"], + }, + "comp2": { + "libs": ["comp2lib"], + "location": '"/fake/pkg/lib/libcomp2lib.a"', + "type": '"static-library"', + "includedirs": ["include/comp2"], + }, + } + }), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + # comp1's own includedirs must not leak comp2's, and vice versa + comp1_block = _target_block(content, "pkg::comp1") + assert "include/comp1" in comp1_block + assert "include/comp2" not in comp1_block + comp2_block = _target_block(content, "pkg::comp2") + assert "include/comp2" in comp2_block + assert "include/comp1" not in comp2_block + assert '"pkg::comp2"' in comp1_block # internal requires resolved + + # Synthetic root target requires every real lib-producing component + root_block = _target_block(content, "pkg::pkg") + assert '"pkg::comp1"' in root_block + assert '"pkg::comp2"' in root_block + assert ".kind = .interface" in root_block + + +def test_zigdeps_cross_package_component_requires(): + """ A component's cross-package require resolves to "otherpkg::othercomp" when that + component exists, or falls back to "otherpkg::otherpkg" when it doesn't """ + client = TestClient() + client.save({ + "dep/conanfile.py": GenConanfile("dep", "1.0").with_package_info(cpp_info={ + "components": { + "thecomp": {"libs": ["thelib"], "location": '"/fake/dep/lib/libthelib.a"', + "type": '"static-library"'}, + } + }), + "other/conanfile.py": GenConanfile("other", "1.0").with_package_info( + cpp_info={"libs": ["otherlib"], "location": '"/fake/other/lib/libotherlib.a"', + "type": '"static-library"'}), + "pkg/conanfile.py": GenConanfile("pkg", "1.0") + .with_require("dep/1.0") + .with_require("other/1.0") + .with_package_info(cpp_info={ + "components": { + "comp1": { + "libs": ["comp1lib"], + "location": '"/fake/pkg/lib/libcomp1lib.a"', + "type": '"static-library"', + "requires": ["dep::thecomp", "other::other"], + }, + } + }), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create dep") + client.run("create other") + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + comp1_block = _target_block(content, "pkg::comp1") + assert '"dep::thecomp"' in comp1_block # real component in "dep" + assert '"other::other"' in comp1_block # "other" has no components -> falls back to root + + +def test_zigdeps_transitive_chain(): + """ Plain (non-component) packages: liba -> libb -> libc, resolved through + get_transitive_requires (the same helper CMakeConfigDeps uses) """ + client = TestClient() + client.save({ + "libc/conanfile.py": GenConanfile("libc", "1.0").with_package_info( + cpp_info={"libs": ["c"], "location": '"/fake/c/libc.a"', "type": '"static-library"'}), + "libb/conanfile.py": GenConanfile("libb", "1.0").with_require("libc/1.0").with_package_info( + cpp_info={"libs": ["b"], "location": '"/fake/b/libb.a"', "type": '"static-library"'}), + "liba/conanfile.py": GenConanfile("liba", "1.0").with_require("libb/1.0").with_package_info( + cpp_info={"libs": ["a"], "location": '"/fake/a/liba.a"', "type": '"static-library"'}), + "conanfile.py": GenConanfile("app", "1.0").with_require("liba/1.0"), + }) + client.run("create libc") + client.run("create libb") + client.run("create liba") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + direct_targets_block = content.split("direct_targets")[1].split("conan_targets")[0] + assert '"liba::liba"' in direct_targets_block + assert "libb::libb" not in direct_targets_block # only direct deps, transitive linking + # is left to the "requires" recursion + + liba_block = _target_block(content, "liba::liba") + assert '"libb::libb"' in liba_block + libb_block = _target_block(content, "libb::libb") + assert '"libc::libc"' in libb_block + + +def test_zigdeps_windows_shared_links_import_lib(): + """ A Windows shared lib links against the import lib (.lib), not the runtime .dll. + Nothing is emitted to make the .dll findable at run time - that is left to Conan's + conanrun environment, so the .dll path must not appear anywhere """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info(cpp_info={ + "libs": ["mylib"], + "location": '"C:/pkg/bin/mylib.dll"', + "link_location": '"C:/pkg/lib/mylib.lib"', + "type": '"shared-library"', + }), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert ".kind = .shared" in content + assert '.lib = "C:/pkg/lib/mylib.lib"' in content + assert "mylib.dll" not in content + + +def test_zigdeps_unix_shared_links_library_no_rpath(): + """ A Unix shared lib is linked directly, and deliberately gets no rpath - making it + loadable at run time is Conan's job via conanrun, not the generator's """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info(cpp_info={ + "libs": ["mylib"], + "location": '"/fake/pkg/lib/libmylib.so"', + "type": '"shared-library"', + }), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert ".kind = .shared" in content + assert '.lib = "/fake/pkg/lib/libmylib.so"' in content + assert "rpath" not in content + + +def test_zigdeps_header_only_no_lib_entry(): + """ A header-only package/component contributes includedirs/defines but no ``lib`` entry, + and doesn't get skipped just because it has no library file """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info( + cpp_info={"defines": ["HEADER_ONLY"]}), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert '.{ "pkg::pkg"' in content + assert ".kind = .interface" in content + assert ".lib = null" in content + assert '.name = "HEADER_ONLY", .value = "1"' in content + + +def test_zigdeps_header_only_components_get_root_target(): + """ Regression test: a components-based package where NO component produces a lib (all + header-only) must still get a "pkg::pkg" root target, aggregating every contributing + component - otherwise it's silently missing from linkDependencies()'s direct_targets, + even though it's a real direct dependency """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info(cpp_info={ + "components": { + "comp1": {"includedirs": ["include/comp1"], "defines": ["FOO"]}, + "comp2": {"includedirs": ["include/comp2"]}, + } + }), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert '.{ "pkg::pkg"' in content + direct_targets_block = content.split("direct_targets")[1].split("conan_targets")[0] + assert '"pkg::pkg"' in direct_targets_block + root_block = _target_block(content, "pkg::pkg") + assert '"pkg::comp1"' in root_block + assert '"pkg::comp2"' in root_block + + +def test_zigdeps_dangling_component_reference_pruned(): + """ Regression test: a "requires" pointing at an exe-only component (which never becomes + a target, since there's nothing to link) must be pruned rather than left dangling - the + same applies to the analogous package-level (non-component) case """ + client = TestClient() + client.save({ + "dep/conanfile.py": GenConanfile("dep", "1.0").with_package_info(cpp_info={ + "components": { + "lib": {"libs": ["lib"], "location": '"/fake/dep/lib/liblib.a"', + "type": '"static-library"'}, + "tool": {"exe": '"mytool"', "location": '"/fake/dep/bin/mytool"'}, + } + }), + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_require("dep/1.0").with_package_info( + cpp_info={"requires": ["dep::tool", "dep::lib"]}), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create dep") + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert "dep::tool" not in _targets_section(content) # nothing to link for an exe + assert '"dep::tool"' in _exes_section(content) # but its path is still exposed + pkg_block = _target_block(content, "pkg::pkg") + assert '"dep::lib"' in pkg_block + dep_block = _target_block(content, "dep::dep") + assert '"dep::lib"' in dep_block # the auto-created root also excludes the exe component + + +def test_zigdeps_versioned_shared_lib_links_link_location(): + """ A Unix shared lib with a distinct link_location (the common libfoo.so.1.2.3 + + unversioned libfoo.so link-name pattern) links the unversioned name, since that is what + link_location is for - the versioned runtime file is not referenced """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info(cpp_info={ + "libs": ["mylib"], + "location": '"/fake/pkg/lib/libmylib.so.1.2.3"', + "link_location": '"/fake/pkg/lib/libmylib.so"', + "type": '"shared-library"', + }), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert '.lib = "/fake/pkg/lib/libmylib.so"' in content + assert "libmylib.so.1.2.3" not in content + + +def test_zigdeps_control_characters_escaped(): + """ Regression test: a raw control character (not just backslash/quote) reaching + _zigstr must be escaped, or it produces a Zig string literal that fails to compile """ + client = TestClient() + client.save({ + # A real newline, not the two characters backslash-n: GenConanfile reprs this into + # the generated recipe, which parses it back to an actual control character + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info( + cpp_info={"defines": ["WEIRD=a\nb"]}), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + # Escaped into a valid Zig literal, rather than breaking the line in two + assert '.value = "a\\nb"' in content + assert '.value = "a' + chr(10) not in content + + +def test_zigdeps_linkdependency_cycle_guard_present(): + """ The generated setup must guard linkDependency's recursion against a "requires" cycle + (cpp_info component requires are free-form strings Conan doesn't validate for cycles, + unlike the package graph) - see the functional cyclic-requires test for an end-to-end + proof this doesn't crash a real `zig build` """ + client = TestClient() + client.save({"conanfile.py": GenConanfile("app", "1.0")}) + client.run("install . -g ZigDeps") + setup = client.load("conan_zig_deps/conan_setup.zig") + + assert "std.StringHashMap" in setup + assert "visited.contains" in setup + + +def test_zigdeps_default_components(): + """ When cpp_info.default_components is set, the root target requires exactly those + components - not every lib-producing one """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info(cpp_info={ + "default_components": ["comp1"], + "components": { + "comp1": {"libs": ["comp1lib"], "location": '"/fake/pkg/lib/libcomp1lib.a"', + "type": '"static-library"'}, + "comp2": {"libs": ["comp2lib"], "location": '"/fake/pkg/lib/libcomp2lib.a"', + "type": '"static-library"'}, + } + }), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + root_block = _target_block(content, "pkg::pkg") + assert '"pkg::comp1"' in root_block + assert '"pkg::comp2"' not in root_block + + +def test_zigdeps_app_dependency_excluded_from_requires(): + """ A dependency whose cpp_info marks it as an executable (.exe set, regardless of the + recipe's own package_type) never becomes a target, so the implicit "link all direct + deps" fallback _requires uses for a plain package with no explicit .requires must not + leave a dangling reference to it - it doesn't make sense to link an executable """ + client = TestClient() + client.save({ + "tool/conanfile.py": GenConanfile("tool", "1.0").with_package_info( + cpp_info={"exe": '"mytool"', "location": '"/fake/tool/bin/mytool"'}), + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_require("tool/1.0").with_package_info( + cpp_info={"libs": ["pkg"], "location": '"/fake/pkg/lib/libpkg.a"', + "type": '"static-library"'}), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create tool") + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert "tool::tool" not in _targets_section(content) + pkg_block = _target_block(content, "pkg::pkg") + assert "tool" not in pkg_block + + +def test_zigdeps_exe_component_produces_no_target(): + """ A component with .exe set is entirely omitted from conan_deps.zig - there is nothing + to link for an executable """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info(cpp_info={ + "components": { + "lib": {"libs": ["lib"], "location": '"/fake/pkg/lib/liblib.a"', + "type": '"static-library"'}, + "tool": {"exe": '"mytool"', "location": '"/fake/pkg/bin/mytool"'}, + } + }), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert '.{ "pkg::lib"' in content + assert "pkg::tool" not in _targets_section(content) + assert '"pkg::tool"' in _exes_section(content) # exposed as an executable path instead + + +def test_zigdeps_frameworks(): + """ Apple frameworks are collected and rendered for linkFramework() to consume """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info( + cpp_info={"frameworks": ["CoreFoundation", "Security"]}), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + pkg_block = _target_block(content, "pkg::pkg") + assert '"CoreFoundation"' in pkg_block + assert '"Security"' in pkg_block + + setup = client.load("conan_zig_deps/conan_setup.zig") + assert "module.linkFramework(framework, .{})" in setup + + +def test_zigdeps_explicit_root_requires_on_plain_package(): + """ A non-components package that sets cpp_info.requires explicitly (rather than relying + on the implicit "link all direct deps" fallback) resolves through the same + parsed_requires() path a components-based package uses, not the transitive_reqs fallback """ + client = TestClient() + client.save({ + "dep/conanfile.py": GenConanfile("dep", "1.0").with_package_info(cpp_info={ + "components": { + "used": {"libs": ["used"], "location": '"/fake/dep/lib/libused.a"', + "type": '"static-library"'}, + "unused": {"libs": ["unused"], "location": '"/fake/dep/lib/libunused.a"', + "type": '"static-library"'}, + } + }), + "pkg/conanfile.py": GenConanfile("pkg", "1.0") + .with_require("dep/1.0") + .with_package_info(cpp_info={ + "libs": ["pkg"], "location": '"/fake/pkg/lib/libpkg.a"', + "type": '"static-library"', + "requires": ["dep::used"], # only "used", not the whole "dep::dep" root + }), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create dep") + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + pkg_block = _target_block(content, "pkg::pkg") + assert '"dep::used"' in pkg_block + assert '"dep::dep"' not in pkg_block + assert '"dep::unused"' not in pkg_block + + +def test_zigdeps_experimental_warning(): + """ Like every other recently added generator, ZigDeps announces that it is experimental """ + client = TestClient() + client.save({"conanfile.py": GenConanfile("app", "1.0")}) + client.run("install . -g ZigDeps") + assert "ZigDeps is experimental" in client.out + + +def test_zigdeps_cpp_dependency_links_cpp_runtime(): + """ A C++ dependency must ask for the C++ runtime, or the consumer fails to link with + undefined std:: symbols. A dependency declaring itself C must not. """ + client = TestClient() + client.save({ + "cpppkg/conanfile.py": GenConanfile("cpppkg", "1.0") + .with_class_attribute('languages = "C++"') + .with_package_info(cpp_info={"libs": ["cpppkg"], + "location": '"/fake/cpppkg/lib/libcpppkg.a"', + "type": '"static-library"'}), + "cpkg/conanfile.py": GenConanfile("cpkg", "1.0") + .with_class_attribute('languages = "C"') + .with_package_info( + cpp_info={"libs": ["cpkg"], "location": '"/fake/cpkg/lib/libcpkg.a"', + "type": '"static-library"'}), + "conanfile.py": GenConanfile("app", "1.0").with_require("cpppkg/1.0") + .with_require("cpkg/1.0"), + }) + client.run("create cpppkg") + client.run("create cpkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert ".link_cpp = true" in _target_block(content, "cpppkg::cpppkg") + assert ".link_cpp = false" in _target_block(content, "cpkg::cpkg") + + +def test_zigdeps_requirement_traits_headers_and_libs(): + """ headers=False must keep the dependency's include dirs and defines out of the + consumer, and libs=False must keep its library from being linked """ + client = TestClient() + client.save({ + "dep/conanfile.py": GenConanfile("dep", "1.0").with_package_info(cpp_info={ + "libs": ["dep"], "location": '"/fake/dep/lib/libdep.a"', + "type": '"static-library"', "defines": ["DEP_DEFINE"], + }), + "conanfile.py": GenConanfile("app", "1.0").with_requirement( + "dep/1.0", headers=False, libs=False), + }) + client.run("create dep") + client.run("install . -g ZigDeps") + block = _target_block(client.load("conan_zig_deps/conan_deps.zig"), "dep::dep") + + assert "DEP_DEFINE" not in block # headers=False -> no defines + assert ".include_paths = &.{ }," in block # headers=False -> no include dirs + assert ".lib = null" in block # libs=False -> nothing to link + + +def test_zigdeps_test_requires_are_generated(): + """ test_requires must produce targets - a test build needs them just like host ones """ + client = TestClient() + client.save({ + "gtest/conanfile.py": GenConanfile("gtest", "1.0").with_package_info( + cpp_info={"libs": ["gtest"], "location": '"/fake/gtest/lib/libgtest.a"', + "type": '"static-library"'}), + "conanfile.py": GenConanfile("app", "1.0").with_test_requires("gtest/1.0"), + }) + client.run("create gtest") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert '.{ "gtest::gtest"' in _targets_section(content) + + +def test_zigdeps_tool_requires_exposed_as_executables(): + """ A tool_require has nothing to link, but its executable path is what a build.zig + actually wants - exposed through the separate conan_exes map """ + client = TestClient() + client.save({ + "gen/conanfile.py": GenConanfile("gen", "1.0").with_package_type("application") + .with_package_info(cpp_info={"exe": '"mygen"', + "location": '"/fake/gen/bin/mygen"'}), + "conanfile.py": GenConanfile("app", "1.0").with_tool_requires("gen/1.0"), + }) + client.run("create gen") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert '.{ "gen::gen", "/fake/gen/bin/mygen" }' in _exes_section(content) + assert "gen::gen" not in _targets_section(content) + + setup = client.load("conan_zig_deps/conan_setup.zig") + assert "pub fn exePath(" in setup + + +def test_zigdeps_unappliable_flags_are_exposed_and_warned(): + """ Zig has no way to push a dependency's compiler flags onto sources the consumer owns, + so they must be surfaced as data and warned about rather than silently dropped """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info(cpp_info={ + "libs": ["pkg"], "location": '"/fake/pkg/lib/libpkg.a"', + "type": '"static-library"', + "cflags": ["-pthread"], "cxxflags": ["-fno-rtti"], "exelinkflags": ["-Wl,-z,now"], + }), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert '"-pthread"' in content + assert '"-fno-rtti"' in content + assert '"-Wl,-z,now"' in content + assert "cannot apply" in client.out or "pass explicitly" in client.out + + +def test_zigdeps_frameworks_and_package_framework(): + """ frameworkdirs must be emitted as search paths, and a package-shipped .framework + bundle linked by name with its parent directory as the search path """ + client = TestClient() + client.save({ + # frameworkdirs is rebased onto the package folder when relative, and a + # leading-slash path is not absolute on Windows (ntpath.isabs), so an absolute- + # looking POSIX path would come out drive-prefixed there. Relative, like the + # includedirs above, behaves the same everywhere. + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info(cpp_info={ + "frameworks": ["CoreFoundation"], + "frameworkdirs": ["myframeworks"], + }), + "fw/conanfile.py": GenConanfile("fw", "1.0").with_package_info(cpp_info={ + "package_framework": '"/fake/fw/lib/MyFramework.framework"', + "type": '"shared-library"', + }), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0") + .with_require("fw/1.0"), + }) + client.run("create pkg") + client.run("create fw") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + pkg_block = _target_block(content, "pkg::pkg") + assert '"CoreFoundation"' in pkg_block + assert "myframeworks" in pkg_block + + fw_block = _target_block(content, "fw::fw") + assert '"MyFramework"' in fw_block # linked by name, .framework stripped + assert '"/fake/fw/lib"' in fw_block # parent dir as search path + + +def test_zigdeps_duplicate_defines_preserved(): + """ Duplicate define names are legal and must not silently collapse to the last one """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info( + cpp_info={"defines": ["DUP=1", "DUP=2", "OTHER"]}), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + block = _target_block(client.load("conan_zig_deps/conan_deps.zig"), "pkg::pkg") + + assert '.name = "DUP", .value = "1"' in block + assert '.name = "DUP", .value = "2"' in block + + +def test_zigdeps_invalid_component_require_raises(): + """ A cpp_info.requires naming a component that does not exist is a recipe bug, and must + be reported rather than silently remapped onto the package root """ + client = TestClient() + client.save({ + "dep/conanfile.py": GenConanfile("dep", "1.0").with_package_info(cpp_info={ + "components": {"real": {"libs": ["real"], + "location": '"/fake/dep/lib/libreal.a"', + "type": '"static-library"'}}}), + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_require("dep/1.0") + .with_package_info(cpp_info={"requires": ["dep::nonexistent"]}), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create dep") + client.run("create pkg") + client.run("install . -g ZigDeps", assert_error=True) + assert "component 'nonexistent' was not found in 'dep'" in client.out + + +def test_zigdeps_default_components_skipping_missing(): + """ default_components naming a component that was skipped (exe-only) must not leave a + dangling reference behind """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info(cpp_info={ + "default_components": ["lib", "tool"], + "components": { + "lib": {"libs": ["lib"], "location": '"/fake/pkg/lib/liblib.a"', + "type": '"static-library"'}, + "tool": {"exe": '"mytool"', "location": '"/fake/pkg/bin/mytool"'}, + }}), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + root_block = _target_block(client.load("conan_zig_deps/conan_deps.zig"), "pkg::pkg") + + assert '"pkg::lib"' in root_block + assert "pkg::tool" not in root_block # skipped, so not referenced + + +def test_zigdeps_libc_always_requested(): + """ Every Conan C/C++ package is built against libc, and Zig does not infer that from an + object file - without it a dependency's own headers fail on things like malloc """ + client = TestClient() + client.save({ + "pkg/conanfile.py": GenConanfile("pkg", "1.0").with_package_info( + cpp_info={"libs": ["pkg"], "location": '"/fake/pkg/lib/libpkg.a"', + "type": '"static-library"'}), + "conanfile.py": GenConanfile("app", "1.0").with_require("pkg/1.0"), + }) + client.run("create pkg") + client.run("install . -g ZigDeps") + + assert ".link_libc = true" in _target_block( + client.load("conan_zig_deps/conan_deps.zig"), "pkg::pkg") + assert "module.link_libc = true" in client.load("conan_zig_deps/conan_setup.zig") + + +def test_zigdeps_cpp_assumed_when_languages_unset(): + """ Most recipes still do not declare ``languages``, and those are assumed to be C++: + linking the C++ runtime into a package that turns out to be pure C is harmless, while + leaving it out of a C++ one breaks the consumer's link. Declaring ``languages = "C"`` + is what opts out. """ + client = TestClient() + client.save({ + # No languages declared -> assumed C++ + "cpppkg/conanfile.py": GenConanfile("cpppkg", "1.0") + .with_package_info(cpp_info={"libs": ["cpppkg"], + "location": '"/fake/cpppkg/lib/libcpppkg.a"', + "type": '"static-library"'}), + # Declares itself C, so it must NOT get the C++ runtime + "cpkg/conanfile.py": GenConanfile("cpkg", "1.0") + .with_class_attribute('languages = "C"') + .with_package_info(cpp_info={"libs": ["cpkg"], + "location": '"/fake/cpkg/lib/libcpkg.a"', + "type": '"static-library"'}), + "conanfile.py": GenConanfile("app", "1.0") + .with_require("cpppkg/1.0").with_require("cpkg/1.0"), + }) + client.run("create cpppkg") + client.run("create cpkg") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + assert ".link_cpp = true" in _target_block(content, "cpppkg::cpppkg") + assert ".link_cpp = false" in _target_block(content, "cpkg::cpkg") + + +def test_zigdeps_tool_requires_bindirs_exposed(): + """ Real tool recipes rarely declare cpp_info.exe, so their bindirs are what is actually + available - exposed so a build.zig can resolve a tool without relying on PATH """ + client = TestClient() + client.save({ + "tool/conanfile.py": GenConanfile("tool", "1.0").with_package_type("application"), + "conanfile.py": GenConanfile("app", "1.0").with_tool_requires("tool/1.0"), + }) + client.run("create tool") + client.run("install . -g ZigDeps") + content = client.load("conan_zig_deps/conan_deps.zig") + + tool_dirs = content.split("conan_tool_dirs")[1].split("conan_targets")[0] + assert '"tool"' in tool_dirs + assert "/bin" in tool_dirs + # A tool_requires is build context: nothing to link, so no target for it + assert "tool::tool" not in _targets_section(content) + + setup = client.load("conan_zig_deps/conan_setup.zig") + assert "pub fn toolPath(" in setup