Files
logos-module-builder/lib/mkLogosModule.nix
T
Dario LipicarandClaude Opus 4.7 598ca36983 feat: consume concrete deps via LIDL (no dep plugin build) + publish lidl output (#110)
* feat: consume concrete deps via LIDL; publish a cheap lidl output

Building/packaging a module no longer builds its (universal) dependency
modules — only `#run` (the standalone app) still bundles dep plugins.

- mkLogosModule: new packages.<sys>.lidl output — a cheap codegen-only
  derivation that runs `logos-cpp-generator --header-to-lidl` over the universal
  impl header (no Qt/plugin compile), publishing the module's interface LIDL.
- mkLogosModule: per-dependency classification. A dep that exposes a `lidl`
  output (or has a dependency_overrides entry) is LIDL-based → passed to the
  backend as `staticDeps` (generated via --dep, no dep plugin build). Deps
  without a `lidl` output take the TRANSITIONAL header-copy fallback
  (`legacyHeaderDepNames` → resolvedModuleDeps), identical to today; isolated
  and banner-commented for removal once all modules publish LIDL.
- parseMetadata: normalize `dependencies` to name strings (accept object
  entries) and add optional `dependency_overrides` ({ file, input?, impl_class? })
  to force a specific LIDL/header source per dep.
- buildCppPlugin (QML C++ backend): same classification + staticDeps threading,
  so UI modules also stop building their universal deps.

Requires logos-cpp-sdk#77 (--header-to-lidl / --dep) and logos-plugin-qt#9
(staticDeps → --dep). A follow-up flake.lock bump pins those once merged
(mirrors the interface-deps plugin-core bump, #109); local builds use
`ws ... --auto-local`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* address review: guard depLidlOf for non-flake deps; add parse-metadata tests

- depLidlOf now explicitly guards `packages`/`<system>` so a raw-derivation
  dependency input returns null (→ transitional header-copy fallback) rather
  than risking a throw. Applied in mkLogosModule.nix and buildCppPlugin.nix.
- test-parse-metadata.nix: cover `dependencies` object-entry normalization and
  `dependency_overrides` parsing/validation (file required; .h needs impl_class).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump logos-cpp-sdk + logos-plugin-qt/core to the deps-via-LIDL backends

Pin the merged --header-to-lidl/--dep generator (logos-cpp-sdk#77) and the
staticDeps-aware Qt backend (logos-plugin-qt#9) so this module builder's LIDL
dependency consumption works against published inputs (not just --auto-local).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 11:54:32 -03:00

496 lines
22 KiB
Nix

# Core module builder function
# This is the main entry point for building Logos modules.
# Plugin compilation and header generation are delegated to a backend selected
# by metadata.json "type": core modules use coreBackend, UI modules use uiBackend.
{ nixpkgs, lib, common, parseMetadata, builderRoot, uiBackend, coreBackend, logos-cpp-sdk, logos-module, logos-test-framework, nix-bundle-lgx, nix-bundle-logos-module-install, logos-standalone-app }:
{
# Required: Path to the module source
src,
# Required: Path to the metadata.json configuration file
configFile,
# Optional: all flake inputs — dependencies in metadata.json are resolved automatically
flakeInputs ? {},
# Optional: Additional flake inputs for external libraries
externalLibInputs ? {},
# Optional: Extra build inputs to add
extraBuildInputs ? [],
# Optional: Extra native build inputs to add
extraNativeBuildInputs ? [],
# Optional: Override any config values
configOverrides ? {},
# Optional: Custom preConfigure hook
preConfigure ? "",
# Optional: Custom postInstall hook
postInstall ? "",
# Optional: override the logos-standalone-app used for `nix run`.
# By default, UI modules (type = "ui") automatically get apps.default wired up
# using the standalone app bundled with logos-module-builder.
logosStandalone ? null,
# Optional: Unit test configuration. When provided, a checks.<system>.unit-tests
# output is automatically generated using logos-test-framework.
# tests = {
# dir = ./tests; # Required: directory containing test sources + CMakeLists.txt
# mockCLibs = []; # Optional: C libraries to mock at link time
# preConfigure = ""; # Optional: custom preConfigure hook
# extraBuildInputs = [];
# extraCmakeFlags = [];
# };
tests ? null,
}:
let
# Parse the module configuration
rawConfig = parseMetadata.parseModuleConfig (builtins.readFile configFile);
config = common.recursiveMerge [ rawConfig configOverrides ];
# Select backend based on module type: core modules are swappable, UI stays Qt
selectedBackend =
if config.type == "core" then coreBackend
else uiBackend;
# Import sub-builders (backend-agnostic)
mkExternalLib = import ./mkExternalLib.nix { inherit lib common; };
mkStandaloneApp = import ./mkStandaloneApp.nix;
modulePreConfigure = import ./modulePreConfigure.nix { inherit lib; };
# When this flake ships cmake/LogosModule.cmake, override LOGOS_MODULE_BUILDER_ROOT
# so the extended macros (generated_code glob, metadata copy, Go static libs) are used.
# Otherwise let the backend's default take over — it already sets
# LOGOS_MODULE_BUILDER_ROOT to its own root which has cmake/LogosModule.cmake.
hasBuilderCmake = builtins.pathExists (builderRoot + "/cmake/LogosModule.cmake");
# Helper to get a package from nixpkgs by name
getPkg = pkgs: name:
let evaluatedName = builtins.seq name name;
in if builtins.isString evaluatedName
then lib.getAttrFromPath (lib.splitString "." evaluatedName) pkgs
else builtins.throw "getPkg expected string but got ${builtins.typeOf evaluatedName}";
forAllSystems = f: lib.genAttrs common.systems (system: f system);
# Package outputs
packages = forAllSystems (system:
let
pkgs = import nixpkgs { inherit system; };
# ── Concrete dependency classification ─────────────────────────────────
# A dependency's typed `modules().<dep>` wrapper is generated from its
# published LIDL contract (`packages.<sys>.lidl`) WITHOUT building the
# dep's plugin. Deps that don't expose a `lidl` output yet take the
# TRANSITIONAL header-copy fallback (`legacyHeaderDepNames`), which DOES
# build them — identical to today's behavior.
# Returns the dep's published LIDL output, or null if the input isn't a
# flake exposing packages.<system>.lidl (e.g. a raw-derivation dep, or a
# module built by a builder that predates this feature) — those fall
# through to the TRANSITIONAL header-copy path. Guard every level so a
# non-flake input never throws.
depLidlOf = name:
let i = flakeInputs.${name} or null;
in if i != null && i ? packages && i.packages ? ${system}
then (i.packages.${system}.lidl or null)
else null;
depIsLidl = name: (config.dependency_overrides ? ${name}) || (depLidlOf name != null);
# LIDL-based deps → `--dep <name>=<lidl>` for the generator. An override
# forces a specific definition (.lidl, or .h + impl_class); otherwise we
# use the dep's published `lidl` output.
staticDeps = map (name:
let ov = config.dependency_overrides.${name} or null;
in if ov != null then {
inherit name;
impl_class = ov.impl_class;
path = if ov.input != null
then (if flakeInputs ? ${ov.input}
then "${flakeInputs.${ov.input}}/${ov.file}"
else throw "dependency_overrides.${name}: flake input '${ov.input}' was not passed to mkLogosModule.")
else "${src}/${ov.file}";
} else {
inherit name;
impl_class = null;
path = "${depLidlOf name}/${name}.lidl";
}
) (lib.filter depIsLidl config.dependencies);
# TRANSITIONAL: header-copy fallback for deps that predate the `lidl`
# output. These deps ARE built (their headers come from introspecting the
# compiled plugin). Remove this block — and the `moduleDepIncludes` use in
# the plugin backends — once every module exposes packages.<sys>.lidl.
legacyHeaderDepNames = lib.filter (name: !(depIsLidl name)) config.dependencies;
# Resolve the fallback deps from inputs. Each entry is exposed
# as a struct so the plugin builder can pick BOTH the dep's
# plugin .dylib AND the right header variant for its own
# --api-style without re-running the codegen at consume time.
# Backward-compatible fallbacks let older deps (which only
# expose `default`) still work — they get treated as Qt-typed.
moduleInputs = lib.filterAttrs (n: _: builtins.elem n legacyHeaderDepNames) flakeInputs;
resolvedModuleDeps = lib.mapAttrs (_: input:
let
ps = input.packages.${system} or null;
# Pre-version of this refactor: input was the raw flake-output
# derivation (not a packages set). Preserve that path so an
# external flake-input dep still works.
fallback = if input ? packages.${system}.default
then input.packages.${system}.default else input;
in
if ps != null then {
default = ps.default;
lib = ps.lib or ps.default;
headers-qt = ps.headers-qt or ps.include or ps.default;
headers-std = ps.headers-std or ps.headers-qt or ps.include or ps.default;
} else {
default = fallback;
lib = fallback;
headers-qt = fallback;
headers-std = fallback;
}
) moduleInputs;
# Resolve interface dependencies (method/event contracts) to concrete
# definition-file paths. A LOCAL interface lives in this repo's `src`;
# a REMOTE one comes from a flake input named by `input` — mirroring
# how `dependencies` resolve to flake inputs. We resolve the path here
# so the generator never touches flake inputs: it just receives
# `--interface <name>=<path>[=<impl_class>]`. (System-independent, but
# kept in this scope alongside resolvedModuleDeps for locality.)
resolvedInterfaceDeps = map (e: {
inherit (e) name impl_class;
path = if e.input != null
then (if flakeInputs ? ${e.input}
then "${flakeInputs.${e.input}}/${e.file}"
else throw "interface_dependencies: interface '${e.name}' references flake input '${e.input}', but no such input was passed to mkLogosModule (declare it in flake.nix and pass it via flakeInputs).")
else "${src}/${e.file}";
}) config.interface_dependencies;
# Resolve a single externalLibInputs entry for a given variant.
# Supports both simple (bare flake input) and structured ({ input, packages }) formats.
resolveExtInput = variant: name: value:
if builtins.isAttrs value && value ? input then
let
flakeInput = value.input;
packages = value.packages or {};
pkgName = packages.${variant} or packages.default or "default";
in
if flakeInput ? packages.${system}.${pkgName}
then flakeInput.packages.${system}.${pkgName}
else builtins.throw ''
External lib "${name}": flake input does not provide packages.${system}.${pkgName}.
Check the "externalLibInputs" structured entry and ensure the flake input exposes the expected package.
''
else
if value ? packages.${system}.default then value.packages.${system}.default else value;
# Whether any external lib input declares per-variant packages
hasVariants = lib.any (v: builtins.isAttrs v && v ? input && v ? packages)
(lib.attrValues externalLibInputs);
buildPkgs = map (getPkg pkgs) (lib.filter builtins.isString config.nix_packages.build);
runtimePkgs = map (getPkg pkgs) (lib.filter builtins.isString config.nix_packages.runtime);
# Pre-resolve default variant external libs (always needed, avoids
# duplicate evaluation when hasVariants triggers a second buildVariant).
defaultResolvedExternalLibs = lib.mapAttrs (resolveExtInput "default") externalLibInputs;
defaultExternalLibs = mkExternalLib.buildExternalLibs {
inherit pkgs config;
externalInputs = defaultResolvedExternalLibs;
};
# Resolve SDK deps for this system — injected into the backend
logosSdk = logos-cpp-sdk.packages.${system}.default;
logosModule = logos-module.packages.${system}.default;
# Build the plugin for a given external-lib variant ("default" or "portable")
buildVariant = variant:
let
externalLibs =
if variant == "default" then defaultExternalLibs
else mkExternalLib.buildExternalLibs {
inherit pkgs config;
externalInputs = lib.mapAttrs (resolveExtInput variant) externalLibInputs;
};
userPreConfigure =
if builtins.isFunction preConfigure
then preConfigure { inherit externalLibs; }
else preConfigure;
preConfigureStr = modulePreConfigure.compose {
inherit config externalLibs;
userPre = userPreConfigure;
fixDarwin = false;
# logos-plugin-qt buildPlugin already stages external libs into lib/
copyExternals = false;
};
goCmakeFlags = lib.optionals (config.go_static_lib_names != []) [
"-DLOGOS_MODULE_GO_STATIC_LIBS=${lib.concatStringsSep ";" config.go_static_lib_names}"
];
# LOGOS_API_STYLE forwards through to logos-cpp-generator and
# picks which type surface the generated <Module> client
# wrappers (and the umbrella LogosModules struct) expose.
# Universal modules — those whose impl is pure C++ and the
# codegen wraps it in Qt glue — get std-typed wrappers so
# they can call other modules without touching Qt at the
# call site. Every other interface (legacy, provider, or
# absent) keeps the historical Qt-typed wrappers.
apiStyleCmakeFlags = lib.optionals (config.interface == "universal") [
"-DLOGOS_API_STYLE=std"
];
# Delegate plugin compilation to the backend.
# The backend only knows about Qt + logosModule (interface.h).
# SDK (generator, lib, headers) is injected via extra* args.
in selectedBackend.buildPlugin ({
inherit pkgs src config postInstall logosModule;
preConfigure = preConfigureStr;
moduleDeps = resolvedModuleDeps;
inherit externalLibs;
extraNativeBuildInputs = extraNativeBuildInputs ++ buildPkgs ++ [ logosSdk ];
extraBuildInputs = extraBuildInputs ++ runtimePkgs;
extraCmakeFlags = [ "-DLOGOS_CPP_SDK_ROOT=${logosSdk}" ] ++ goCmakeFlags ++ apiStyleCmakeFlags;
extraEnv = {
LOGOS_CPP_SDK_ROOT = "${logosSdk}";
} // lib.optionalAttrs hasBuilderCmake {
LOGOS_MODULE_BUILDER_ROOT = "${builderRoot}";
};
}
# Only pass interfaceDeps when the module declares any — keeps existing
# dependency-only modules buildable against a backend that predates the
# interface-dependencies feature (graceful degradation).
// lib.optionalAttrs (config.interface_dependencies != []) {
interfaceDeps = resolvedInterfaceDeps;
}
# LIDL-based concrete deps → `--dep` flags (generate from the dep's
# published LIDL, no dep plugin build). Gated so a backend that predates
# this feature still builds (such deps then fall through unresolved).
// lib.optionalAttrs (staticDeps != []) {
inherit staticDeps;
});
moduleLib = buildVariant "default";
moduleLibPortable = if hasVariants then buildVariant "portable" else null;
# Two header variants per module — one Qt-typed, one std-typed.
# Each is its own Nix derivation, so a downstream module only
# realises the one its `--api-style` actually consumes. Default-
# output (`include`) stays pointed at the Qt variant for
# backward compatibility with consumers that read `${dep}/include`
# directly.
moduleIncludeQt = selectedBackend.buildHeaders {
inherit pkgs src config logosSdk;
pluginLib = moduleLib;
apiStyle = "qt";
};
moduleIncludeStd = selectedBackend.buildHeaders {
inherit pkgs src config logosSdk;
pluginLib = moduleLib;
apiStyle = "std";
};
# Publish this module's interface as LIDL — the language-neutral contract
# a consumer turns into typed `modules().<name>` bindings WITHOUT building
# this module's plugin (source → LIDL → C++). Cheap: runs only the C++
# frontend (`--header-to-lidl`) over the impl header; no Qt/plugin compile.
# Produced for universal modules; the impl header + class come from the
# same convention `universalCodegen` uses (`codegen.impl_*` or defaults).
lidlImplClass = config.codegen.impl_class or (modulePreConfigure.defaultImplClassFromName config.name);
lidlIhRaw = config.codegen.impl_header or "${config.name}_impl.h";
lidlImplHeaderRel = if lib.hasInfix "/" lidlIhRaw then lidlIhRaw else "src/${lidlIhRaw}";
moduleLidl =
if config.interface == "universal"
then pkgs.runCommand "logos-${config.name}-lidl" {
nativeBuildInputs = [ logosSdk ];
} ''
mkdir -p $out
logos-cpp-generator --header-to-lidl "${src}/${lidlImplHeaderRel}" \
--impl-class "${lidlImplClass}" \
--metadata "${configFile}" \
-o "$out/${config.name}.lidl"
''
else null;
# Combined package — copies the Qt-typed headers (backward
# compat). The `//` merge exposes src + version on the derivation
# so downstream bundlers (nix-bundle-lgx) can locate metadata.json.
combined = (pkgs.runCommand "logos-${config.name}-module" {} ''
mkdir -p $out/lib $out/include
# Copy library files (not symlinks)
if [ -d "${moduleLib}/lib" ]; then
cp -rL ${moduleLib}/lib/* $out/lib/
fi
# Copy include files (not symlinks) — use find to avoid nullglob issues
if [ -d "${moduleIncludeQt}/include" ] && [ -n "$(find ${moduleIncludeQt}/include -maxdepth 1 -not -name '.*' -not -path ${moduleIncludeQt}/include -print -quit)" ]; then
cp -rL ${moduleIncludeQt}/include/* $out/include/
fi
'') // { inherit src; version = config.version; };
in {
# Individual outputs (e.g., nix build .#chat-lib)
"${config.name}-lib" = moduleLib;
"${config.name}-include" = moduleIncludeQt;
"${config.name}-headers-qt" = moduleIncludeQt;
"${config.name}-headers-std" = moduleIncludeStd;
# Short aliases (e.g., nix build .#lib)
lib = moduleLib;
include = moduleIncludeQt;
headers-qt = moduleIncludeQt;
headers-std = moduleIncludeStd;
# Default package - combined lib + include (nix build)
default = combined;
} // lib.optionalAttrs (moduleLibPortable != null) {
"${config.name}-lib-portable" = moduleLibPortable;
lib-portable = moduleLibPortable;
} // lib.optionalAttrs (moduleLidl != null) {
# Published LIDL contract — consumers generate bindings from this without
# building the plugin. Cheap (frontend only). Absent for non-universal
# modules, so consumers fall back to the header-copy path for those.
"${config.name}-lidl" = moduleLidl;
lidl = moduleLidl;
}
);
# Development shell (delegates to backend for deps)
devShells = forAllSystems (system:
let
pkgs = import nixpkgs { inherit system; };
logosSdk = logos-cpp-sdk.packages.${system}.default;
logosModule = logos-module.packages.${system}.default;
backendShell = selectedBackend.devShellInputs pkgs { inherit logosModule; };
buildPkgs = map (getPkg pkgs) config.nix_packages.build;
runtimePkgs = map (getPkg pkgs) config.nix_packages.runtime;
in {
default = pkgs.mkShell {
nativeBuildInputs = backendShell.nativeBuildInputs ++ buildPkgs ++ [ logosSdk ];
buildInputs = backendShell.buildInputs ++ runtimePkgs;
shellHook = ''
${backendShell.shellHook}
export LOGOS_CPP_SDK_ROOT="${logosSdk}"
${lib.optionalString hasBuilderCmake ''export LOGOS_MODULE_BUILDER_ROOT="${builderRoot}"''}
echo "Logos ${config.name} module development environment"
echo "LOGOS_CPP_SDK_ROOT: $LOGOS_CPP_SDK_ROOT"
echo "LOGOS_MODULE_ROOT: $LOGOS_MODULE_ROOT"
echo "LOGOS_MODULE_BUILDER_ROOT: $LOGOS_MODULE_BUILDER_ROOT"
'';
};
}
);
# LGX package outputs (nix-bundle-lgx provided by the builder)
nixBundleLgx = nix-bundle-lgx;
optionalLgx =
{
packages = forAllSystems (system:
let
bundleLgx = nixBundleLgx.bundlers.${system}.default;
bundleLgxPortable = nixBundleLgx.bundlers.${system}.portable;
installDev = nix-bundle-logos-module-install.bundlers.${system}.dev;
installPortable = nix-bundle-logos-module-install.bundlers.${system}.portable;
moduleLib = packages.${system}.lib;
# Use the portable-linked plugin for lgx-portable when available
moduleLibForPortable =
packages.${system}.lib-portable or moduleLib;
in {
lgx = bundleLgx moduleLib;
install = installDev moduleLib;
lgx-portable = bundleLgxPortable moduleLibForPortable;
install-portable = installPortable moduleLibForPortable;
}
);
};
# Resolve the standalone app: explicit override > built-in from module-builder
resolvedStandalone =
if logosStandalone != null then logosStandalone
else if config.type == "ui" then logos-standalone-app
else null;
optionalApps =
if resolvedStandalone == null then {}
else {
apps = forAllSystems (system:
let
pkgs = import nixpkgs { inherit system; };
# Collect all module dependencies (direct + transitive) for bundling
allDeps = common.collectAllModuleDeps system flakeInputs config.dependencies;
in {
default = mkStandaloneApp {
inherit pkgs;
standalone = resolvedStandalone.packages.${system}.default;
plugin = packages.${system}.default;
metadataFile = configFile;
dirName = "logos-${config.name}-plugin-dir";
format = "qt-plugin";
moduleDeps = allDeps;
};
}
);
};
# Merge LGX outputs into packages
mergedPackages = lib.mapAttrs (system: sysPkgs:
sysPkgs // (optionalLgx.packages.${system} or {})
) packages;
# Build unit tests — explicit config wins, otherwise auto-detect tests/CMakeLists.txt
mkTests = import ./mkLogosModuleTests.nix {
inherit nixpkgs lib common parseMetadata;
inherit logos-cpp-sdk;
logos-test-framework = logos-test-framework;
};
resolvedTests =
if tests != null then tests
else if builtins.pathExists (src + "/tests/CMakeLists.txt") then {
dir = src + "/tests";
}
else null;
testChecks =
if resolvedTests == null then {}
else mkTests {
inherit src flakeInputs externalLibInputs;
configFile = configFile;
testDir = resolvedTests.dir;
mockCLibs = resolvedTests.mockCLibs or [];
preConfigure = resolvedTests.preConfigure or preConfigure;
extraBuildInputs = resolvedTests.extraBuildInputs or [];
extraCmakeFlags = resolvedTests.extraCmakeFlags or [];
};
optionalTests =
if testChecks == {} then {}
else { checks = testChecks; };
# Also expose unit-tests as a package so `nix build .#unit-tests` works
testPackages =
if testChecks == {} then {}
else lib.mapAttrs (_system: sysChecks:
{ unit-tests = sysChecks.unit-tests; }
) testChecks;
finalPackages = lib.mapAttrs (system: sysPkgs:
sysPkgs // (testPackages.${system} or {})
) mergedPackages;
in {
packages = finalPackages;
inherit devShells config;
metadataJson = builtins.readFile configFile;
} // optionalApps // optionalTests