pw_module: Experimental module creation command

This starts a pw command which can be used to create new modules with
all of the required boilerplate files, simplifying the process of
starting a module.

Initially, the command only has basic support for C++-based modules.
Many of its features are still experimental, and only upstream use is
supported.

Change-Id: I6af9abf8db8833e4fddf7b13bd5220c1cef9448e
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/103611
Reviewed-by: Anthony DiGirolamo <tonymd@google.com>
Commit-Queue: Alexei Frolov <frolv@google.com>
diff --git a/pw_module/docs.rst b/pw_module/docs.rst
index a704c54..917da4c 100644
--- a/pw_module/docs.rst
+++ b/pw_module/docs.rst
@@ -35,3 +35,18 @@
   20191205 17:05:19 ERR FAIL: Found errors when checking module pw_module
 
 
+.. _module-pw_module-module-create:
+
+``pw module create``
+^^^^^^^^^^^^^^^^^^^^
+The ``pw module create`` command is used to generate all of the required
+boilerplate for a new Pigweed module.
+
+.. note::
+
+   ``pw module create`` is still under construction and mostly experimental.
+   It is only usable in upstream Pigweed, and has limited feature support, with
+   a command-line API subject to change.
+
+   Once the command is more stable, it will be properly documented. For now,
+   running ``pw module create --help`` will display the current set of options.
diff --git a/pw_module/py/BUILD.gn b/pw_module/py/BUILD.gn
index 8a646a7..0270514 100644
--- a/pw_module/py/BUILD.gn
+++ b/pw_module/py/BUILD.gn
@@ -26,7 +26,9 @@
     "pw_module/__init__.py",
     "pw_module/__main__.py",
     "pw_module/check.py",
+    "pw_module/create.py",
   ]
+  python_deps = [ "$dir_pw_build/py" ]
   tests = [ "check_test.py" ]
   pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_module/py/pw_module/__main__.py b/pw_module/py/pw_module/__main__.py
index cf37ec5..c59137e 100644
--- a/pw_module/py/pw_module/__main__.py
+++ b/pw_module/py/pw_module/__main__.py
@@ -16,6 +16,7 @@
 import argparse
 
 import pw_module.check
+import pw_module.create
 
 
 def main() -> None:
@@ -28,6 +29,8 @@
 
     pw_module.check.register_subcommand(
         subparsers.add_parser('check', help=pw_module.check.__doc__))
+    pw_module.create.register_subcommand(
+        subparsers.add_parser('create', help=pw_module.create.__doc__))
 
     args = {**vars(parser.parse_args())}
     func = args['func']
