cmake: scripts: support board extension

Fixes: #69548

Support extending an existing board with new board variants.

This commit introduces the following changes to allow a board to be
extended out-of-tree.

The board yaml schema is extended to support an extend field which
will be used to identify the board to be extended.

A board 'plank' can be extended like this:
> board:
>   extend: plank
>   variants:
>     - name: ext
>       qualifier: soc1

For the rest of the build system this means that there is no longer a
single board directory.
The existing CMake variable BOARD_DIR is kept and reference the
directory which defines the board.
A new CMake variable BOARD_DIRECTORIES provides a list of all
directories which defines board targets for the board.
This means the directory which defines the board as well as all
directories that extends the board.

Signed-off-by: Torsten Rasmussen <Torsten.Rasmussen@nordicsemi.no>
diff --git a/scripts/list_boards.py b/scripts/list_boards.py
index bf71658..634c67d 100755
--- a/scripts/list_boards.py
+++ b/scripts/list_boards.py
@@ -4,13 +4,13 @@
 # SPDX-License-Identifier: Apache-2.0
 
 import argparse
-from collections import defaultdict
+from collections import defaultdict, Counter
 from dataclasses import dataclass, field
 import itertools
 from pathlib import Path
 import pykwalify.core
 import sys
-from typing import List
+from typing import List, Union
 import yaml
 import list_hardware
 from list_hardware import unique_paths
@@ -91,7 +91,8 @@
 @dataclass(frozen=True)
 class Board:
     name: str
-    dir: Path
+    # HWMv1 only supports a single Path, and requires Board dataclass to be hashable.
+    directories: Union[Path, List[Path]]
     hwm: str
     full_name: str = None
     arch: str = None
@@ -103,6 +104,41 @@
     socs: List[Soc] = field(default_factory=list, compare=False)
     variants: List[str] = field(default_factory=list, compare=False)
 
+    def from_qualifier(self, qualifiers):
+        qualifiers_list = qualifiers.split('/')
+
+        node = Soc(None)
+        n = len(qualifiers_list)
+        if n > 0:
+            soc_qualifier = qualifiers_list.pop(0)
+            for s in self.socs:
+                if s.name == soc_qualifier:
+                    node = s
+                    break
+
+        if n > 1:
+            if node.cpuclusters:
+                cpu_qualifier = qualifiers_list.pop(0)
+                for c in node.cpuclusters:
+                    if c.name == cpu_qualifier:
+                        node = c
+                        break
+                else:
+                    node = Variant(None)
+
+        for q in qualifiers_list:
+            for v in node.variants:
+                if v.name == q:
+                    node = v
+                    break
+            else:
+                node = Variant(None)
+
+        if node in (Soc(None), Variant(None)):
+            sys.exit(f'ERROR: qualifiers {qualifiers} not found when extending board {self.name}')
+
+        return node
+
 
 def board_key(board):
     return board.name
@@ -165,11 +201,10 @@
     for arch in arches:
         if not (boards / arch).is_dir():
             continue
-
         for maybe_board in (boards / arch).iterdir():
             if not maybe_board.is_dir():
                 continue
-            if board_dir is not None and board_dir != maybe_board:
+            if board_dir and maybe_board not in board_dir:
                 continue
             for maybe_defconfig in maybe_board.iterdir():
                 file_name = maybe_defconfig.name
@@ -181,7 +216,8 @@
 
 
 def load_v2_boards(board_name, board_yml, systems):
-    boards = []
+    boards = {}
+    board_extensions = []
     if board_yml.is_file():
         with board_yml.open('r', encoding='utf-8') as f:
             b = yaml.load(f.read(), Loader=SafeLoader)
@@ -199,6 +235,18 @@
 
         board_array = b.get('boards', [b.get('board', None)])
         for board in board_array:
+            mutual_exclusive = {'name', 'extend'}
+            if len(mutual_exclusive - board.keys()) < 1:
+                sys.exit(f'ERROR: Malformed "board" section in file: {board_yml.as_posix()}\n'
+                         f'{mutual_exclusive} are mutual exclusive at this level.')
+
+            # This is a extending an existing board, place in array to allow later processing.
+            if 'extend' in board:
+                board.update({'dir': board_yml.parent})
+                board_extensions.append(board)
+                continue
+
+            # Create board
             if board_name is not None:
                 if board['name'] != board_name:
                     # Not the board we're looking for, ignore.
@@ -220,9 +268,9 @@
             socs = [Soc.from_soc(systems.get_soc(s['name']), s.get('variants', []))
                     for s in board.get('socs', {})]
 
