mirror of
https://github.com/toyota-connected/emb_cli.git
synced 2026-06-21 07:19:37 -07:00
feat(cross): cross-file extends — app layer over a project target
`cross.extends` gains a second form alongside the board reference:
- <board> : a target in emb's shipped board library (hardware).
- <dir>#<target> : a target in another emb project at <dir> (the project
layer) — e.g. a Flutter app extending an ivi-homescreen
target, so plugin config (DISABLE_PLUGINS/PLUGINS_DIR,
plugin -dev packages) lives with the app.
This completes the layering: app -> project -> board. <dir> is resolved relative
to the extending manifest's project root (the .emb/ parent in .emb/ mode, else
the manifest's dir); the referenced project's own extends chain is applied via
resolve(), and the same merge rules apply (maps deep-merge, backends/cpu_flags
replace, sysroot.dev_packages union). Reference cycles across projects are
detected and reported.
Tests cover the three-layer merge (hardware from board, backends from project,
plugins from app, dev_packages unioned across all three) plus unknown-target
and missing-project errors.
Signed-off-by: Joel Winarske <joel.winarske@gmail.com>
This commit is contained in:
@@ -99,6 +99,10 @@ class CrossProjectResolver {
|
||||
/// Lazily-loaded board registry: board name -> its hardware `cross:` map.
|
||||
Map<String, Map<String, dynamic>>? _boards;
|
||||
|
||||
/// Project dirs currently being resolved through a cross-project `extends`,
|
||||
/// to break `app -> project -> app` reference cycles.
|
||||
final Set<String> _resolvingProjects = {};
|
||||
|
||||
/// The base file searched for inside a `.emb/` directory.
|
||||
static const baseFileName = 'base.emb.yaml';
|
||||
|
||||
@@ -307,9 +311,15 @@ class CrossProjectResolver {
|
||||
if (e.key != 'targets') e.key: e.value,
|
||||
};
|
||||
|
||||
/// Resolve a `cross.extends: <board>` reference: deep-merge [cross] (the
|
||||
/// project/app layer) over the named base board (emb's hardware layer),
|
||||
/// recursing for chains. No-op when there is no `extends`.
|
||||
/// Resolve a `cross.extends` reference: deep-merge [cross] (the derived
|
||||
/// project/app layer) over the resolved base it names. Two forms:
|
||||
/// - `<board>` — a target in emb's shipped board library (hardware).
|
||||
/// - `<dir>#<target>` — a target in another emb project at <dir> (the
|
||||
/// project layer; e.g. an app extending an
|
||||
/// ivi-homescreen target). <dir> is relative to the
|
||||
/// extending manifest's project root.
|
||||
/// Chains resolve recursively (app -> project -> board). No-op without
|
||||
/// `extends`.
|
||||
Map<String, dynamic> _applyExtends(
|
||||
Map<String, dynamic> cross,
|
||||
String? sourcePath, [
|
||||
@@ -317,24 +327,80 @@ class CrossProjectResolver {
|
||||
]) {
|
||||
final ext = cross['extends'];
|
||||
if (ext == null) return cross;
|
||||
final baseName = ext.toString();
|
||||
if (seen.contains(baseName)) {
|
||||
final ref = ext.toString();
|
||||
if (seen.contains(ref)) {
|
||||
throw CrossProjectException(
|
||||
'extends: cycle through "$baseName" (in ${sourcePath ?? "manifest"}).',
|
||||
'extends: cycle through "$ref" (in ${sourcePath ?? "manifest"}).',
|
||||
);
|
||||
}
|
||||
final base = ref.contains('#')
|
||||
? _projectExtendsBase(ref, sourcePath)
|
||||
: _boardExtendsBase(ref, sourcePath, seen);
|
||||
final derived = {...cross}..remove('extends');
|
||||
return _mergeCross(base, derived);
|
||||
}
|
||||
|
||||
/// The resolved `cross:` of a board named [name] from the shipped board
|
||||
/// library, with its own `extends` chain applied.
|
||||
Map<String, dynamic> _boardExtendsBase(
|
||||
String name,
|
||||
String? sourcePath,
|
||||
Set<String> seen,
|
||||
) {
|
||||
final registry = _boardRegistry();
|
||||
final base = registry[baseName];
|
||||
final base = registry[name];
|
||||
if (base == null) {
|
||||
final known = registry.keys.isEmpty ? 'none' : registry.keys.join(', ');
|
||||
throw CrossProjectException(
|
||||
'extends: unknown board "$baseName" (in ${sourcePath ?? "manifest"}). '
|
||||
'extends: unknown board "$name" (in ${sourcePath ?? "manifest"}). '
|
||||
'Known boards: $known.',
|
||||
);
|
||||
}
|
||||
final resolvedBase = _applyExtends(base, sourcePath, {...seen, baseName});
|
||||
final derived = {...cross}..remove('extends');
|
||||
return _mergeCross(resolvedBase, derived);
|
||||
return _applyExtends(base, sourcePath, {...seen, name});
|
||||
}
|
||||
|
||||
/// The resolved `cross:` of `<dir>#<target>` — a target from another emb
|
||||
/// project at <dir> (relative to the extending manifest's project root). That
|
||||
/// project's own `extends` chain is applied by [resolve].
|
||||
Map<String, dynamic> _projectExtendsBase(String ref, String? sourcePath) {
|
||||
final i = ref.lastIndexOf('#');
|
||||
final dirRef = ref.substring(0, i);
|
||||
final name = ref.substring(i + 1);
|
||||
final root = _projectRootOf(sourcePath);
|
||||
final dir = p.normalize(p.join(root, dirRef));
|
||||
final abs = p.absolute(dir);
|
||||
if (!_resolvingProjects.add(abs)) {
|
||||
throw CrossProjectException(
|
||||
'extends: project reference cycle at "$dir".',
|
||||
);
|
||||
}
|
||||
try {
|
||||
final project = resolve(dir);
|
||||
if (project == null) {
|
||||
throw CrossProjectException(
|
||||
'extends: no emb project at "$dir" (from "$ref").',
|
||||
);
|
||||
}
|
||||
final target = project.targets[name];
|
||||
if (target == null) {
|
||||
throw CrossProjectException(
|
||||
'extends: project "$dir" has no target "$name" (from "$ref"). '
|
||||
'Available: ${project.targets.keys.join(", ")}.',
|
||||
);
|
||||
}
|
||||
return target.cross;
|
||||
} finally {
|
||||
_resolvingProjects.remove(abs);
|
||||
}
|
||||
}
|
||||
|
||||
/// The project root for a manifest at [sourcePath]: the `.emb/` parent in
|
||||
/// `.emb/` mode, else the manifest file's own directory. Cross-project
|
||||
/// `extends` paths are resolved relative to this.
|
||||
String _projectRootOf(String? sourcePath) {
|
||||
if (sourcePath == null) return '.';
|
||||
final dir = p.dirname(sourcePath);
|
||||
return p.basename(dir) == '.emb' ? p.dirname(dir) : dir;
|
||||
}
|
||||
|
||||
/// Merge the [over] (derived) layer onto [base], with the cross-layer rules:
|
||||
|
||||
@@ -322,5 +322,96 @@ cross:
|
||||
throwsA(isA<CrossProjectException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('app extends a project target which extends a board (3 layers)', () {
|
||||
// project: tmp/proj/.emb/rpi.emb.yaml — extends the board, adds backends.
|
||||
Directory(p.join(tmp.path, 'proj', '.emb')).createSync(recursive: true);
|
||||
File(p.join(tmp.path, 'proj', '.emb', 'rpi.emb.yaml')).writeAsStringSync(
|
||||
'''
|
||||
id: ivi-homescreen
|
||||
cross:
|
||||
targets:
|
||||
rpi5-bookworm:
|
||||
extends: rpi5-bookworm
|
||||
backends:
|
||||
drm-kms-egl: {BUILD_BACKEND_DRM_KMS_EGL: 'ON'}
|
||||
sysroot:
|
||||
dev_packages: [libproject-dev]
|
||||
''',
|
||||
);
|
||||
// app: a flat manifest extending the project target, adding plugins.
|
||||
final app = File(p.join(tmp.path, 'app.emb.yaml'))
|
||||
..writeAsStringSync('''
|
||||
id: myapp
|
||||
cross:
|
||||
targets:
|
||||
rpi5-bookworm:
|
||||
extends: 'proj#rpi5-bookworm'
|
||||
defines: {DISABLE_PLUGINS: 'OFF'}
|
||||
sysroot:
|
||||
dev_packages: [libapp-dev]
|
||||
''');
|
||||
final project = CrossProjectResolver(
|
||||
const ManifestLoader(),
|
||||
boards,
|
||||
).resolve(app.path)!;
|
||||
final cross = project.targets['rpi5-bookworm']!.cross;
|
||||
// hardware from the board layer:
|
||||
expect(cross['toolchain_version'], '12.3.rel1');
|
||||
expect(cross['cpu_flags'], ['-mcpu=cortex-a76']);
|
||||
// backends from the project layer:
|
||||
expect((cross['backends'] as Map).keys, ['drm-kms-egl']);
|
||||
// plugin defines from the app layer:
|
||||
expect((cross['defines'] as Map)['DISABLE_PLUGINS'], 'OFF');
|
||||
// dev_packages union across all three layers:
|
||||
expect(
|
||||
(cross['sysroot'] as Map)['dev_packages'],
|
||||
containsAll(['libegl-dev', 'libproject-dev', 'libapp-dev']),
|
||||
);
|
||||
expect(cross.containsKey('extends'), isFalse);
|
||||
});
|
||||
|
||||
test('extends an unknown project target throws', () {
|
||||
Directory(p.join(tmp.path, 'proj', '.emb')).createSync(recursive: true);
|
||||
File(p.join(tmp.path, 'proj', '.emb', 'rpi.emb.yaml')).writeAsStringSync(
|
||||
'''
|
||||
id: ivi-homescreen
|
||||
cross:
|
||||
targets:
|
||||
rpi5-bookworm: {extends: rpi5-bookworm}
|
||||
''',
|
||||
);
|
||||
final app = File(p.join(tmp.path, 'app.emb.yaml'))
|
||||
..writeAsStringSync('''
|
||||
id: myapp
|
||||
cross:
|
||||
targets:
|
||||
x: {extends: 'proj#no-such-target'}
|
||||
''');
|
||||
expect(
|
||||
() => CrossProjectResolver(
|
||||
const ManifestLoader(),
|
||||
boards,
|
||||
).resolve(app.path),
|
||||
throwsA(isA<CrossProjectException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('extends a missing project dir throws', () {
|
||||
final app = File(p.join(tmp.path, 'app.emb.yaml'))
|
||||
..writeAsStringSync('''
|
||||
id: myapp
|
||||
cross:
|
||||
targets:
|
||||
x: {extends: 'no-such-proj#rpi5-bookworm'}
|
||||
''');
|
||||
expect(
|
||||
() => CrossProjectResolver(
|
||||
const ManifestLoader(),
|
||||
boards,
|
||||
).resolve(app.path),
|
||||
throwsA(isA<CrossProjectException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user