diff --git a/pw_module/py/pw_module/create.py b/pw_module/py/pw_module/create.py
new file mode 100644
index 0000000..8025ce8
--- /dev/null
+++ b/pw_module/py/pw_module/create.py
@@ -0,0 +1,870 @@
+# Copyright 2022 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Creates a new Pigweed module."""
+
+import abc
+import argparse
+import dataclasses
+from dataclasses import dataclass
+import datetime
+import logging
+import os
+from pathlib import Path
+import re
+import sys
+
+from typing import Any, Dict, Iterable, List, Optional, Type, Union
+
+from pw_build import generate_modules_lists
+
+_LOG = logging.getLogger(__name__)
+
+_PIGWEED_LICENSE = \
+f"""# Copyright {datetime.datetime.now().year} The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License."""
+
+_PIGWEED_LICENSE_CC = _PIGWEED_LICENSE.replace('#', '//')
+
+
+# TODO(frolv): Adapted from pw_protobuf. Consolidate them.
+class _OutputFile:
+    DEFAULT_INDENT_WIDTH = 2
+
+    def __init__(self, file: Path, indent_width: int = DEFAULT_INDENT_WIDTH):
+        self._file = file
+        self._content: List[str] = []
+        self._indent_width: int = indent_width
+        self._indentation = 0
+
+    def line(self, line: str = '') -> None:
+        if line:
+            self._content.append(' ' * self._indentation)
+            self._content.append(line)
+        self._content.append('\n')
+
+    def indent(
+        self,
+        width: int = None,
+    ) -> '_OutputFile._IndentationContext':
+        """Increases the indentation level of the output."""
+        return self._IndentationContext(
+            self, width if width is not None else self._indent_width)
+
+    @property
+    def path(self) -> Path:
+        return self._file
+
+    @property
+    def content(self) -> str:
+        return ''.join(self._content)
+
+    def write(self) -> None:
+        print('  create  ' + str(self._file.relative_to(Path.cwd())))
+        self._file.write_text(self.content)
+
+    class _IndentationContext:
+        """Context that increases the output's indentation when it is active."""
+        def __init__(self, output: '_OutputFile', width: int):
+            self._output = output
+            self._width: int = width
+
+        def __enter__(self):
+            self._output._indentation += self._width
+
+        def __exit__(self, typ, value, traceback):
+            self._output._indentation -= self._width
+
+
+class _ModuleName:
+    _MODULE_NAME_REGEX = '(^[a-zA-Z]{2,})((_[a-zA-Z0-9]+)+)$'
+
+    def __init__(self, prefix: str, main: str) -> None:
+        self._prefix = prefix
+        self._main = main
+
+    @property
+    def full(self) -> str:
+        return f'{self._prefix}_{self._main}'
+
+    @property
+    def prefix(self) -> str:
+        return self._prefix
+
+    @property
+    def main(self) -> str:
+        return self._main
+
+    @property
+    def default_namespace(self) -> str:
+        return f'{self._prefix}::{self._main}'
+
+    def upper_camel_case(self) -> str:
+        return ''.join(s.capitalize() for s in self._main.split('_'))
+
+    def __str__(self) -> str:
+        return self.full
+
+    def __repr__(self) -> str:
+        return self.full
+
+    @classmethod
+    def parse(cls, name: str) -> Optional['_ModuleName']:
+        match = re.fullmatch(_ModuleName._MODULE_NAME_REGEX, name)
+        if not match:
+            return None
+
+        return cls(match.group(1), match.group(2)[1:])
+
+
+@dataclass
+class _ModuleContext:
+    name: _ModuleName
+    dir: Path
+    root_build_files: List['_BuildFile']
+    sub_build_files: List['_BuildFile']
+    build_systems: List[str]
+    is_upstream: bool
+
+    def build_files(self) -> Iterable['_BuildFile']:
+        yield from self.root_build_files
+        yield from self.sub_build_files
+
+    def add_docs_file(self, file: Path):
+        for build_file in self.root_build_files:
+            build_file.add_docs_source(str(file.relative_to(self.dir)))
+
+    def add_cc_target(self, target: '_BuildFile.CcTarget') -> None:
+        for build_file in self.root_build_files:
+            build_file.add_cc_target(target)
+
+    def add_cc_test(self, target: '_BuildFile.CcTarget') -> None:
+        for build_file in self.root_build_files:
+            build_file.add_cc_test(target)
+
+
+class _BuildFile:
+    """Abstract representation of a build file for a module."""
+    @dataclass
+    class Target:
+        name: str
+
+        # TODO(frolv): Shouldn't be a string list as that's build system
+        # specific. Figure out a way to resolve dependencies from targets.
+        deps: List[str] = dataclasses.field(default_factory=list)
+
+    @dataclass
+    class CcTarget(Target):
+        sources: List[Path] = dataclasses.field(default_factory=list)
+        headers: List[Path] = dataclasses.field(default_factory=list)
+
+        def rebased_sources(self, rebase_path: Path) -> Iterable[str]:
+            return (str(src.relative_to(rebase_path)) for src in self.sources)
+
+        def rebased_headers(self, rebase_path: Path) -> Iterable[str]:
+            return (str(hdr.relative_to(rebase_path)) for hdr in self.headers)
+
+    def __init__(self, path: Path, ctx: _ModuleContext):
+        self._path = path
+        self._ctx = ctx
+
+        self._docs_sources: List[str] = []
+        self._cc_targets: List[_BuildFile.CcTarget] = []
+        self._cc_tests: List[_BuildFile.CcTarget] = []
+
+    @property
+    def path(self) -> Path:
+        return self._path
+
+    @property
+    def dir(self) -> Path:
+        return self._path.parent
+
+    def add_docs_source(self, filename: str) -> None:
+        self._docs_sources.append(filename)
+
+    def add_cc_target(self, target: CcTarget) -> None:
+        self._cc_targets.append(target)
+
+    def add_cc_test(self, target: CcTarget) -> None:
+        self._cc_tests.append(target)
+
+    def write(self) -> None:
+        """Writes the contents of the build file to disk."""
+        file = _OutputFile(self._path, self._indent_width())
+
+        if self._ctx.is_upstream:
+            file.line(_PIGWEED_LICENSE)
+            file.line()
+
+        self._write_preamble(file)
+
+        for target in self._cc_targets:
+            file.line()
+            self._write_cc_target(file, target)
+
+        for target in self._cc_tests:
+            file.line()
+            self._write_cc_test(file, target)
+
+        if self._docs_sources:
+            file.line()
+            self._write_docs_target(file, self._docs_sources)
+
+        file.write()
+
+    @abc.abstractmethod
+    def _indent_width(self) -> int:
+        """Returns the default indent width for the build file's code style."""
+
+    @abc.abstractmethod
+    def _write_preamble(self, file: _OutputFile) -> None:
+        """Formats"""
+
+    @abc.abstractmethod
+    def _write_cc_target(
+        self,
+        file: _OutputFile,
+        target: '_BuildFile.CcTarget',
+    ) -> None:
+        """Defines a C++ library target within the build file."""
+
+    @abc.abstractmethod
+    def _write_cc_test(
+        self,
+        file: _OutputFile,
+        target: '_BuildFile.CcTarget',
+    ) -> None:
+        """Defines a C++ unit test target within the build file."""
+
+    @abc.abstractmethod
+    def _write_docs_target(
+        self,
+        file: _OutputFile,
+        docs_sources: List[str],
+    ) -> None:
+        """Defines a documentation target within the build file."""
+
+
+# TODO(frolv): The Dict here should be Dict[str, '_GnVal'] (i.e. _GnScope),
+# but mypy does not yet support recursive types:
+# https://github.com/python/mypy/issues/731
+_GnVal = Union[bool, int, str, List[str], Dict[str, Any]]
+_GnScope = Dict[str, _GnVal]
+
+
+class _GnBuildFile(_BuildFile):
+    _DEFAULT_FILENAME = 'BUILD.gn'
+    _INCLUDE_CONFIG_TARGET = 'public_include_path'
+
+    def __init__(self,
+                 directory: Path,
+                 ctx: _ModuleContext,
+                 filename: str = _DEFAULT_FILENAME):
+        super().__init__(directory / filename, ctx)
+
+    def _indent_width(self) -> int:
+        return 2
+
+    def _write_preamble(self, file: _OutputFile) -> None:
+        # Upstream modules always require a tests target, even if it's empty.
+        has_tests = len(self._cc_tests) > 0 or self._ctx.is_upstream
+
+        imports = []
+
+        if self._cc_targets:
+            imports.append('$dir_pw_build/target_types.gni')
+
+        if has_tests:
+            imports.append('$dir_pw_unit_test/test.gni')
+
+        if self._docs_sources:
+            imports.append('$dir_pw_docgen/docs.gni')
+
+        file.line('import("//build_overrides/pigweed.gni")\n')
+        for imp in sorted(imports):
+            file.line(f'import("{imp}")')
+
+        if self._cc_targets:
+            file.line()
+            _GnBuildFile._target(file, 'config',
+                                 _GnBuildFile._INCLUDE_CONFIG_TARGET, {
+                                     'include_dirs': ['public'],
+                                     'visibility': [':*'],
+                                 })
+
+        if has_tests:
+            file.line()
+            _GnBuildFile._target(
+                file, 'pw_test_group', 'tests', {
+                    'tests': list(f':{test.name}' for test in self._cc_tests),
+                })
+
+    def _write_cc_target(
+        self,
+        file: _OutputFile,
+        target: _BuildFile.CcTarget,
+    ) -> None:
+        """Defines a GN source_set for a C++ target."""
+
+        target_vars: _GnScope = {}
+
+        if target.headers:
+            target_vars['public_configs'] = [
+                f':{_GnBuildFile._INCLUDE_CONFIG_TARGET}'
+            ]
+            target_vars['public'] = list(target.rebased_headers(self.dir))
+
+        if target.sources:
+            target_vars['sources'] = list(target.rebased_sources(self.dir))
+
+        if target.deps:
+            target_vars['deps'] = target.deps
+
+        _GnBuildFile._target(file, 'pw_source_set', target.name, target_vars)
+
+    def _write_cc_test(
+        self,
+        file: _OutputFile,
+        target: '_BuildFile.CcTarget',
+    ) -> None:
+        _GnBuildFile._target(
+            file, 'pw_test', target.name, {
+                'sources': list(target.rebased_sources(self.dir)),
+                'deps': target.deps,
+            })
+
+    def _write_docs_target(
+        self,
+        file: _OutputFile,
+        docs_sources: List[str],
+    ) -> None:
+        """Defines a pw_doc_group for module documentation."""
+        _GnBuildFile._target(file, 'pw_doc_group', 'docs', {
+            'sources': docs_sources,
+        })
+
+    @staticmethod
+    def _target(
+        file: _OutputFile,
+        target_type: str,
+        name: str,
+        args: _GnScope,
+    ) -> None:
+        """Formats a GN target."""
+
+        file.line(f'{target_type}("{name}") {{')
+
+        with file.indent():
+            _GnBuildFile._format_gn_scope(file, args)
+
+        file.line('}')
+
+    @staticmethod
+    def _format_gn_scope(file: _OutputFile, scope: _GnScope) -> None:
+        """Formats all of the variables within a GN scope to a file.
+
+        This function does not write the enclosing braces of the outer scope to
+        support use from multiple formatting contexts.
+        """
+        for key, val in scope.items():
+            if isinstance(val, int):
+                file.line(f'{key} = {val}')
+                continue
+
+            if isinstance(val, str):
+                file.line(f'{key} = {_GnBuildFile._gn_string(val)}')
+                continue
+
+            if isinstance(val, bool):
+                file.line(f'{key} = {str(val).lower()}')
+                continue
+
+            if isinstance(val, dict):
+                file.line(f'{key} = {{')
+                with file.indent():
+                    _GnBuildFile._format_gn_scope(file, val)
+                file.line('}')
+                continue
+
+            # Format a list of strings.
+            # TODO(frolv): Lists of other types?
+            assert isinstance(val, list)
+
+            if not val:
+                file.line(f'{key} = []')
+                continue
+
+            if len(val) == 1:
+                file.line(f'{key} = [ {_GnBuildFile._gn_string(val[0])} ]')
+                continue
+
+            file.line(f'{key} = [')
+            with file.indent():
+                for string in sorted(val):
+                    file.line(f'{_GnBuildFile._gn_string(string)},')
+            file.line(']')
+
+    @staticmethod
+    def _gn_string(string: str) -> str:
+        """Converts a Python string into a string literal within a GN file.
+
+        Accounts for the possibility of variable interpolation within GN,
+        removing quotes if unnecessary:
+
+            "string"           ->  "string"
+            "string"           ->  "string"
+            "$var"             ->  var
+            "$var2"            ->  var2
+            "$3var"            ->  "$3var"
+            "$dir_pw_foo"      ->  dir_pw_foo
+            "$dir_pw_foo:bar"  ->  "$dir_pw_foo:bar"
+            "$dir_pw_foo/baz"  ->  "$dir_pw_foo/baz"
+            "${dir_pw_foo}"    ->  dir_pw_foo
+
+        """
+
+        # Check if the entire string refers to a interpolated variable.
+        #
+        # Simple case: '$' followed a single word, e.g. "$my_variable".
+        # Note that identifiers can't start with a number.
+        if re.fullmatch(r'^\$[a-zA-Z_]\w*$', string):
+            return string[1:]
+
+        # GN permits wrapping an interpolated variable in braces.
+        # Check for strings of the format "${my_variable}".
+        if re.fullmatch(r'^\$\{[a-zA-Z_]\w*\}$', string):
+            return string[2:-1]
+
+        return f'"{string}"'
+
+
+class _BazelBuildFile(_BuildFile):
+    _DEFAULT_FILENAME = 'BUILD.bazel'
+
+    def __init__(self,
+                 directory: Path,
+                 ctx: _ModuleContext,
+                 filename: str = _DEFAULT_FILENAME):
+        super().__init__(directory / filename, ctx)
+
+    def _indent_width(self) -> int:
+        return 4
+
+    def _write_preamble(self, file: _OutputFile) -> None:
+        imports = ['//pw_build:pigweed.bzl']
+        if self._cc_targets:
+            imports.append('pw_cc_library')
+
+        if self._cc_tests:
+            imports.append('pw_cc_test')
+
+        file.line('load(')
+        with file.indent():
+            for imp in sorted(imports):
+                file.line(f'"{imp}",')
+        file.line(')\n')
+
+        file.line('package(default_visibility = ["//visibility:public"])\n')
+        file.line('licenses(["notice"])')
+
+    def _write_cc_target(
+        self,
+        file: _OutputFile,
+        target: _BuildFile.CcTarget,
+    ) -> None:
+        _BazelBuildFile._target(
+            file, 'pw_cc_library', target.name, {
+                'srcs': list(target.rebased_sources(self.dir)),
+                'hdrs': list(target.rebased_headers(self.dir)),
+                'includes': ['public'],
+            })
+
+    def _write_cc_test(
+        self,
+        file: _OutputFile,
+        target: '_BuildFile.CcTarget',
+    ) -> None:
+        _BazelBuildFile._target(file, 'pw_cc_test', target.name, {
+            'srcs': list(target.rebased_sources(self.dir)),
+            'deps': target.deps,
+        })
+
+    def _write_docs_target(
+        self,
+        file: _OutputFile,
+        docs_sources: List[str],
+    ) -> None:
+        file.line('# Bazel does not yet support building docs.')
+        _BazelBuildFile._target(file, 'filegroup', 'docs',
+                                {'srcs': docs_sources})
+
+    @staticmethod
+    def _target(
+        file: _OutputFile,
+        target_type: str,
+        name: str,
+        keys: Dict[str, List[str]],
+    ) -> None:
+        file.line(f'{target_type}(')
+
+        with file.indent():
+            file.line(f'name = "{name}",')
+
+            for k, vals in keys.items():
+                if len(vals) == 1:
+                    file.line(f'{k} = ["{vals[0]}"],')
+                    continue
+
+                file.line(f'{k} = [')
+                with file.indent():
+                    for val in sorted(vals):
+                        file.line(f'"{val}",')
+                file.line('],')
+
+        file.line(')')
+
+
+class _CmakeBuildFile(_BuildFile):
+    _DEFAULT_FILENAME = 'CMakeLists.txt'
+
+    def __init__(self,
+                 directory: Path,
+                 ctx: _ModuleContext,
+                 filename: str = _DEFAULT_FILENAME):
+        super().__init__(directory / filename, ctx)
+
+    def _indent_width(self) -> int:
+        return 2
+
+    def _write_preamble(self, file: _OutputFile) -> None:
+        file.line('include($ENV{PW_ROOT}/pw_build/pigweed.cmake)')
+
+    def _write_cc_target(
+        self,
+        file: _OutputFile,
+        target: _BuildFile.CcTarget,
+    ) -> None:
+        if target.name == self._ctx.name.full:
+            target_name = target.name
+        else:
+            target_name = f'{self._ctx.name.full}.{target.name}'
+
+        _CmakeBuildFile._target(
+            file,
+            'pw_add_module_library',
+            target_name,
+            {
+                'sources': list(target.rebased_sources(self.dir)),
+                'headers': list(target.rebased_headers(self.dir)),
+                'public_includes': ['public'],
+            },
+        )
+
+    def _write_cc_test(
+        self,
+        file: _OutputFile,
+        target: '_BuildFile.CcTarget',
+    ) -> None:
+        _CmakeBuildFile._target(file, 'pw_auto_add_module_tests',
+                                self._ctx.name.full, {'private_deps': []})
+
+    def _write_docs_target(
+        self,
+        file: _OutputFile,
+        docs_sources: List[str],
+    ) -> None:
+        file.line('# CMake does not yet support building docs.')
+
+    @staticmethod
+    def _target(
+        file: _OutputFile,
+        target_type: str,
+        name: str,
+        keys: Dict[str, List[str]],
+    ) -> None:
+        file.line(f'{target_type}({name}')
+
+        with file.indent():
+            for k, vals in keys.items():
+                file.line(k.upper())
+                with file.indent():
+                    for val in sorted(vals):
+                        file.line(val)
+
+        file.line(')')
+
+
+class _LanguageGenerator:
+    """Generates files for a programming language in a new Pigweed module."""
+    def __init__(self, ctx: _ModuleContext) -> None:
+        self._ctx = ctx
+
+    @abc.abstractmethod
+    def create_source_files(self) -> None:
+        """Creates the boilerplate source files required by the language."""
+
+
+class _CcLanguageGenerator(_LanguageGenerator):
+    """Generates boilerplate source files for a C++ module."""
+    def __init__(self, ctx: _ModuleContext) -> None:
+        super().__init__(ctx)
+
+        self._public_dir = ctx.dir / 'public'
+        self._headers_dir = self._public_dir / ctx.name.full
+
+    def create_source_files(self) -> None:
+        self._headers_dir.mkdir(parents=True)
+
+        main_header = self._new_header(self._ctx.name.main)
+        main_source = self._new_source(self._ctx.name.main)
+        test_source = self._new_source(f'{self._ctx.name.main}_test')
+
+        # TODO(frolv): This could be configurable.
+        namespace = self._ctx.name.default_namespace
+
+        main_source.line(
+            f'#include "{main_header.path.relative_to(self._public_dir)}"\n')
+        main_source.line(f'namespace {namespace} {{\n')
+        main_source.line('int magic = 42;\n')
+        main_source.line(f'}}  // namespace {namespace}')
+
+        main_header.line(f'namespace {namespace} {{\n')
+        main_header.line('extern int magic;\n')
+        main_header.line(f'}}  // namespace {namespace}')
+
+        test_source.line(
+            f'#include "{main_header.path.relative_to(self._public_dir)}"\n')
+        test_source.line('#include "gtest/gtest.h"\n')
+        test_source.line(f'namespace {namespace} {{')
+        test_source.line('namespace {\n')
+        test_source.line(
+            f'TEST({self._ctx.name.upper_camel_case()}, GeneratesCorrectly) {{'
+        )
+        with test_source.indent():
+            test_source.line('EXPECT_EQ(magic, 42);')
+        test_source.line('}\n')
+        test_source.line('}  // namespace')
+        test_source.line(f'}}  // namespace {namespace}')
+
+        self._ctx.add_cc_target(
+            _BuildFile.CcTarget(name=self._ctx.name.full,
+                                sources=[main_source.path],
+                                headers=[main_header.path]))
+
+        self._ctx.add_cc_test(
+            _BuildFile.CcTarget(name=f'{self._ctx.name.main}_test',
+                                deps=[f':{self._ctx.name.full}'],
+                                sources=[test_source.path]))
+
+        main_header.write()
+        main_source.write()
+        test_source.write()
+
+    def _new_source(self, name: str) -> _OutputFile:
+        file = _OutputFile(self._ctx.dir / f'{name}.cc')
+
+        if self._ctx.is_upstream:
+            file.line(_PIGWEED_LICENSE_CC)
+            file.line()
+
+        return file
+
+    def _new_header(self, name: str) -> _OutputFile:
+        file = _OutputFile(self._headers_dir / f'{name}.h')
+
+        if self._ctx.is_upstream:
+            file.line(_PIGWEED_LICENSE_CC)
+
+        file.line('#pragma once\n')
+        return file
+
+
+_BUILD_FILES: Dict[str, Type[_BuildFile]] = {
+    'bazel': _BazelBuildFile,
+    'cmake': _CmakeBuildFile,
+    'gn': _GnBuildFile,
+}
+
+_LANGUAGE_GENERATORS: Dict[str, Type[_LanguageGenerator]] = {
+    'cc': _CcLanguageGenerator,
+}
+
+
+def _check_module_name(
+    module: str,
+    is_upstream: bool,
+) -> Optional[_ModuleName]:
+    """Checks whether a module name is valid."""
+
+    name = _ModuleName.parse(module)
+    if not name:
+        _LOG.error('"%s" does not conform to the Pigweed module name format',
+                   module)
+        return None
+
+    if is_upstream and name.prefix != 'pw':
+        _LOG.error('Modules within Pigweed itself must start with "pw_"')
+        return None
+
+    return name
+
+
+def _create_main_docs_file(ctx: _ModuleContext) -> None:
+    """Populates the top-level docs.rst file within a new module."""
+
+    docs_file = _OutputFile(ctx.dir / 'docs.rst')
+    docs_file.line(f'.. _module-{ctx.name}:\n')
+
+    title = '=' * len(ctx.name.full)
+    docs_file.line(title)
+    docs_file.line(ctx.name.full)
+    docs_file.line(title)
+    docs_file.line(f'This is the main documentation file for {ctx.name}.')
+
+    ctx.add_docs_file(docs_file.path)
+
+    docs_file.write()
+
+
+def _basic_module_setup(
+    module_name: _ModuleName,
+    module_dir: Path,
+    build_systems: Iterable[str],
+    is_upstream: bool,
+) -> _ModuleContext:
+    """Creates the basic layout of a Pigweed module."""
+    module_dir.mkdir()
+
+    ctx = _ModuleContext(name=module_name,
+                         dir=module_dir,
+                         root_build_files=[],
+                         sub_build_files=[],
+                         build_systems=list(build_systems),
+                         is_upstream=is_upstream)
+
+    ctx.root_build_files.extend(_BUILD_FILES[build](module_dir, ctx)
+                                for build in ctx.build_systems)
+
+    _create_main_docs_file(ctx)
+
+    return ctx
+
+
+def _create_module(module: str, languages: Iterable[str],
+                   build_systems: Iterable[str]) -> None:
+    project_root = Path(os.environ.get('PW_PROJECT_ROOT', ''))
+    assert project_root.is_dir()
+
+    is_upstream = os.environ.get('PW_ROOT') == str(project_root)
+
+    module_name = _check_module_name(module, is_upstream)
+    if not module_name:
+        sys.exit(1)
+
+    if not is_upstream:
+        _LOG.error('`pw module create` is experimental and does '
+                   'not yet support downstream projects.')
+        sys.exit(1)
+
+    module_dir = project_root / module
+
+    if module_dir.is_dir():
+        _LOG.error('Module %s already exists', module)
+        sys.exit(1)
+
+    if module_dir.is_file():
+        _LOG.error(
+            'Cannot create module %s as a file of that name already exists',
+            module)
+        sys.exit(1)
+
+    ctx = _basic_module_setup(module_name, module_dir, build_systems,
+                              is_upstream)
+
+    try:
+        generators = list(_LANGUAGE_GENERATORS[lang](ctx)
+                          for lang in languages)
+    except KeyError as key:
+        _LOG.error('Unsupported language: %s', key)
+        sys.exit(1)
+
+    for generator in generators:
+        generator.create_source_files()
+
+    for build_file in ctx.build_files():
+        build_file.write()
+
+    if is_upstream:
+        modules_file = project_root / 'PIGWEED_MODULES'
+        if not modules_file.exists():
+            _LOG.error('Could not locate PIGWEED_MODULES file; '
+                       'your repository may be in a bad state.')
+            return
+
+        modules_gni_file = (project_root / 'pw_build' /
+                            'generated_pigweed_modules_lists.gni')
+
+        # Cut off the extra newline at the end of the file.
+        modules_list = modules_file.read_text().split('\n')[:-1]
+        modules_list.append(module_name.full)
+        modules_list.sort()
+        modules_list.append('')
+        modules_file.write_text('\n'.join(modules_list))
+        print('  modify  ' + str(modules_file.relative_to(Path.cwd())))
+
+        generate_modules_lists.main(
+            root=project_root,
+            modules_list=modules_file,
+            modules_gni_file=modules_gni_file,
+            warn_only=None,
+        )
+        print('  modify  ' + str(modules_gni_file.relative_to(Path.cwd())))
+
+    print()
+    _LOG.info('Module %s created at %s', module_name,
+              module_dir.relative_to(Path.cwd()))
+
+
+def register_subcommand(parser: argparse.ArgumentParser) -> None:
+    csv = lambda s: s.split(',')
+
+    parser.add_argument(
+        '--build-systems',
+        help=('Comma-separated list of build systems the module supports. '
+              f'Options: {", ".join(_BUILD_FILES.keys())}'),
+        type=csv,
+        default=_BUILD_FILES.keys(),
+        metavar='BUILD[,BUILD,...]')
+    parser.add_argument(
+        '--languages',
+        help=('Comma-separated list of languages the module will use. '
+              f'Options: {", ".join(_LANGUAGE_GENERATORS.keys())}'),
+        type=csv,
+        default=[],
+        metavar='LANG[,LANG,...]')
+    parser.add_argument('module',
+                        help='Name of the module to create.',
+                        metavar='MODULE_NAME')
+    parser.set_defaults(func=_create_module)