-            board = Board(
+            boards[board['name']] = Board(
                 name=board['name'],
-                dir=board_yml.parent,
+                directories=[board_yml.parent],
                 vendor=board.get('vendor'),
                 full_name=board.get('full_name'),
                 revision_format=board.get('revision', {}).get('format'),
@@ -234,8 +282,28 @@
                 variants=[Variant.from_dict(v) for v in board.get('variants', [])],
                 hwm='v2',
             )
-            boards.append(board)
-    return boards
+            board_qualifiers = board_v2_qualifiers(boards[board['name']])
+            duplicates = [q for q, n in Counter(board_qualifiers).items() if n > 1]
+            if duplicates:
+                sys.exit(f'ERROR: Duplicated board qualifiers detected {duplicates} for board: '
+                         f'{board["name"]}.\nPlease check content of: {board_yml.as_posix()}\n')
+    return boards, board_extensions
+
+
+def extend_v2_boards(boards, board_extensions):
+    for e in board_extensions:
+        board = boards.get(e['extend'])
+        if board is None:
+            continue
+        board.directories.append(e['dir'])
+
+        for v in e.get('variants', []):
+            node = board.from_qualifier(v['qualifier'])
+            if str(v['qualifier'] + '/' + v['name']) in board_v2_qualifiers(board):
+                board_yml = e['dir'] / BOARD_YML
+                sys.exit(f'ERROR: Variant: {v["name"]}, defined multiple times for board: '
+                         f'{board.name}.\nLast defined in {board_yml}')
+            node.variants.append(Variant.from_dict(v))
 
 
 # Note that this does not share the args.board functionality of find_v2_boards
@@ -253,14 +321,25 @@
     root_args = argparse.Namespace(**{'soc_roots': args.soc_roots})
     systems = list_hardware.find_v2_systems(root_args)
 
-    boards = []
+    boards = {}
+    board_extensions = []
     board_files = []
-    for root in unique_paths(args.board_roots):
-        board_files.extend((root / 'boards').rglob(BOARD_YML))
+    if args.board_dir:
+        board_files = [d / BOARD_YML for d in args.board_dir]
+    else:
+        for root in unique_paths(args.board_roots):
+            board_files.extend((root / 'boards').rglob(BOARD_YML))
 
     for board_yml in board_files:
-        b = load_v2_boards(args.board, board_yml, systems)
-        boards.extend(b)
+        b, e = load_v2_boards(args.board, board_yml, systems)
+        conflict_boards = set(boards.keys()).intersection(b.keys())
+        if conflict_boards:
+            sys.exit(f'ERROR: Board(s): {conflict_boards}, defined multiple times.\n'
+                     f'Last defined in {board_yml}')
+        boards.update(b)
+        board_extensions.extend(e)
+
+    extend_v2_boards(boards, board_extensions)
     return boards
 
 
@@ -285,7 +364,7 @@
                         help='add a soc root, may be given more than once')
     parser.add_argument("--board", dest='board', default=None,
                         help='lookup the specific board, fail if not found')
-    parser.add_argument("--board-dir", default=None, type=Path,
+    parser.add_argument("--board-dir", default=[], type=Path, action='append',
                         help='Only look for boards at the specific location')
 
 
@@ -327,20 +406,16 @@
 
 
 def dump_v2_boards(args):
-    if args.board_dir:
-        root_args = argparse.Namespace(**{'soc_roots': args.soc_roots})
-        systems = list_hardware.find_v2_systems(root_args)
-        boards = load_v2_boards(args.board, args.board_dir / BOARD_YML, systems)
-    else:
-        boards = find_v2_boards(args)
+    boards = find_v2_boards(args)
 
-    for b in boards:
+    for b in boards.values():
         qualifiers_list = board_v2_qualifiers(b)
         if args.cmakeformat is not None:
             notfound = lambda x: x or 'NOTFOUND'
             info = args.cmakeformat.format(
                 NAME='NAME;' + b.name,
-                DIR='DIR;' + str(b.dir.as_posix()),
+                DIR='DIR;' + ';'.join(
+                    [str(x.as_posix()) for x in b.directories]),
                 VENDOR='VENDOR;' + notfound(b.vendor),
                 HWM='HWM;' + b.hwm,
                 REVISION_DEFAULT='REVISION_DEFAULT;' + notfound(b.revision_default),
@@ -365,7 +440,7 @@
             if args.cmakeformat is not None:
                 info = args.cmakeformat.format(
                     NAME='NAME;' + board.name,
-                    DIR='DIR;' + str(board.dir.as_posix()),
+                    DIR='DIR;' + str(board.directories.as_posix()),
                     HWM='HWM;' + board.hwm,
                     VENDOR='VENDOR;NOTFOUND',
                     REVISION_DEFAULT='REVISION_DEFAULT;NOTFOUND',