diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 717dd3d..46dd297 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: python3 \ python3-venv python3 -m venv build/build_common/ci_python - build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2 + build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2 -r tools/docker/format-requirements.txt echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH" - name: Restore Conan cache uses: actions/cache@v4 @@ -50,6 +50,10 @@ jobs: run: ./tools/configure.py --target x86_64_gcc12_release - name: Test tooling run: python3 -m unittest discover -s tests -p 'test_*.py' + - name: Check code style + run: | + python3 -m pip install --no-cache-dir -r tools/docker/format-requirements.txt + python3 tools/format.py --check - name: Build native release run: ./tools/build.py --target x86_64_gcc12_release - name: Test native release diff --git a/README.md b/README.md index aaefa21..33a68d8 100644 --- a/README.md +++ b/README.md @@ -47,14 +47,26 @@ Product repos can add typed extra CMake compile databases under `tooling.lsp.com {"CompileFlags": {"CompilationDatabase": "build/native"}}, {"If": {"PathMatch": "src/firmware/.*"}, "CompileFlags": {"CompilationDatabase": "build/firmware"}} ], - "clang_format": {"DisableFormat": true}, + "clang_format": {"BasedOnStyle": "LLVM", "IndentWidth": 2, "ColumnLimit": 0, "SortIncludes": "Never"}, "pyright": {"include": ["tools", "conanfile.py"]} } } } ``` -Each supplied entry replaces that file's entire configuration. Omitted entries use Modulus defaults: the default target's build directory for clangd, `-Wall`/`-Wextra`, disabled clang-tidy and formatting, and Python paths for the bootstrap, recipe, and exported tools. Entries use the editors' own configuration keys. LLVM files use JSON-compatible YAML, with separate documents for clangd fragments. +Each supplied entry replaces that file's entire configuration. Omitted entries use Modulus defaults: the default target's build directory for clangd, `-Wall`/`-Wextra`, disabled clang-tidy, the compact formatting policy below, and Python paths for the bootstrap, recipe, and exported tools. Entries use the editors' own configuration keys. LLVM files use JSON-compatible YAML, with separate documents for clangd fragments. + +### Shared Code Style + +Run `python3 tools/format.py` in Modulus, or `./modulus.py format` in a consumer. Optional repository-relative paths restrict the operation. `format --check` reports layout differences and prohibited aliases without writing files; CI runs this check. `format --stdin-filename /absolute/buffer.cpp` reads stdin and writes only formatted text, for editor integrations. + +The build image pins clang-format 19.1.7, autopep8 2.3.2, and the syntax parser dependencies in `tools/docker/format-requirements.txt`. Python uses two-space indentation for both blocks and continuations, including multiline tuples, lists, dictionaries, imports, and calls. Continuations indent two spaces from their opening line rather than aligning to a column; closing brackets align with the opening line. Line-length and aggressive semantic fixes are disabled. C++ has no column limit, uses braces, and preserves include order and groups. The shared syntax-aware pass joins simple signatures/calls, removes blank lines inside functions and class/struct bodies, separates definitions outside classes by one blank line, and pads namespace interiors without indenting their bodies. Class members, including access labels, defaulted/deleted constructors, inline methods, and nested types, have no intervening blank lines. Blank lines within literal and comment contents are preserved. Comments, multiline strings, structured literals, and preprocessor content are not joined. Python formatting verifies AST equivalence before writing. C++ formatting supplements clang-format using concrete syntax boundaries, including common test macros; it is not a C++ type checker. + +Use full type/module names rather than C++ type/namespace aliases, Python type aliases, or renamed imports. The existing `std::*_t` portability definitions in `include/modulus/types.hpp` are the sole local exception; third-party SDK definitions are not rewritten. Formatting changes layout only, so alias diagnostics require explicit code changes. + +Discovery includes Git-tracked and nonignored untracked C/C++ and Python sources. Generated/build/vendor directories and symlinks are excluded. Add project-relative glob patterns with `tooling.format.exclude`; C++ settings remain configurable through `tooling.editor.clang_format`. A batch validates every input before it writes any output. + +Neovim uses Conform for C++ and Python in registered Modulus projects, retaining clangd and Pyright for language features. LazyVim's manual command, format-on-save handling, and global/buffer toggles remain in control. Other projects retain their formatter selections. There is no editor-triggered download, image build, host formatter fallback, or compilation-database requirement. Missing/stale images fail on stderr without replacing the buffer. Configure first to prepare the image. `MODULUS_DIRECT=1` is for provisioned CI environments, which install the same pinned requirements; it is not set by the editor. The authored `cmake/` directory belongs in Git. `CMakeFiles/`, compiler caches, and generated sources belong in build directories. Modulus rejects project/source-root output directories before clearing configure or LSP caches. Conan may still use its own isolated build folders. Existing build caches are retained; they are not source files. diff --git a/conanfile.py b/conanfile.py index 995418b..e43eedf 100644 --- a/conanfile.py +++ b/conanfile.py @@ -8,8 +8,6 @@ import conan.tools.files import modulus.config -config = modulus.config - class modulus_recipe(conan.ConanFile): name = "modulus" version = "latest" @@ -83,26 +81,18 @@ class modulus_recipe(conan.ConanFile): "tools/docker/*", "README.md", ) - def config_options(self): settings = self.settings options = self.options if settings.os == "Windows": del options.fPIC - def configure(self): options = self.options if options.shared: del options.fPIC - def requirements(self): config = modulus.config.load_recipe_config(self) - self.requires( - modulus.config.boost_ref(config), - transitive_headers=True, - transitive_libs=True, - options=modulus.config.boost_requirement_options(config), - ) + self.requires(modulus.config.boost_ref(config), transitive_headers=True, transitive_libs=True, options=modulus.config.boost_requirement_options(config)) if self.options.with_openssl: self.requires( "openssl/4.0.1", @@ -127,7 +117,6 @@ class modulus_recipe(conan.ConanFile): transitive_headers=True, transitive_libs=True, ) - def generate(self): options = self.options deps = conan.tools.cmake.CMakeDeps(self) @@ -142,7 +131,6 @@ class modulus_recipe(conan.ConanFile): toolchain.variables["MODULUS_WITH_RAYLIB"] = bool(options.with_raylib) toolchain.variables["MODULUS_WITH_MINIAUDIO"] = bool(options.with_miniaudio) toolchain.generate() - def build(self): options = self.options cmake = conan.tools.cmake.CMake(self) @@ -150,17 +138,14 @@ class modulus_recipe(conan.ConanFile): cmake.build() if options.with_tests and conan.tools.build.can_run(self): cmake.test(target="check") - def package_id(self): self.info.options.rm_safe("with_tests") - def package(self): cmake = conan.tools.cmake.CMake(self) cmake.install() package = pathlib.Path(self.package_folder) conan.tools.files.copy(self, "*.py", src=pathlib.Path(self.recipe_folder) / "modulus", dst=package / "modulus", keep_path=True) conan.tools.files.copy(self, "*", src=pathlib.Path(self.source_folder) / "tools", dst=package / "tools", keep_path=True) - def package_info(self): cpp_info = self.cpp_info cpp_info.set_property("cmake_file_name", "modulus") @@ -173,18 +158,14 @@ class modulus_recipe(conan.ConanFile): components["modulus"].set_property("cmake_target_name", "modulus::modulus") components["modulus"].libs = ["modulus"] components["modulus"].requires = ["boost::json"] - components["boost_json"].set_property("cmake_target_name", "modulus::boost_json") components["boost_json"].requires = ["boost::json"] - if self.options.with_openssl: components["openssl"].set_property("cmake_target_name", "modulus::openssl") components["openssl"].requires = ["openssl::ssl", "openssl::crypto"] - if self.options.with_sqlite: components["sqlite"].set_property("cmake_target_name", "modulus::sqlite") components["sqlite"].requires = ["sqlite3::sqlite"] - if self.options.with_raylib: components["raylib"].set_property("cmake_target_name", "modulus::raylib") components["raylib"].requires = ["raylib::raylib"] @@ -197,7 +178,6 @@ class modulus_recipe(conan.ConanFile): "atomic", "dl", ] - if self.options.with_miniaudio: components["miniaudio"].set_property("cmake_target_name", "modulus::miniaudio") components["miniaudio"].requires = ["miniaudio::miniaudio"] diff --git a/include/modulus/config.hpp b/include/modulus/config.hpp index 9bc749c..11ad304 100644 --- a/include/modulus/config.hpp +++ b/include/modulus/config.hpp @@ -8,6 +8,7 @@ #include #include + namespace modulus { struct logging_config { @@ -19,14 +20,14 @@ struct config { modulus::error_config errors{}; }; -auto parse_logging_config(boost::json::value const& value) -> std::expected; -auto parse_error_config(boost::json::value const& value) -> std::expected; -auto parse_config(boost::json::value const& value) -> std::expected; -auto load_config(std::string const& path) -> std::expected; -auto to_json(modulus::logging_config const& config) -> boost::json::value; -auto to_json(modulus::error_config const& config) -> boost::json::value; -auto to_json(modulus::config const& config) -> boost::json::value; -auto configure_logger(modulus::logger& logger, modulus::logging_config const& config) -> void; -auto configure(modulus::config const& config) -> void; +auto parse_logging_config(boost::json::value const &value) -> std::expected; +auto parse_error_config(boost::json::value const &value) -> std::expected; +auto parse_config(boost::json::value const &value) -> std::expected; +auto load_config(std::string const &path) -> std::expected; +auto to_json(modulus::logging_config const &config) -> boost::json::value; +auto to_json(modulus::error_config const &config) -> boost::json::value; +auto to_json(modulus::config const &config) -> boost::json::value; +auto configure_logger(modulus::logger &logger, modulus::logging_config const &config) -> void; +auto configure(modulus::config const &config) -> void; } diff --git a/include/modulus/error.hpp b/include/modulus/error.hpp index 3fe945b..7b5b41f 100644 --- a/include/modulus/error.hpp +++ b/include/modulus/error.hpp @@ -7,6 +7,7 @@ #include #include + namespace modulus { struct error_config { @@ -19,12 +20,12 @@ struct error_config { class error { public: error() = default; - error(std::string category, std::int32_t code, std::string message, modulus::source_location location = modulus::source_location( __builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())); + error(std::string category, std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())); auto category() const noexcept -> std::string_view; auto code() const noexcept -> std::int32_t; auto message() const noexcept -> std::string_view; - auto location() const noexcept -> const modulus::source_location&; - auto describe(modulus::error_config const& config) const -> std::string; + auto location() const noexcept -> const modulus::source_location &; + auto describe(modulus::error_config const &config) const -> std::string; auto describe() const -> std::string; private: std::string category_{"generic"}; @@ -33,7 +34,7 @@ private: modulus::source_location location_{modulus::source_location::unknown()}; }; -auto make_error(std::string category, std::int32_t code, std::string message, modulus::source_location location = modulus::source_location( __builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> modulus::error; +auto make_error(std::string category, std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> modulus::error; auto set_default_error_config(modulus::error_config config) -> void; auto default_error_config() -> modulus::error_config; diff --git a/include/modulus/json_config.hpp b/include/modulus/json_config.hpp index 1d16ded..7053ce5 100644 --- a/include/modulus/json_config.hpp +++ b/include/modulus/json_config.hpp @@ -30,22 +30,22 @@ struct enum_mapping { auto invalid(modulus::json_config::options config, std::string message) -> modulus::error; auto not_found(modulus::json_config::options config, std::string message) -> modulus::error; -auto load_value(std::string const& path, modulus::json_config::options config = {}) -> std::expected; -auto require_object(boost::json::value const& value, std::string_view context, modulus::json_config::options config = {}) -> std::expected; -auto reject_unknown_fields(boost::json::object const& object, std::initializer_list allowed, std::string_view context, modulus::json_config::options config = {}) -> std::expected; -auto optional_bool_field(boost::json::object const& object, std::string_view name, std::bool_t fallback, modulus::json_config::options config = {}) -> std::expected; -auto optional_string_field(boost::json::object const& object, std::string_view name, std::string fallback, modulus::json_config::options config = {}) -> std::expected; -auto required_string_field(boost::json::object const& object, std::string_view name, std::string_view context, modulus::json_config::options config = {}) -> std::expected; -auto optional_uint64_field(boost::json::object const& object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config = {}) -> std::expected; -auto positive_uint64_field(boost::json::object const& object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config = {}) -> std::expected; -auto optional_int32_field(boost::json::object const& object, std::string_view name, std::int32_t fallback, std::int32_t min_value, std::int32_t max_value, modulus::json_config::options config = {}) -> std::expected; -auto positive_milliseconds_field(boost::json::object const& object, std::string_view name, std::chrono::milliseconds fallback, std::uint64_t max_value = static_cast(std::numeric_limits::max()), modulus::json_config::options config = {}) -> std::expected; +auto load_value(std::string const &path, modulus::json_config::options config = {}) -> std::expected; +auto require_object(boost::json::value const &value, std::string_view context, modulus::json_config::options config = {}) -> std::expected; +auto reject_unknown_fields(boost::json::object const &object, std::initializer_list allowed, std::string_view context, modulus::json_config::options config = {}) -> std::expected; +auto optional_bool_field(boost::json::object const &object, std::string_view name, std::bool_t fallback, modulus::json_config::options config = {}) -> std::expected; +auto optional_string_field(boost::json::object const &object, std::string_view name, std::string fallback, modulus::json_config::options config = {}) -> std::expected; +auto required_string_field(boost::json::object const &object, std::string_view name, std::string_view context, modulus::json_config::options config = {}) -> std::expected; +auto optional_uint64_field(boost::json::object const &object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config = {}) -> std::expected; +auto positive_uint64_field(boost::json::object const &object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config = {}) -> std::expected; +auto optional_int32_field(boost::json::object const &object, std::string_view name, std::int32_t fallback, std::int32_t min_value, std::int32_t max_value, modulus::json_config::options config = {}) -> std::expected; +auto positive_milliseconds_field(boost::json::object const &object, std::string_view name, std::chrono::milliseconds fallback, std::uint64_t max_value = static_cast(std::numeric_limits::max()), modulus::json_config::options config = {}) -> std::expected; template auto parse_enum(std::string_view value, std::initializer_list> mappings, std::string_view message, modulus::json_config::options config = {}) -> std::expected; template -auto optional_enum_field(boost::json::object const& object, std::string_view name, ENUM fallback, std::initializer_list> mappings, std::string_view message, modulus::json_config::options config = {}) -> std::expected; +auto optional_enum_field(boost::json::object const &object, std::string_view name, ENUM fallback, std::initializer_list> mappings, std::string_view message, modulus::json_config::options config = {}) -> std::expected; } @@ -60,7 +60,7 @@ auto modulus::json_config::parse_enum(std::string_view value, std::initializer_l } template -auto modulus::json_config::optional_enum_field(boost::json::object const& object, std::string_view name, ENUM fallback, std::initializer_list> mappings, std::string_view message, modulus::json_config::options config) -> std::expected { +auto modulus::json_config::optional_enum_field(boost::json::object const &object, std::string_view name, ENUM fallback, std::initializer_list> mappings, std::string_view message, modulus::json_config::options config) -> std::expected { auto found = object.find(name); if (found == object.end()) { return fallback; diff --git a/include/modulus/log.hpp b/include/modulus/log.hpp index 9d949cc..32b04ab 100644 --- a/include/modulus/log.hpp +++ b/include/modulus/log.hpp @@ -13,6 +13,7 @@ #include #include + namespace modulus { enum class log_level : std::int32_t { @@ -41,20 +42,20 @@ public: auto set_level(modulus::log_level level) -> void; auto level() const -> modulus::log_level; auto enabled(modulus::log_level level) const -> std::bool_t; - auto set_sink(std::function sink) -> void; + auto set_sink(std::function sink) -> void; auto write(modulus::log_record record) const -> void; - auto log(modulus::log_level level, std::string message, modulus::source_location location = modulus::source_location( __builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) const -> void; + auto log(modulus::log_level level, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) const -> void; private: mutable std::mutex mutex_; mutable std::mutex sink_mutex_; modulus::log_level level_{modulus::log_level::INFO}; - std::function sink_{}; + std::function sink_{}; }; -auto default_logger() -> modulus::logger&; +auto default_logger() -> modulus::logger &; auto set_default_log_level(modulus::log_level level) -> void; auto is_log_enabled(modulus::log_level level) -> std::bool_t; -auto log(modulus::log_level level, std::string message, modulus::source_location location = modulus::source_location( __builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; +auto log(modulus::log_level level, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; auto log_trace(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; auto log_debug(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; auto log_info(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; @@ -63,12 +64,12 @@ auto log_error(std::string message, modulus::source_location location = modulus: auto log_critical(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; template -auto log_lazy(modulus::log_level level, MESSAGE&& message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; +auto log_lazy(modulus::log_level level, MESSAGE &&message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; } template -auto modulus::log_lazy(modulus::log_level level, MESSAGE&& message, modulus::source_location location) -> void { +auto modulus::log_lazy(modulus::log_level level, MESSAGE &&message, modulus::source_location location) -> void { assert(static_cast(level) >= static_cast(modulus::log_level::TRACE)); assert(static_cast(level) <= static_cast(modulus::log_level::OFF)); assert(location.file_name() != nullptr); diff --git a/include/modulus/result.hpp b/include/modulus/result.hpp index a91ffdc..8142340 100644 --- a/include/modulus/result.hpp +++ b/include/modulus/result.hpp @@ -7,10 +7,11 @@ #include + namespace modulus { template -auto success(VALUE&& value) -> std::expected, modulus::error>; +auto success(VALUE &&value) -> std::expected, modulus::error>; template auto fail(modulus::error value) -> std::expected; @@ -18,7 +19,7 @@ auto fail(modulus::error value) -> std::expected; } template -auto modulus::success(VALUE&& value) -> std::expected, modulus::error> { +auto modulus::success(VALUE &&value) -> std::expected, modulus::error> { return std::forward(value); } diff --git a/include/modulus/source_location.hpp b/include/modulus/source_location.hpp index 0cf25f0..32f8277 100644 --- a/include/modulus/source_location.hpp +++ b/include/modulus/source_location.hpp @@ -5,31 +5,33 @@ #include + namespace modulus { class source_location { public: constexpr source_location() noexcept = default; - constexpr source_location(std::char_t const* file, std::uint_least32_t line, std::char_t const* function) noexcept; - static consteval auto current(std::char_t const* file = __builtin_FILE(), std::uint_least32_t line = __builtin_LINE(), std::char_t const* function = __builtin_FUNCTION()) noexcept -> modulus::source_location; + constexpr source_location(std::char_t const *file, std::uint_least32_t line, std::char_t const *function) noexcept; + static consteval auto current(std::char_t const *file = __builtin_FILE(), std::uint_least32_t line = __builtin_LINE(), std::char_t const *function = __builtin_FUNCTION()) noexcept -> modulus::source_location; static constexpr auto unknown() noexcept -> modulus::source_location; - constexpr auto file_name() const noexcept -> std::char_t const*; + constexpr auto file_name() const noexcept -> std::char_t const *; constexpr auto line() const noexcept -> std::uint_least32_t; - constexpr auto function_name() const noexcept -> std::char_t const*; + constexpr auto function_name() const noexcept -> std::char_t const *; private: - std::char_t const* file_{""}; + std::char_t const *file_{""}; std::uint_least32_t line_{}; - std::char_t const* function_{""}; + std::char_t const *function_{""}; }; } -constexpr modulus::source_location::source_location(std::char_t const* file, std::uint_least32_t line, std::char_t const* function) noexcept : file_(file), line_(line), function_(function) { +constexpr modulus::source_location::source_location(std::char_t const *file, std::uint_least32_t line, std::char_t const *function) noexcept + : file_(file), line_(line), function_(function) { assert(file != nullptr); assert(function != nullptr); } -consteval auto modulus::source_location::current(std::char_t const* file, std::uint_least32_t line, std::char_t const* function) noexcept -> modulus::source_location { +consteval auto modulus::source_location::current(std::char_t const *file, std::uint_least32_t line, std::char_t const *function) noexcept -> modulus::source_location { assert(file != nullptr); assert(function != nullptr); return modulus::source_location(file, line, function); @@ -39,7 +41,7 @@ constexpr auto modulus::source_location::unknown() noexcept -> modulus::source_l return modulus::source_location(); } -constexpr auto modulus::source_location::file_name() const noexcept -> std::char_t const* { +constexpr auto modulus::source_location::file_name() const noexcept -> std::char_t const * { assert(file_ != nullptr); return file_; } @@ -49,7 +51,7 @@ constexpr auto modulus::source_location::line() const noexcept -> std::uint_leas return line_; } -constexpr auto modulus::source_location::function_name() const noexcept -> std::char_t const* { +constexpr auto modulus::source_location::function_name() const noexcept -> std::char_t const * { assert(function_ != nullptr); return function_; } diff --git a/include/modulus/types.hpp b/include/modulus/types.hpp index 0738fd3..4fcab5e 100644 --- a/include/modulus/types.hpp +++ b/include/modulus/types.hpp @@ -3,10 +3,11 @@ #include #if defined(__has_include) -# if __has_include() -# include -# endif +#if __has_include() +#include #endif +#endif + namespace std { diff --git a/modulus/__init__.py b/modulus/__init__.py index b5757f8..a9a2c5b 100644 --- a/modulus/__init__.py +++ b/modulus/__init__.py @@ -1,2 +1 @@ - __all__ = [] diff --git a/modulus/clangd.py b/modulus/clangd.py index b30aabe..bb0abad 100644 --- a/modulus/clangd.py +++ b/modulus/clangd.py @@ -1,4 +1,3 @@ -"""Prepare container headers separately from the stdout-clean LSP transport.""" import fcntl import hashlib import json @@ -22,7 +21,11 @@ def settings(context): return {**modulus.config.clangd_defaults, **context.lsp_settings.get("clangd", {})} def recipe_digest(context): - return hashlib.sha256(context.dockerfile.read_bytes()).hexdigest() + digest = hashlib.sha256(context.dockerfile.read_bytes()) + requirements = context.dockerfile.parent / "format-requirements.txt" + if requirements.is_file(): + digest.update(requirements.read_bytes()) + return digest.hexdigest() def current_image(context, architecture=None): try: @@ -106,7 +109,7 @@ def prepare(context): probe = context.tools_dir / "clangd_headers.py" result = subprocess.run( [*isolated_run(image), "--volume", f"{probe}:/modulus-headers.py:ro", image, - "python3", "/modulus-headers.py", json.dumps(settings(context))], + "python3", "/modulus-headers.py", json.dumps(settings(context))], stdout=subprocess.PIPE, text=True, check=True, ) roots = json.loads(result.stdout) @@ -114,8 +117,8 @@ def prepare(context): stage = pathlib.Path(temporary) / "cache" stage.mkdir() command = [*isolated_run(image), image, "tar", "--dereference", "--hard-dereference", - "--exclude=*/.git", "--exclude=*/__pycache__", - "-C", "/", "-cf", "-", *[root.lstrip("/") for root in roots]] + "--exclude=*/.git", "--exclude=*/__pycache__", + "-C", "/", "-cf", "-", *[root.lstrip("/") for root in roots]] with subprocess.Popen(command, stdout=subprocess.PIPE) as process: try: extract_headers(process.stdout, stage / "rootfs", roots) @@ -144,7 +147,7 @@ def path_mappings(cache, roots): def server_arguments(context, argv): config = settings(context) return [config["executable"], *config["arguments"], *argv, - "--query-driver=" + ",".join(config["query_drivers"])] + "--query-driver=" + ",".join(config["query_drivers"])] def launch_command(context, argv, image, cache, roots, name): root = context.repo_root @@ -184,8 +187,10 @@ def launch(context, argv): def run_server(command, name): # Inherit stdio verbatim; never send build logs or a terminal over the LSP pipe. + def interrupted(signum, frame): raise SystemExit(128 + signum) + previous = {sig: signal.signal(sig, interrupted) for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)} process = None try: @@ -197,7 +202,7 @@ def run_server(command, name): if process is not None: try: subprocess.run(["docker", "rm", "--force", name], stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, check=False, timeout=15) + stderr=subprocess.DEVNULL, check=False, timeout=15) except (OSError, subprocess.TimeoutExpired) as error: print(f"Modulus clangd cleanup: {error}", file=sys.stderr) try: diff --git a/modulus/cli.py b/modulus/cli.py index abafda1..6887256 100644 --- a/modulus/cli.py +++ b/modulus/cli.py @@ -12,6 +12,7 @@ import urllib.parse import modulus.config import modulus.export import modulus.clangd +import modulus.formatting build_root = pathlib.Path("build") common_dir = build_root / "build_common" @@ -30,6 +31,8 @@ def main(command): repo_root = find_repo_root(pathlib.Path.cwd()) config = modulus.config.load_repo_config(repo_root) context = build_context(repo_root, config) + if command == "format": + return modulus.formatting.launch(context, sys.argv[1:]) if command == "clangd": return modulus.clangd.launch(context, sys.argv[1:]) if command in {"configure", "lsp"}: @@ -79,7 +82,6 @@ class build_context: self.docker_marker = pathlib.Path(str(docker["marker"])) self.upload_env = str(self.tooling["upload_env"]) self.package_upload_env = str(self.artifact["upload_env"]) - def _targets(self): result = {} targets = self.tooling["targets"] @@ -94,14 +96,12 @@ class build_context: cmake_definitions=dict(target.get("cmake_definitions", {})), ) return result - def target(self, name): try: return self.targets[name] except KeyError: valid = ", ".join(sorted(self.targets)) raise SystemExit(f"unknown target '{name}'. Valid targets: {valid}") from None - def selected_targets(self, names, all_targets): if all_targets: return list(self.targets.values()) @@ -165,7 +165,7 @@ def require_conan_credentials(operation): return None return user, token -def run(context, command, cwd=None, env_overrides=None, env_unset=None, display_command=None,): +def run(context, command, cwd=None, env_overrides=None, env_unset=None, display_command=None): visible = display_command if display_command is not None else command print("+", " ".join(shlex.quote(str(part)) for part in visible)) env = os.environ.copy() @@ -287,6 +287,8 @@ def build_image(context): context.docker_image, "--label", f"{modulus.clangd.image_label}={modulus.clangd.recipe_digest(context)}", + "--build-arg", + f"MODULUS_TOOLS={context.tools_rel}", ".", ], ) @@ -658,7 +660,6 @@ def lsp(context, argv): parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes for Modulus build-target compile databases.") args = parser.parse_args(argv) - if not args.no_build_targets: context.conan_cache_dir.mkdir(parents=True, exist_ok=True) if not args.no_remote: @@ -671,7 +672,6 @@ def lsp(context, argv): status = configure_one(context, target, args.update, no_remote=args.no_remote) if status != 0: return status - database_names = args.database or [str(database["name"]) for database in lsp_compile_databases(context)] for name in database_names: status = configure_lsp_database(context, database_map[name]) diff --git a/modulus/config.py b/modulus/config.py index 0d0a3a3..86ad4c1 100644 --- a/modulus/config.py +++ b/modulus/config.py @@ -4,6 +4,8 @@ import os import re import pathlib +import modulus.format_layout + class config_error(RuntimeError): pass @@ -85,6 +87,7 @@ tooling_keys = { "remote", "run", "lsp", + "format", } run_keys = { "enabled", @@ -224,6 +227,11 @@ def normalize(data): ) if not isinstance(tooling, dict): raise config_error("modulus.json tooling must be an object") + formatting = tooling.setdefault("format", {}) + if not isinstance(formatting, dict): + raise config_error("modulus.json tooling.format must be an object") + validate_keys(formatting, {"exclude"}, "tooling.format") + validate_string_list(formatting.setdefault("exclude", []), "tooling.format.exclude") tooling.setdefault("default_target", "x86_64_gcc12_debug") tooling.setdefault("targets", default_targets()) tooling.setdefault( @@ -285,7 +293,7 @@ def default_editor_config(tooling): }, "Diagnostics": {"ClangTidy": {"Remove": ["*"]}}, }], - "clang_format": {"DisableFormat": True}, + "clang_format": copy.deepcopy(modulus.format_layout.clang_style), "pyright": { "include": ["modulus.py", "conanfile.py", "build/build_tools"], "extraPaths": ["build/build_common/modulus", "."], diff --git a/modulus/format_layout.py b/modulus/format_layout.py new file mode 100644 index 0000000..1db47cf --- /dev/null +++ b/modulus/format_layout.py @@ -0,0 +1,301 @@ +"""Syntax-aware whitespace rules supplementing clang-format and autopep8.""" +import ast +import io +import json +import re +import subprocess +import tokenize + +clang_style = { + "BasedOnStyle": "LLVM", + "IndentWidth": 2, + "ContinuationIndentWidth": 2, + "ColumnLimit": 0, + "NamespaceIndentation": "None", + "BreakBeforeBraces": "Attach", + "AllowShortFunctionsOnASingleLine": "Empty", + "AllowShortIfStatementsOnASingleLine": "Never", + "AllowShortLoopsOnASingleLine": False, + "InsertBraces": True, + "SeparateDefinitionBlocks": "Always", + "MaxEmptyLinesToKeep": 1, + "KeepEmptyLinesAtTheStartOfBlocks": False, + "SortIncludes": "Never", + "IncludeBlocks": "Preserve", + "SortUsingDeclarations": "Never", + "BreakStringLiterals": False, + "ReflowComments": False, + "FixNamespaceComments": False, +} + +def descendants(node): + yield node + for child in node.children: + yield from descendants(child) + +def cpp_tree(source): + import tree_sitter + import tree_sitter_cpp + parser = tree_sitter.Parser(tree_sitter.Language(tree_sitter_cpp.language())) + return parser.parse(source).root_node + +def protected_cpp(node): + return node.type in {"string_literal", "raw_string_literal", "char_literal", "comment", "preproc_def", "preproc_function_def", "preproc_call", "preproc_include", "preproc_arg"} + +def apply_edits(source, edits): + disjoint = [] + end = 0 + for start, stop, replacement in sorted(edits, key=lambda edit: (edit[0], -edit[1])): + if start >= end: + disjoint.append((start, stop, replacement)) + end = stop + for start, stop, replacement in reversed(disjoint): + source = source[:start] + replacement + source[stop:] + return source + +def join_cpp(source): + edits = [] + root = cpp_tree(source) + for node in descendants(root): + if node.type not in {"parameter_list", "argument_list", "template_argument_list", "template_parameter_list"}: + continue + children = list(descendants(node)) + if node.has_error or any(child.type in {"comment", "lambda_expression", "initializer_list"} or (protected_cpp(child) and child.start_point.row != child.end_point.row) for child in children): + continue + parent = node.parent + if any(protected_cpp(ancestor) for ancestor in ancestors(parent)): + continue + value = source[node.start_byte:node.end_byte] + if b"\n" in value: + edits.append((node.start_byte, node.end_byte, re.sub(rb"[ \t]*\r?\n[ \t]*", b" ", value))) + return apply_edits(source, edits) + +def ancestors(node): + while node is not None: + yield node + node = node.parent + +def space_lines(source, compact, units, padding, protected, class_interiors=()): + lines = source.splitlines(keepends=True) + boundaries = set(padding) + for first, last in units: + if first > 0: + boundaries.add(first) + boundaries.add(last + 1) + # Class/struct members stay together even when a member is itself a definition. + boundaries.difference_update(class_interiors) + compact = compact.union(class_interiors) + output = [] + pending = [] + previous = None + for row, line in enumerate(lines): + if not line.strip() and row not in protected: + pending.append(row) + continue + if previous is not None: + gap = range(previous + 1, row + 1) + wanted = any(boundary in boundaries for boundary in gap) + inside = any(position in compact for position in gap) + if wanted or (pending and not inside): + output.append("\n") + output.append(line) + previous = row + pending = [] + return "".join(output).rstrip("\n") + "\n" + +def space_cpp(source): + root = cpp_tree(source.encode()) + compact, protected, padding = set(), set(), set() + class_interiors = set() + units = [] + for node in descendants(root): + first, last = node.start_point.row, node.end_point.row + if protected_cpp(node): + protected.update(range(first, last + 1)) + if node.type == "compound_statement" and not node.has_error: + compact.update(range(first + 1, last + 1)) + if node.type == "field_declaration_list" and len(node.children) >= 2: + opening, closing = node.children[0], node.children[-1] + if opening.type == "{" and closing.type == "}" and not opening.is_missing and not closing.is_missing: + class_interiors.update(range(first + 1, last + 1)) + if node.type == "namespace_definition": + body = node.child_by_field_name("body") + if body and body.end_point.row > body.start_point.row + 1: + padding.update((body.start_point.row + 1, body.end_point.row)) + if node.type in {"function_definition", "class_specifier", "struct_specifier", "enum_specifier", "namespace_definition"} and not node.has_error: + unit = node + while unit.parent and unit.parent.type in {"template_declaration", "declaration", "type_definition"}: + unit = unit.parent + before = source.splitlines()[unit.start_point.row][:unit.start_point.column] + if not before.strip(): + units.append((unit.start_point.row, unit.end_point.row)) + # Unit padding belongs between siblings, never between a block brace and a function. + lines = source.splitlines() + units = [(first, last) for first, last in units if first not in protected and last not in protected] + boundaries = set(padding) + for first, last in units: + previous = next((line.strip() for line in reversed(lines[:first]) if line.strip()), "") + following = next((line.strip() for line in lines[last + 1:] if line.strip()), "") + if previous and not previous.endswith("{"): + boundaries.add(first) + if following and not following.startswith("}"): + boundaries.add(last + 1) + return space_lines(source, compact, [], boundaries, protected, class_interiors) + +def python_tokens(source): + return list(tokenize.generate_tokens(io.StringIO(source).readline)) + +def python_literal_lines(tree): + rows = set() + for node in ast.walk(tree): + if isinstance(node, (ast.JoinedStr, ast.Constant)) and hasattr(node, "end_lineno"): + if isinstance(node, ast.JoinedStr) or isinstance(node.value, (str, bytes)): + rows.update(range(node.lineno - 1, node.end_lineno)) + return rows + +def indent_python(source): + lines = source.splitlines(keepends=True) + indents = [len(line) - len(line.lstrip(" \t")) for line in lines] + tokens = python_tokens(source) + stack, handled = [], set() + statement_indent = None + literal_starts = [] + for index, token in enumerate(tokens): + # Python 3.12+ tokenizes f-string expressions separately; their contents stay intact. + if token.type == getattr(tokenize, "FSTRING_END", None): + handled.update(range(literal_starts.pop(), token.end[0])) + continue + if literal_starts: + if token.type == getattr(tokenize, "FSTRING_START", None): + literal_starts.append(token.start[0]) + continue + if token.type in {tokenize.INDENT, tokenize.DEDENT, tokenize.NL, tokenize.ENDMARKER}: + continue + if token.type == tokenize.NEWLINE: + statement_indent = None + continue + if token.type == tokenize.STRING: + handled.update(range(token.start[0], token.end[0])) + row = token.start[0] - 1 + if row not in handled: + indent = indents[row] + if stack: + # Inline nested brackets share their opening line's indentation. + indent = stack[-1] + 2 + closing = 0 + for following_index in range(index, len(tokens)): + following = tokens[following_index] + if following.start[0] != token.start[0] or following.type != tokenize.OP or following.string not in ")]}": + break + closing += 1 + if closing: + indent = stack[-closing] + elif statement_indent is not None: + indent = statement_indent + 2 + lines[row] = " " * indent + lines[row].lstrip(" \t") + indents[row] = indent + handled.add(row) + if statement_indent is None and token.type != tokenize.COMMENT: + statement_indent = indents[row] + if token.type == getattr(tokenize, "FSTRING_START", None): + literal_starts.append(token.start[0]) + if token.type == tokenize.OP: + if token.string in "([{": + stack.append(indents[row]) + elif token.string in ")]}": + stack.pop() + return "".join(lines) + +def join_python(source): + tokens = python_tokens(source) + literals = python_literal_lines(ast.parse(source)) + lines = source.splitlines(keepends=True) + offsets = [0] + for line in lines: + offsets.append(offsets[-1] + len(line)) + stack, edits = [], [] + for index, token in enumerate(tokens): + if token.type != tokenize.OP: + continue + if token.string in "([{": + stack.append(index) + elif token.string in ")]}": + start = stack.pop() + opening = tokens[start] + prior = tokens[start - 1] if start else None + # Only calls and signatures. Structured literals and multiline strings stay intact. + if opening.string != "(" or not prior or prior.type != tokenize.NAME or prior.string == "import": + continue + content = tokens[start + 1:index] + if any(row in literals for row in range(opening.start[0], token.end[0] - 1)): + continue + if any(item.type == tokenize.COMMENT or (item.type == tokenize.STRING and item.start[0] != item.end[0]) or item.string in {"[", "{", "lambda", "for"} for item in content): + continue + first = offsets[opening.start[0] - 1] + opening.start[1] + last = offsets[token.end[0] - 1] + token.end[1] + value = re.sub(r"[ \t]*\r?\n[ \t]*", " ", source[first:last]) + value = re.sub(r",[ \t]*\)$", ")", value) + if value != source[first:last]: + edits.append((first, last, value)) + return apply_edits(source, edits) + +def space_python(source): + tree = ast.parse(source) + compact, protected = set(), python_literal_lines(tree) + class_interiors = set() + units = [] + for token in python_tokens(source): + if token.type in {tokenize.STRING, tokenize.COMMENT}: + protected.update(range(token.start[0] - 1, token.end[0])) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + class_interiors.update(range(node.lineno, node.end_lineno)) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + compact.update(range(node.lineno, node.end_lineno)) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + first = min([node.lineno, *[decorator.lineno for decorator in node.decorator_list]]) - 1 + last = node.end_lineno - 1 + units.append((first, last)) + return space_lines(source, compact, units, set(), protected, class_interiors) + +def format_source(source, suffix, style=None): + if not source.strip(): + return source + if suffix == ".py": + import autopep8 + before = ast.dump(ast.parse(source)) + result = autopep8.fix_code(join_python(source), options={"indent_size": 2, "ignore": ["E501", "E3", "E4", "W50", "W6"], "aggressive": 0}) + result = space_python(indent_python(result)) + if ast.dump(ast.parse(result)) != before: + raise ValueError("formatter changed Python syntax") + return result + result = subprocess.run(["clang-format", "--style=" + json.dumps(style or clang_style)], input=join_cpp(source.encode()).decode(), text=True, capture_output=True, check=True) + return space_cpp(result.stdout) + +def alias_diagnostics(source, suffix, portability=False): + if suffix != ".py": + root = cpp_tree(source.encode()) + allowed = {"bool_t", "char_t", "uchar_t", "float32_t", "float64_t"} + for node in descendants(root): + if node.type in {"alias_declaration", "namespace_alias_definition", "type_definition"}: + name = node.child_by_field_name("name") + if portability and name and name.text.decode() in allowed and any(parent.type == "namespace_definition" and parent.child_by_field_name("name").text == b"std" for parent in ancestors(node.parent)): + continue + yield node.start_point.row + 1, "use the original type or namespace, not an alias" + return + tree = ast.parse(source) + modules = {alias.name for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names} + modules.update(name.split(".")[0] for name in list(modules)) + typing_names = {alias.name for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and node.module in {"typing", "typing_extensions"} for alias in node.names} + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)) and any(alias.asname for alias in node.names): + yield node.lineno, "use imports without renaming" + if isinstance(node, getattr(ast, "TypeAlias", ())): + yield node.lineno, "write the type annotation directly" + if isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None: + value = node.value + explicit = isinstance(node, ast.AnnAssign) and ast.unparse(node.annotation).split(".")[-1] == "TypeAlias" + type_expression = isinstance(value, ast.Subscript) and (ast.unparse(value.value) in typing_names | {"list", "dict", "tuple", "set", "frozenset", "type"} or ast.unparse(value.value).startswith(("typing.", "typing_extensions."))) + module_reference = isinstance(value, (ast.Name, ast.Attribute)) and ast.unparse(value) in modules + if explicit or type_expression or module_reference: + yield node.lineno, "use the original module or inline type annotation" diff --git a/modulus/formatting.py b/modulus/formatting.py new file mode 100644 index 0000000..d08c7d9 --- /dev/null +++ b/modulus/formatting.py @@ -0,0 +1,90 @@ +"""Shared batch/check and stdout-clean editor formatting transport.""" +import argparse +import fnmatch +import os +import pathlib +import subprocess +import sys + +import modulus.clangd +import modulus.format_layout + +suffixes = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".py"} +excluded_directories = {".git", ".venv", "venv", "build", "dist", "generated", "CMakeFiles", "__pycache__", ".cache", ".mypy_cache", ".pytest_cache", ".tox", "_deps", "vendor", "third_party", "third-party", "external", "node_modules"} + +def eligible(root, path, excludes): + relative = path.relative_to(root) + if path.suffix not in suffixes or any(part in excluded_directories for part in relative.parts): + return False + if any(fnmatch.fnmatch(relative.as_posix(), pattern) for pattern in excludes): + return False + return path.resolve().is_relative_to(root.resolve()) and not any(parent.is_symlink() for parent in [path, *path.parents] if parent != root) + +def source_files(root, requested, excludes): + result = subprocess.run(["git", "-C", str(root), "ls-files", "--cached", "--others", "--exclude-standard", "-z"], capture_output=True, check=True) + candidates = {root / os.fsdecode(name) for name in result.stdout.split(b"\0") if name} + selected = [pathlib.Path(name) for name in requested] + if any(path.is_absolute() or ".." in path.parts for path in selected): + raise ValueError("format paths must be relative to the repository") + return sorted(path for path in candidates if path.is_file() and eligible(root, path, excludes) and (not selected or any(path == root / name or path.is_relative_to(root / name) for name in selected))) + +def local_format(context, args): + root = context.repo_root + settings = context.tooling.get("format", {}) + excludes = settings.get("exclude", []) + style = context.tooling["editor"]["clang_format"] + if args.stdin_filename: + path = pathlib.Path(args.stdin_filename) + path = path if path.is_absolute() else root / path + if not path.is_relative_to(root) or not eligible(root, path, excludes): + raise ValueError("buffer is outside the project's formattable sources") + source = sys.stdin.read() + sys.stdout.write(modulus.format_layout.format_source(source, path.suffix, style)) + return 0 + changed = [] + diagnostics = [] + # Validate and format every input before changing any files. + for path in source_files(root, args.paths, excludes): + source = path.read_text(encoding="utf-8") + try: + result = modulus.format_layout.format_source(source, path.suffix, style) + except (ValueError, SyntaxError) as error: + raise ValueError(f"{path.relative_to(root)}: {error}") from error + if source != result: + changed.append((path, result)) + if args.check: + portability = context.package["name"] == "modulus" and path.relative_to(root).as_posix() == "include/modulus/types.hpp" + diagnostics.extend(f"{path.relative_to(root)}:{line}: {message}" for line, message in modulus.format_layout.alias_diagnostics(source, path.suffix, portability)) + if args.check: + for path, _ in changed: + print(f"{path.relative_to(root)}: formatting differs", file=sys.stderr) + for message in diagnostics: + print(message, file=sys.stderr) + return int(bool(changed or diagnostics)) + for path, result in changed: + path.write_text(result, encoding="utf-8") + print(f"Formatted {len(changed)} files.", file=sys.stderr) + return 0 + +def launch(context, argv): + parser = argparse.ArgumentParser(description="Format project C/C++ and Python with the shared Modulus policy.") + parser.add_argument("--check", action="store_true", help="check layout and prohibited aliases without writing files") + parser.add_argument("--stdin-filename", help="format stdin using this buffer's path; output only formatted text") + parser.add_argument("paths", nargs="*", help="optional repository-relative files or directories") + args = parser.parse_args(argv) + if args.stdin_filename and (args.check or args.paths): + parser.error("--stdin-filename cannot be combined with --check or paths") + try: + if context.docker_marker.exists() or os.environ.get("MODULUS_DIRECT") == "1": + return local_format(context, args) + image = modulus.clangd.current_image(context) + if image is None: + raise RuntimeError("missing or stale build image; run configure first") + root = context.repo_root + mode = "ro" if args.check or args.stdin_filename else "rw" + command = [*modulus.clangd.isolated_run(image), "--interactive", "--volume", f"{root}:{root}:{mode}", "--env", "PYTHONDONTWRITEBYTECODE=1", "--workdir", str(root), image, "python3", str(context.tools_dir / "format.py"), *argv] + return subprocess.run(command, check=False).returncode + except (OSError, ValueError, SyntaxError, ImportError, RuntimeError, subprocess.SubprocessError) as error: + detail = error.stderr if isinstance(error, subprocess.CalledProcessError) and error.stderr else str(error) + print(f"Modulus format: {detail}", file=sys.stderr) + return 1 diff --git a/schemas/modulus.schema.json b/schemas/modulus.schema.json index 65c6005..5432c87 100644 --- a/schemas/modulus.schema.json +++ b/schemas/modulus.schema.json @@ -134,6 +134,13 @@ "type": "object", "additionalProperties": false, "properties": { + "format": { + "type": "object", + "additionalProperties": false, + "properties": { + "exclude": { "type": "array", "items": { "type": "string" } } + } + }, "default_target": { "$ref": "#/$defs/cmake_name" }, diff --git a/src/config.cpp b/src/config.cpp index 61208dd..bfcf673 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -17,6 +17,7 @@ #include #include + namespace { auto config_error(std::string message) -> modulus::error { @@ -74,7 +75,7 @@ auto log_level_name(modulus::log_level level) -> std::string_view { } -auto modulus::parse_logging_config(boost::json::value const& value) -> std::expected { +auto modulus::parse_logging_config(boost::json::value const &value) -> std::expected { auto object = modulus::json_config::require_object(value, "logging config"); if (!object) { return modulus::fail(object.error()); @@ -99,17 +100,18 @@ auto modulus::parse_logging_config(boost::json::value const& value) -> std::expe return modulus::success(config); } -auto modulus::parse_error_config(boost::json::value const& value) -> std::expected { +auto modulus::parse_error_config(boost::json::value const &value) -> std::expected { auto object = modulus::json_config::require_object(value, "error config"); if (!object) { return modulus::fail(object.error()); } auto unknown = modulus::json_config::reject_unknown_fields(**object, { - "include_category", - "include_code", - "include_message", - "include_location", - }, "error config"); + "include_category", + "include_code", + "include_message", + "include_location", + }, + "error config"); if (!unknown) { return modulus::fail(unknown.error()); } @@ -137,15 +139,16 @@ auto modulus::parse_error_config(boost::json::value const& value) -> std::expect return modulus::success(config); } -auto modulus::parse_config(boost::json::value const& value) -> std::expected { +auto modulus::parse_config(boost::json::value const &value) -> std::expected { auto object = modulus::json_config::require_object(value, "config root"); if (!object) { return modulus::fail(object.error()); } auto unknown = modulus::json_config::reject_unknown_fields(**object, { - "logging", - "errors", - }, "config root"); + "logging", + "errors", + }, + "config root"); if (!unknown) { return modulus::fail(unknown.error()); } @@ -167,19 +170,19 @@ auto modulus::parse_config(boost::json::value const& value) -> std::expected std::expected { +auto modulus::load_config(std::string const &path) -> std::expected { auto value = modulus::json_config::load_value(path, { - .category = "modulus.config", - .invalid_argument_code = 1, - .not_found_code = 1, - }); + .category = "modulus.config", + .invalid_argument_code = 1, + .not_found_code = 1, + }); if (!value) { return modulus::fail(value.error()); } return modulus::parse_config(*value); } -auto modulus::to_json(modulus::logging_config const& config) -> boost::json::value { +auto modulus::to_json(modulus::logging_config const &config) -> boost::json::value { return boost::json::object{ { "level", @@ -188,7 +191,7 @@ auto modulus::to_json(modulus::logging_config const& config) -> boost::json::val }; } -auto modulus::to_json(modulus::error_config const& config) -> boost::json::value { +auto modulus::to_json(modulus::error_config const &config) -> boost::json::value { return boost::json::object{ { "include_category", @@ -209,7 +212,7 @@ auto modulus::to_json(modulus::error_config const& config) -> boost::json::value }; } -auto modulus::to_json(modulus::config const& config) -> boost::json::value { +auto modulus::to_json(modulus::config const &config) -> boost::json::value { return boost::json::object{ { "logging", @@ -222,12 +225,12 @@ auto modulus::to_json(modulus::config const& config) -> boost::json::value { }; } -auto modulus::configure_logger(modulus::logger& logger, modulus::logging_config const& config) -> void { +auto modulus::configure_logger(modulus::logger &logger, modulus::logging_config const &config) -> void { logger.set_level(config.level); modulus::log_debug("modulus logger configured"); } -auto modulus::configure(modulus::config const& config) -> void { +auto modulus::configure(modulus::config const &config) -> void { modulus::configure_logger(modulus::default_logger(), config.logging); modulus::set_default_error_config(config.errors); modulus::log_debug("modulus configuration applied"); diff --git a/src/error.cpp b/src/error.cpp index 02eb24d..4ebe457 100644 --- a/src/error.cpp +++ b/src/error.cpp @@ -6,21 +6,23 @@ #include #include + namespace { -auto config_mutex() -> std::mutex& { +auto config_mutex() -> std::mutex & { static auto mutex = std::mutex{}; return mutex; } -auto config_storage() -> modulus::error_config& { +auto config_storage() -> modulus::error_config & { static auto config = modulus::error_config{}; return config; } } -modulus::error::error(std::string category, std::int32_t code, std::string message, modulus::source_location location) : category_(std::move(category)), code_(code), message_(std::move(message)), location_(location) { +modulus::error::error(std::string category, std::int32_t code, std::string message, modulus::source_location location) + : category_(std::move(category)), code_(code), message_(std::move(message)), location_(location) { assert(!category_.empty()); assert(location_.file_name() != nullptr); assert(location_.function_name() != nullptr); @@ -41,12 +43,12 @@ auto modulus::error::message() const noexcept -> std::string_view { return message_; } -auto modulus::error::location() const noexcept -> const modulus::source_location& { +auto modulus::error::location() const noexcept -> const modulus::source_location & { assert(location_.file_name() != nullptr); return location_; } -auto modulus::error::describe(modulus::error_config const& config) const -> std::string { +auto modulus::error::describe(modulus::error_config const &config) const -> std::string { assert(!category_.empty()); assert(location_.file_name() != nullptr); auto out = std::ostringstream{}; diff --git a/src/json_config.cpp b/src/json_config.cpp index 8d52779..8e66a5e 100644 --- a/src/json_config.cpp +++ b/src/json_config.cpp @@ -17,6 +17,7 @@ #include #include + namespace { auto make_config_error(modulus::json_config::options config, std::int32_t code, std::string message) -> modulus::error { @@ -35,7 +36,7 @@ auto key_allowed(std::string_view key, std::initializer_list a return false; } -auto integer_value(boost::json::value const& value, std::string_view name, modulus::json_config::options config) -> std::expected { +auto integer_value(boost::json::value const &value, std::string_view name, modulus::json_config::options config) -> std::expected { if (!value.is_number() || value.is_double()) { return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " must be an integer")); } @@ -58,7 +59,7 @@ auto modulus::json_config::not_found(modulus::json_config::options config, std:: return make_config_error(config, config.not_found_code, std::move(message)); } -auto modulus::json_config::load_value(std::string const& path, modulus::json_config::options config) -> std::expected { +auto modulus::json_config::load_value(std::string const &path, modulus::json_config::options config) -> std::expected { if (path.empty()) { return std::unexpected(modulus::json_config::invalid(config, "config path must not be empty")); } @@ -76,7 +77,7 @@ auto modulus::json_config::load_value(std::string const& path, modulus::json_con return value; } -auto modulus::json_config::require_object(boost::json::value const& value, std::string_view context, modulus::json_config::options config) -> std::expected { +auto modulus::json_config::require_object(boost::json::value const &value, std::string_view context, modulus::json_config::options config) -> std::expected { assert(context.data() != nullptr); if (!value.is_object()) { return std::unexpected(modulus::json_config::invalid(config, std::string{context} + " must be an object")); @@ -84,9 +85,9 @@ auto modulus::json_config::require_object(boost::json::value const& value, std:: return &value.as_object(); } -auto modulus::json_config::reject_unknown_fields(boost::json::object const& object, std::initializer_list allowed, std::string_view context, modulus::json_config::options config) -> std::expected { +auto modulus::json_config::reject_unknown_fields(boost::json::object const &object, std::initializer_list allowed, std::string_view context, modulus::json_config::options config) -> std::expected { assert(context.data() != nullptr); - for (auto const& field : object) { + for (auto const &field : object) { auto key = std::string_view{ field.key().data(), field.key().size(), @@ -98,7 +99,7 @@ auto modulus::json_config::reject_unknown_fields(boost::json::object const& obje return {}; } -auto modulus::json_config::optional_bool_field(boost::json::object const& object, std::string_view name, std::bool_t fallback, modulus::json_config::options config) -> std::expected { +auto modulus::json_config::optional_bool_field(boost::json::object const &object, std::string_view name, std::bool_t fallback, modulus::json_config::options config) -> std::expected { assert(name.data() != nullptr); auto found = object.find(name); if (found == object.end()) { @@ -110,7 +111,7 @@ auto modulus::json_config::optional_bool_field(boost::json::object const& object return found->value().as_bool(); } -auto modulus::json_config::optional_string_field(boost::json::object const& object, std::string_view name, std::string fallback, modulus::json_config::options config) -> std::expected { +auto modulus::json_config::optional_string_field(boost::json::object const &object, std::string_view name, std::string fallback, modulus::json_config::options config) -> std::expected { assert(name.data() != nullptr); auto found = object.find(name); if (found == object.end()) { @@ -122,7 +123,7 @@ auto modulus::json_config::optional_string_field(boost::json::object const& obje return std::string{found->value().as_string()}; } -auto modulus::json_config::required_string_field(boost::json::object const& object, std::string_view name, std::string_view context, modulus::json_config::options config) -> std::expected { +auto modulus::json_config::required_string_field(boost::json::object const &object, std::string_view name, std::string_view context, modulus::json_config::options config) -> std::expected { assert(name.data() != nullptr); assert(context.data() != nullptr); auto found = object.find(name); @@ -136,7 +137,7 @@ auto modulus::json_config::required_string_field(boost::json::object const& obje return value; } -auto modulus::json_config::optional_uint64_field(boost::json::object const& object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected { +auto modulus::json_config::optional_uint64_field(boost::json::object const &object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected { assert(name.data() != nullptr); auto found = object.find(name); if (found == object.end()) { @@ -155,7 +156,7 @@ auto modulus::json_config::optional_uint64_field(boost::json::object const& obje return number; } -auto modulus::json_config::positive_uint64_field(boost::json::object const& object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected { +auto modulus::json_config::positive_uint64_field(boost::json::object const &object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected { auto parsed = modulus::json_config::optional_uint64_field(object, name, fallback, max_value, config); if (!parsed) { return parsed; @@ -166,7 +167,7 @@ auto modulus::json_config::positive_uint64_field(boost::json::object const& obje return parsed; } -auto modulus::json_config::optional_int32_field(boost::json::object const& object, std::string_view name, std::int32_t fallback, std::int32_t min_value, std::int32_t max_value, modulus::json_config::options config) -> std::expected { +auto modulus::json_config::optional_int32_field(boost::json::object const &object, std::string_view name, std::int32_t fallback, std::int32_t min_value, std::int32_t max_value, modulus::json_config::options config) -> std::expected { assert(name.data() != nullptr); auto found = object.find(name); if (found == object.end()) { @@ -182,7 +183,7 @@ auto modulus::json_config::optional_int32_field(boost::json::object const& objec return static_cast(*number); } -auto modulus::json_config::positive_milliseconds_field(boost::json::object const& object, std::string_view name, std::chrono::milliseconds fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected { +auto modulus::json_config::positive_milliseconds_field(boost::json::object const &object, std::string_view name, std::chrono::milliseconds fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected { auto parsed = modulus::json_config::positive_uint64_field(object, name, static_cast(fallback.count()), max_value, config); if (!parsed) { return std::unexpected(parsed.error()); diff --git a/src/log.cpp b/src/log.cpp index b2b6518..667dbff 100644 --- a/src/log.cpp +++ b/src/log.cpp @@ -7,6 +7,7 @@ #include #include + namespace { auto is_valid_log_level(modulus::log_level level) noexcept -> std::bool_t { @@ -19,13 +20,13 @@ auto should_log(modulus::log_level record_level, modulus::log_level configured_l return configured_level != modulus::log_level::OFF && static_cast(record_level) >= static_cast(configured_level); } -auto format_log_record(std::ostream& stream, const modulus::log_record& record) -> void { +auto format_log_record(std::ostream &stream, const modulus::log_record &record) -> void { assert(is_valid_log_level(record.level)); assert(record.location.file_name() != nullptr); stream << '[' << modulus::to_string(record.level) << "] " << record.message << " (" << record.location.file_name() << ':' << record.location.line() << ")\n"; } -auto stderr_sink(const modulus::log_record& record) -> void { +auto stderr_sink(const modulus::log_record &record) -> void { assert(is_valid_log_level(record.level)); assert(record.location.file_name() != nullptr); static auto mutex = std::mutex{}; @@ -38,18 +39,26 @@ auto stderr_sink(const modulus::log_record& record) -> void { auto modulus::to_string(modulus::log_level level) noexcept -> std::string_view { assert(is_valid_log_level(level)); switch (level) { - case modulus::log_level::TRACE: return "trace"; - case modulus::log_level::DEBUG: return "debug"; - case modulus::log_level::INFO: return "info"; - case modulus::log_level::WARN: return "warn"; - case modulus::log_level::ERROR: return "error"; - case modulus::log_level::CRITICAL: return "critical"; - case modulus::log_level::OFF: return "off"; + case modulus::log_level::TRACE: + return "trace"; + case modulus::log_level::DEBUG: + return "debug"; + case modulus::log_level::INFO: + return "info"; + case modulus::log_level::WARN: + return "warn"; + case modulus::log_level::ERROR: + return "error"; + case modulus::log_level::CRITICAL: + return "critical"; + case modulus::log_level::OFF: + return "off"; } return "unknown"; } -modulus::logger::logger() : sink_(stderr_sink) {} +modulus::logger::logger() + : sink_(stderr_sink) {} auto modulus::logger::set_level(modulus::log_level level) -> void { assert(is_valid_log_level(level)); @@ -69,7 +78,7 @@ auto modulus::logger::enabled(modulus::log_level level) const -> std::bool_t { return should_log(level, level_) && static_cast(sink_); } -auto modulus::logger::set_sink(std::function sink) -> void { +auto modulus::logger::set_sink(std::function sink) -> void { auto lock = std::lock_guard{mutex_}; sink_ = std::move(sink); } @@ -78,7 +87,7 @@ auto modulus::logger::write(modulus::log_record record) const -> void { assert(is_valid_log_level(record.level)); assert(record.location.file_name() != nullptr); assert(record.location.function_name() != nullptr); - auto sink = std::function{}; + auto sink = std::function{}; { auto lock = std::lock_guard{mutex_}; if (!should_log(record.level, level_) || !sink_) { @@ -102,7 +111,7 @@ auto modulus::logger::log(modulus::log_level level, std::string message, modulus }); } -auto modulus::default_logger() -> modulus::logger& { +auto modulus::default_logger() -> modulus::logger & { static auto instance = modulus::logger{}; return instance; } diff --git a/tests/nvim_format.lua b/tests/nvim_format.lua new file mode 100644 index 0000000..03a5e7e --- /dev/null +++ b/tests/nvim_format.lua @@ -0,0 +1,55 @@ +local root = assert(vim.env.MODULUS_FORMAT_REPO) +vim.opt.rtp:append(assert(vim.env.MODULUS_CONFORM_PATH)) +local conform = require("conform") +conform.setup({ formatters_by_ft = { cpp = { "clang_format" }, _ = { "original_fallback" } } }) +vim.lsp.config("clangd", { cmd = { "clangd" }, filetypes = { "c", "cpp" } }) +vim.lsp.enable = function() end +dofile(assert(vim.env.MODULUS_FORMAT_HELPER)).setup(root) +local function buffer(name, ft, lines) + local buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(buf) + vim.api.nvim_buf_set_name(buf, name) + vim.bo[buf].filetype = ft + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + return buf +end +local function content(buf) + return table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") +end +local function check() + local outside = buffer("/tmp/not-modulus.cpp", "cpp", { "int x;" }) + assert(vim.deep_equal(conform.list_formatters_for_buffer(outside), { "clang_format" })) + local python_outside = buffer("/tmp/not_modulus.py", "python", { "pass" }) + assert(vim.deep_equal(conform.list_formatters_for_buffer(python_outside), { "original_fallback" })) + local cpp = buffer(root .. "/tests/modulus-format-probe.cpp", "cpp", { "void example(){", "", "int a=1;", "", "a++;", "}" }) + assert(vim.deep_equal(conform.list_formatters_for_buffer(cpp), { "modulus" })) + local failure + conform.format({ bufnr = cpp, timeout_ms = 10000 }, function(err) failure = err end) + assert(not failure, vim.inspect(failure)) + assert(content(cpp) == "void example() {\n int a = 1;\n a++;\n}", content(cpp)) + local py = buffer(root .. "/tests/modulus_format_probe.py", "python", { "def example( x ): return x+1" }) + vim.g.autoformat = false + vim.api.nvim_exec_autocmds("BufWritePre", { group = "ModulusFormat", buffer = py }) + assert(content(py) == "def example( x ): return x+1") + vim.g.autoformat = true + vim.b[py].autoformat = false + vim.api.nvim_exec_autocmds("BufWritePre", { group = "ModulusFormat", buffer = py }) + assert(content(py) == "def example( x ): return x+1") + vim.b[py].autoformat = true + vim.api.nvim_exec_autocmds("BufWritePre", { group = "ModulusFormat", buffer = py }) + assert(content(py) == "def example(x): return x + 1", content(py)) + vim.api.nvim_buf_set_lines(py, 0, -1, false, { "patterns = (", ' ".git",', ' ".tmp",', ")" }) + vim.api.nvim_exec_autocmds("BufWritePre", { group = "ModulusFormat", buffer = py }) + assert(content(py) == 'patterns = (\n ".git",\n ".tmp",\n)', content(py)) + vim.api.nvim_buf_set_lines(py, 0, -1, false, { "def broken(" }) + conform.format({ bufnr = py, timeout_ms = 10000, quiet = true }, function(err) failure = err end) + assert(failure, "invalid Python unexpectedly succeeded") + assert(content(py) == "def broken(", "failed formatter replaced the buffer") +end +local ok, err = pcall(check) +if not ok then + print("MODULUS_FORMAT_FAILURE " .. tostring(err)) + vim.cmd("cquit 1") +end +print("MODULUS_FORMAT_OK") +vim.cmd("qa!") diff --git a/tests/test.cpp b/tests/test.cpp index 923b76b..4c8a0f1 100644 --- a/tests/test.cpp +++ b/tests/test.cpp @@ -15,11 +15,12 @@ #include + namespace { struct stderr_capture { std::ostringstream stream{}; - std::streambuf* previous{std::cerr.rdbuf(stream.rdbuf())}; + std::streambuf *previous{std::cerr.rdbuf(stream.rdbuf())}; ~stderr_capture(); }; @@ -71,7 +72,7 @@ BOOST_AUTO_TEST_CASE(modulus_logger_filters_and_reports_caller_location) { auto records = std::vector{}; auto logger = modulus::logger{}; logger.set_level(modulus::log_level::DEBUG); - logger.set_sink([&](const modulus::log_record& record) { + logger.set_sink([&](const modulus::log_record &record) { records.push_back(record); }); logger.log(modulus::log_level::TRACE, "hidden"); @@ -85,7 +86,7 @@ BOOST_AUTO_TEST_CASE(modulus_logger_serializes_custom_sink_writes) { auto records = std::vector{}; auto logger = modulus::logger{}; logger.set_level(modulus::log_level::INFO); - logger.set_sink([&](const modulus::log_record& record) { + logger.set_sink([&](const modulus::log_record &record) { records.push_back(record); }); auto threads = std::vector{}; @@ -96,12 +97,12 @@ BOOST_AUTO_TEST_CASE(modulus_logger_serializes_custom_sink_writes) { } }); } - for (auto& thread : threads) { + for (auto &thread : threads) { thread.join(); } BOOST_TEST(records.size() == std::size_t{200}); auto messages = std::set{}; - for (const auto& record : records) { + for (const auto &record : records) { messages.insert(record.message); } BOOST_TEST(messages.size() == std::size_t{200}); @@ -111,10 +112,10 @@ BOOST_AUTO_TEST_CASE(modulus_logger_tolerates_concurrent_configuration_changes) auto records = std::vector{}; auto logger = modulus::logger{}; logger.set_level(modulus::log_level::INFO); - logger.set_sink([&](const modulus::log_record& record) { + logger.set_sink([&](const modulus::log_record &record) { records.push_back(record); }); - auto sink = [&](const modulus::log_record& record) -> void { + auto sink = [&](const modulus::log_record &record) -> void { records.push_back(record); }; auto mutator = std::thread{[&]() -> void { @@ -132,7 +133,7 @@ BOOST_AUTO_TEST_CASE(modulus_logger_tolerates_concurrent_configuration_changes) } }); } - for (auto& thread : threads) { + for (auto &thread : threads) { thread.join(); } mutator.join(); @@ -159,7 +160,7 @@ BOOST_AUTO_TEST_CASE(modulus_default_logger_serializes_stderr_lines) { } }); } - for (auto& thread : threads) { + for (auto &thread : threads) { thread.join(); } auto captured = std::istringstream{capture.stream.str()}; diff --git a/tests/test_clangd.py b/tests/test_clangd.py index a65ca7a..2a0d52d 100644 --- a/tests/test_clangd.py +++ b/tests/test_clangd.py @@ -9,80 +9,75 @@ import tempfile import unittest from unittest.mock import MagicMock, patch -import modulus.clangd as clangd -import modulus.cli as cli -import modulus.config as config -import modulus.export as export +import modulus.clangd +import modulus.cli +import modulus.config +import modulus.export class clangd_test(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = pathlib.Path(temporary.name) - self.context = cli.build_context(self.root, config.normalize({"package": {"name": "sample"}})) + self.context = modulus.cli.build_context(self.root, modulus.config.normalize({"package": {"name": "sample"}})) + self.context.docker_marker = self.root / "not-in-container" self.context.dockerfile.parent.mkdir(parents=True) self.context.dockerfile.write_text("FROM sample\n") self.image = "sha256:" + "1" * 64 - def test_defaults_and_custom_settings(self): - self.assertEqual(clangd.settings(self.context)["executable"], "/usr/bin/clangd-19") - self.assertIn("/usr/bin/g++-12", clangd.settings(self.context)["query_drivers"]) + self.assertEqual(modulus.clangd.settings(self.context)["executable"], "/usr/bin/clangd-19") + self.assertIn("/usr/bin/g++-12", modulus.clangd.settings(self.context)["query_drivers"]) custom = {"executable": "/opt/bin/clangd", "arguments": [], "query_drivers": ["/opt/bin/g++"], "header_roots": ["/opt/sdk"]} - result = config.normalize({"package": {"name": "sample"}, "tooling": {"lsp": {"clangd": custom}}}) + result = modulus.config.normalize({"package": {"name": "sample"}, "tooling": {"lsp": {"clangd": custom}}}) self.assertEqual(result["tooling"]["lsp"]["clangd"], custom) - def test_config_rejects_unsafe_or_unknown_settings(self): for invalid in ([], {"unknown": True}, {"executable": "clangd"}, {"arguments": "--background-index"}, - {"query_drivers": ["/usr/bin/*"]}, {"header_roots": ["/"]}, - {"header_roots": ["/usr/../etc"]}, {"query_drivers": ["/bin/g++,/bin/gcc"]}, - {"arguments": ["--query-driver=*"]}, {"arguments": ["--path-mappings=/=/"]}): - with self.subTest(invalid=invalid), self.assertRaises(config.config_error): - config.normalize({"package": {"name": "sample"}, "tooling": {"lsp": {"clangd": invalid}}}) - + {"query_drivers": ["/usr/bin/*"]}, {"header_roots": ["/"]}, + {"header_roots": ["/usr/../etc"]}, {"query_drivers": ["/bin/g++,/bin/gcc"]}, + {"arguments": ["--query-driver=*"]}, {"arguments": ["--path-mappings=/=/"]}): + with self.subTest(invalid=invalid), self.assertRaises(modulus.config.config_error): + modulus.config.normalize({"package": {"name": "sample"}, "tooling": {"lsp": {"clangd": invalid}}}) def test_image_fingerprint_and_architecture(self): - image = {"Id": self.image, "Architecture": "amd64", "Config": {"Labels": {clangd.image_label: clangd.recipe_digest(self.context)}}} + image = {"Id": self.image, "Architecture": "amd64", "Config": {"Labels": {modulus.clangd.image_label: modulus.clangd.recipe_digest(self.context)}}} with patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0, json.dumps([image]))): - self.assertEqual(clangd.current_image(self.context), self.image) - self.assertIsNone(clangd.current_image(self.context, "arm64")) + self.assertEqual(modulus.clangd.current_image(self.context), self.image) + self.assertIsNone(modulus.clangd.current_image(self.context, "arm64")) self.context.dockerfile.write_text("FROM changed\n") - self.assertIsNone(clangd.current_image(self.context)) - + self.assertIsNone(modulus.clangd.current_image(self.context)) def test_cache_is_shared_by_image_and_header_settings_not_repo(self): - cache = clangd.cache_directory(self.context, self.image) - other = cli.build_context(self.root / "other", self.context.config) - self.assertEqual(cache, clangd.cache_directory(other, self.image)) + cache = modulus.clangd.cache_directory(self.context, self.image) + other = modulus.cli.build_context(self.root / "other", self.context.config) + self.assertEqual(cache, modulus.clangd.cache_directory(other, self.image)) self.context.lsp_settings["clangd"]["arguments"].append("--log=error") - self.assertEqual(cache, clangd.cache_directory(self.context, self.image)) + self.assertEqual(cache, modulus.clangd.cache_directory(self.context, self.image)) self.context.lsp_settings["clangd"]["header_roots"].append("/opt/sdk") - self.assertNotEqual(cache, clangd.cache_directory(self.context, self.image)) - self.assertNotEqual(cache, clangd.cache_directory(other, "sha256:" + "2" * 64)) - + self.assertNotEqual(cache, modulus.clangd.cache_directory(self.context, self.image)) + self.assertNotEqual(cache, modulus.clangd.cache_directory(other, "sha256:" + "2" * 64)) def test_stdio_launch_has_no_tty_socket_credentials_or_host_home(self): - command = clangd.launch_command(self.context, [], self.image, self.root / "cache", ["/usr/include"], "test-server") + command = modulus.clangd.launch_command(self.context, [], self.image, self.root / "cache", ["/usr/include"], "test-server") self.assertIn("--interactive", command) self.assertIn("--read-only", command) self.assertIn(f"{self.root}:{self.root}:ro", command) self.assertIn(f"{self.context.build_root}:{self.context.build_root}:rw", command) self.assertIn("--network", command) self.assertNotIn("-t", command) - for unwanted in ("--tty", "--volumes-from", "CONAN_TOKEN", "/var/run/docker.sock", str(pathlib.Path.home()) + ":"): + for unwanted in ("--tty", "--volumes-from", "CONAN_TOKEN", "/var/run/docker.sock"): self.assertNotIn(unwanted, " ".join(command)) + volumes = [command[index + 1] for index, argument in enumerate(command) if argument == "--volume"] + self.assertFalse(any(volume.startswith(str(pathlib.Path.home()) + ":") for volume in volumes)) self.assertIn("--query-driver=/usr/bin/gcc-12,/usr/bin/g++-12,/usr/bin/aarch64-linux-gnu-gcc,/usr/bin/aarch64-linux-gnu-g++", command) - def test_startup_failure_does_not_prepare_and_keeps_stdout_clean(self): output, errors = io.StringIO(), io.StringIO() - with patch.object(clangd, "current_image", return_value=None), patch.object(clangd, "prepare") as prepare, \ - patch.object(cli, "ensure_build_image") as build, contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): - self.assertEqual(clangd.launch(self.context, []), 1) + with patch.object(modulus.clangd, "current_image", return_value=None), patch.object(modulus.clangd, "prepare") as prepare, \ + patch.object(modulus.cli, "ensure_build_image") as build, contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): + self.assertEqual(modulus.clangd.launch(self.context, []), 1) prepare.assert_not_called() build.assert_not_called() self.assertEqual(output.getvalue(), "") self.assertIn("configure", errors.getvalue()) - def test_path_mapping_uses_client_to_server_direction_and_specificity(self): - self.assertEqual(clangd.path_mappings(pathlib.Path("/cache"), ["/usr/include", "/opt/sdk"]), - "--path-mappings=/cache/rootfs/usr/include=/usr/include,/cache/rootfs/opt/sdk=/opt/sdk") - + self.assertEqual(modulus.clangd.path_mappings(pathlib.Path("/cache"), ["/usr/include", "/opt/sdk"]), + "--path-mappings=/cache/rootfs/usr/include=/usr/include,/cache/rootfs/opt/sdk=/opt/sdk") def archive(self, name, kind=tarfile.REGTYPE): stream = io.BytesIO() with tarfile.open(fileobj=stream, mode="w") as archive: @@ -92,69 +87,63 @@ class clangd_test(unittest.TestCase): archive.addfile(member) stream.seek(0) return stream - def test_header_extraction_rejects_escape_and_links(self): for name, kind in (("../bad", tarfile.REGTYPE), ("/usr/include/bad", tarfile.REGTYPE), - ("usr/include/../bad", tarfile.REGTYPE), ("etc/passwd", tarfile.REGTYPE), - ("usr/include/link", tarfile.SYMTYPE)): + ("usr/include/../bad", tarfile.REGTYPE), ("etc/passwd", tarfile.REGTYPE), + ("usr/include/link", tarfile.SYMTYPE)): with self.subTest(name=name), self.assertRaises(ValueError): - clangd.extract_headers(self.archive(name, kind), self.root, ["/usr/include"]) - clangd.extract_headers(self.archive("usr/include/header.h"), self.root, ["/usr/include"]) + modulus.clangd.extract_headers(self.archive(name, kind), self.root, ["/usr/include"]) + modulus.clangd.extract_headers(self.archive("usr/include/header.h"), self.root, ["/usr/include"]) self.assertEqual((self.root / "usr/include/header.h").stat().st_mode & 0o777, 0o444) - def test_completed_cache_never_invokes_container_again(self): cache = self.root / "cache" (cache / "rootfs/usr/include").mkdir(parents=True) - (cache / "manifest.json").write_text(json.dumps({"version": clangd.cache_version, "roots": ["/usr/include"]})) - with patch.object(clangd, "cache_directory", return_value=cache), patch.object(clangd, "current_image", return_value=self.image), \ - patch.object(subprocess, "run") as run: - self.assertEqual(clangd.prepare(self.context), 0) + (cache / "manifest.json").write_text(json.dumps({"version": modulus.clangd.cache_version, "roots": ["/usr/include"]})) + with patch.object(modulus.clangd, "cache_directory", return_value=cache), patch.object(modulus.clangd, "current_image", return_value=self.image), \ + patch.object(subprocess, "run") as run: + self.assertEqual(modulus.clangd.prepare(self.context), 0) run.assert_not_called() - def test_failed_extraction_never_publishes_partial_cache(self): cache = self.root / "image/cache" process = MagicMock() process.__enter__.return_value = process process.stdout = self.archive("../escape") - with patch.object(clangd, "cache_directory", return_value=cache), patch.object(clangd, "current_image", return_value=self.image), \ - patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0, '["/usr/include"]')), \ - patch.object(subprocess, "Popen", return_value=process), contextlib.redirect_stderr(io.StringIO()): - self.assertEqual(clangd.prepare(self.context), 1) + with patch.object(modulus.clangd, "cache_directory", return_value=cache), patch.object(modulus.clangd, "current_image", return_value=self.image), \ + patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0, '["/usr/include"]')), \ + patch.object(subprocess, "Popen", return_value=process), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(modulus.clangd.prepare(self.context), 1) self.assertFalse(cache.exists()) self.assertEqual(list(cache.parent.glob(".headers-*")), []) - def test_cleanup_always_waits_for_child_even_if_docker_removal_times_out(self): process = MagicMock() process.wait.side_effect = [0, subprocess.TimeoutExpired("docker", 5), 0] with patch.object(subprocess, "Popen", return_value=process), \ - patch.object(subprocess, "run", side_effect=subprocess.TimeoutExpired("docker rm", 15)), \ - contextlib.redirect_stderr(io.StringIO()): - self.assertEqual(clangd.run_server(["docker"], "test-server"), 0) + patch.object(subprocess, "run", side_effect=subprocess.TimeoutExpired("docker rm", 15)), \ + contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(modulus.clangd.run_server(["docker"], "test-server"), 0) process.kill.assert_called_once() self.assertEqual(process.wait.call_count, 3) - def test_nvim_generation_is_stable_opt_out_and_respects_ownership(self): - export.sync_nvim_file(self.root, {}) + modulus.export.sync_nvim_file(self.root, {}) path = self.root / ".nvim.lua" before = path.stat().st_mtime_ns - export.sync_nvim_file(self.root, {}) + modulus.export.sync_nvim_file(self.root, {}) self.assertEqual(before, path.stat().st_mtime_ns) - export.sync_nvim_file(self.root, {"enabled": False}) + modulus.export.sync_nvim_file(self.root, {"enabled": False}) self.assertFalse(path.exists()) path.write_text("-- user settings\n") with contextlib.redirect_stderr(io.StringIO()): - export.sync_nvim_file(self.root, {}) - export.sync_nvim_file(self.root, {"enabled": False}) + modulus.export.sync_nvim_file(self.root, {}) + modulus.export.sync_nvim_file(self.root, {"enabled": False}) self.assertEqual(path.read_text(), "-- user settings\n") - def test_lsp_prepares_after_successful_database_refresh_only(self): - with patch.object(cli, "in_docker", return_value=False), patch.object(cli, "docker_run", return_value=0) as run, \ - patch.object(clangd, "prepare", return_value=0) as prepare: - self.assertEqual(cli.lsp(self.context, []), 0) + with patch.object(modulus.cli, "in_docker", return_value=False), patch.object(modulus.cli, "docker_run", return_value=0) as run, \ + patch.object(modulus.clangd, "prepare", return_value=0) as prepare: + self.assertEqual(modulus.cli.lsp(self.context, []), 0) prepare.assert_called_once_with(self.context) prepare.reset_mock() run.return_value = 1 - self.assertEqual(cli.lsp(self.context, []), 1) + self.assertEqual(modulus.cli.lsp(self.context, []), 1) prepare.assert_not_called() if __name__ == "__main__": diff --git a/tests/test_clangd_integration.py b/tests/test_clangd_integration.py index ba7c1db..6ab6b6e 100644 --- a/tests/test_clangd_integration.py +++ b/tests/test_clangd_integration.py @@ -21,7 +21,6 @@ class lsp_session: self.reader.start() self.next_id = 0 self.pending = [] - def read_messages(self): try: while True: @@ -38,12 +37,10 @@ class lsp_session: self.messages.put(json.loads(self.process.stdout.read(int(headers["content-length"])))) except Exception as error: self.messages.put(error) - def send(self, message): body = json.dumps({"jsonrpc": "2.0", **message}).encode() self.process.stdin.write(f"Content-Length: {len(body)}\r\n\r\n".encode() + body) self.process.stdin.flush() - def wait(self, predicate, timeout=90): deadline = time.monotonic() + timeout while True: @@ -58,7 +55,6 @@ class lsp_session: self.log.seek(0) raise AssertionError(f"{message}\n{self.log.read().decode(errors='replace')}") self.pending.append(message) - def request(self, method, params): self.next_id += 1 self.send({"id": self.next_id, "method": method, "params": params}) @@ -66,20 +62,17 @@ class lsp_session: if "error" in response: raise AssertionError(response["error"]) return response.get("result") - def initialize(self, root): result = self.request("initialize", {"processId": os.getpid(), "rootUri": root.as_uri(), "capabilities": {"textDocument": {"publishDiagnostics": {"versionSupport": True}}}}) self.send({"method": "initialized", "params": {}}) return result - def open(self, path, text): uri = path.as_uri() self.send({"method": "textDocument/didOpen", "params": {"textDocument": { "uri": uri, "languageId": "cpp", "version": 1, "text": text}}}) return self.wait(lambda message: message.get("method") == "textDocument/publishDiagnostics" - and message["params"]["uri"] == uri)["params"]["diagnostics"] - + and message["params"]["uri"] == uri)["params"]["diagnostics"] def close(self): if self.process.poll() is None: self.process.terminate() @@ -93,26 +86,24 @@ class lsp_session: class container_clangd_test(unittest.TestCase): def roots(self): return [pathlib.Path(root).resolve() for root in os.environ["MODULUS_LSP_TEST_ROOTS"].split(os.pathsep)] - @unittest.skipUnless(shutil.which("nvim"), "Neovim is not installed") def test_neovim_attachment_and_readonly_navigation(self): script = pathlib.Path(__file__).with_name("nvim_clangd.lua") for root in self.roots(): database = json.loads((root / "build/build_x86_debug/compile_commands.json").read_text()) source = next(pathlib.Path(entry["file"]) for entry in database - if pathlib.Path(entry["file"]).is_relative_to(root / "src")) + if pathlib.Path(entry["file"]).is_relative_to(root / "src")) sources = [(source, "cstdint")] if (root / "src/vamp_pico/main.cpp").exists(): sources.append((root / "src/vamp_pico/main.cpp", "pico/stdlib.h")) for source, header in sources: with self.subTest(repo=root.name, source=str(source)): env = {**os.environ, "MODULUS_LSP_REPO": str(root), "MODULUS_LSP_SOURCE": str(source.relative_to(root)), - "MODULUS_LSP_HEADER": header} + "MODULUS_LSP_HEADER": header} result = subprocess.run(["nvim", "--headless", "-i", "NONE", "-u", str(script)], cwd=root, - env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180) + env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180) self.assertEqual(result.returncode, 0, result.stdout) self.assertIn("MODULUS_NVIM_OK", result.stdout) - def test_native_and_pico_diagnostics_and_local_header_definitions(self): for root in self.roots(): with self.subTest(repo=root.name): @@ -122,7 +113,7 @@ class container_clangd_test(unittest.TestCase): self.assertIn("19.", initialized["serverInfo"]["version"]) database = json.loads((root / "build/build_x86_debug/compile_commands.json").read_text()) source = next(pathlib.Path(entry["file"]) for entry in database - if pathlib.Path(entry["file"]).is_relative_to(root / "src")) + if pathlib.Path(entry["file"]).is_relative_to(root / "src")) sources = [(source, "cstdint")] if (root / "src/vamp_pico").is_dir(): sources.extend((path, "pico/stdlib.h") for path in sorted((root / "src/vamp_pico").glob("*.cpp"))) @@ -145,7 +136,6 @@ class container_clangd_test(unittest.TestCase): self.assertEqual(session.process.wait(timeout=25), 0) finally: session.close() - def test_eof_and_termination_remove_container(self): def containers(): return set(subprocess.check_output(["docker", "ps", "-q", "--filter", "name=modulus-clangd-"], text=True).split()) diff --git a/tests/test_cli.py b/tests/test_cli.py index b194ac4..d7d1e73 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -59,7 +59,6 @@ class upload_packages_test(unittest.TestCase): }, } return modulus.cli.build_context(repo_root or pathlib.Path(os.getcwd()), config) - def test_cli_import_does_not_import_conan_recipe_module(self): repo = pathlib.Path(__file__).resolve().parents[1] command = [ @@ -69,7 +68,6 @@ class upload_packages_test(unittest.TestCase): ] completed = subprocess.run(command, cwd=repo, check=False) self.assertEqual(completed.returncode, 0) - def test_project_root_build_is_rejected(self): with tempfile.TemporaryDirectory() as directory: root = pathlib.Path(directory) @@ -77,7 +75,6 @@ class upload_packages_test(unittest.TestCase): context.config["tooling"]["targets"]["native"]["build_dir"] = "." with self.assertRaisesRegex(modulus.config.config_error, "project and source roots"): modulus.cli.build_context(root, context.config) - def test_configure_and_lsp_refresh_project_editor_settings(self): with tempfile.TemporaryDirectory() as directory: root = pathlib.Path(directory) @@ -97,7 +94,6 @@ class upload_packages_test(unittest.TestCase): before = {name: (root / name).stat().st_mtime_ns for name in modulus.export.editor_files.values()} self.assertEqual(modulus.cli.main(command), 0) self.assertEqual(before, {name: (root / name).stat().st_mtime_ns for name in before}) - def test_lsp_rejects_source_root_before_cache_cleanup(self): with tempfile.TemporaryDirectory() as directory: root = pathlib.Path(directory) @@ -112,7 +108,6 @@ class upload_packages_test(unittest.TestCase): modulus.cli.configure_lsp_database(context, {"source_dir": "subproject", "build_dir": build_dir}) clear.assert_not_called() self.assertEqual(cache.read_text(encoding="utf-8"), "keep") - @unittest.mock.patch.object(modulus.cli, "upload_all_packages", return_value=37) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) def test_failed_upload_does_not_force_by_default(self, configure, upload): @@ -122,7 +117,6 @@ class upload_packages_test(unittest.TestCase): self.assertEqual(status, 37) configure.assert_called_once_with(context, authenticate=True) upload.assert_called_once_with(context, "origin", force=False) - @unittest.mock.patch.object(modulus.cli, "upload_all_packages") @unittest.mock.patch.object(modulus.cli, "configure_conan_remote") def test_replace_requires_guard_env(self, configure, upload): @@ -131,7 +125,6 @@ class upload_packages_test(unittest.TestCase): self.assertEqual(status, 1) configure.assert_not_called() upload.assert_not_called() - @unittest.mock.patch.object(modulus.cli, "upload_all_packages", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) def test_replace_forces_upload_when_guarded(self, configure, upload): @@ -147,7 +140,6 @@ class upload_packages_test(unittest.TestCase): self.assertEqual(status, 0) configure.assert_called_once_with(context, authenticate=True) upload.assert_called_once_with(context, "origin", force=True) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_upload_defaults_to_latest_reference(self, run): with unittest.mock.patch.dict(os.environ, {}, clear=True): @@ -155,13 +147,11 @@ class upload_packages_test(unittest.TestCase): self.assertEqual(status, 0) self.assertIn("sample/latest:*", run.call_args.args[1]) self.assertNotIn("--force", run.call_args.args[1]) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_replace_upload_uses_conan_force(self, run): status = modulus.cli.upload_all_packages(self.context(), "origin", force=True) self.assertEqual(status, 0) self.assertIn("--force", run.call_args.args[1]) - def test_checked_in_ci_overwrites_latest_on_master(self): workflow = (pathlib.Path(__file__).resolve().parents[1] / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8") self.assertIn("gitea.ref == 'refs/heads/master'", workflow) @@ -178,7 +168,6 @@ class upload_packages_test(unittest.TestCase): self.assertNotIn("--volume \"${PWD}:${PWD}\"", workflow) self.assertNotIn("CONAN_PUBLIC_REMOTE_URL", workflow) self.assertNotIn("docs.py", workflow) - def test_removed_public_commands_are_rejected(self): with tempfile.TemporaryDirectory() as repo_dir: repo = pathlib.Path(repo_dir) @@ -194,7 +183,6 @@ class upload_packages_test(unittest.TestCase): modulus.cli.main(command) finally: os.chdir(previous_cwd) - def test_docker_passthrough_includes_replace_env(self): passthrough = modulus.cli.docker_passthrough_env(self.context()) self.assertIn("MODULUS_ALLOW_PACKAGE_REPLACE", passthrough) @@ -205,7 +193,6 @@ class upload_packages_test(unittest.TestCase): self.assertNotIn("MODULUS_EXPORT_RUNTIME", passthrough) self.assertNotIn("GITHUB_TOKEN", passthrough) self.assertNotIn("GITEA_TOKEN", passthrough) - def test_run_ports_default_to_empty_list(self): config = modulus.config.normalize({ "package": { @@ -213,7 +200,6 @@ class upload_packages_test(unittest.TestCase): }, }) self.assertEqual(config["tooling"]["run"]["ports"], []) - def test_run_accepts_docker_publish_mapping(self): config = modulus.config.normalize({ "package": { @@ -226,7 +212,6 @@ class upload_packages_test(unittest.TestCase): }, }) self.assertEqual(config["tooling"]["run"]["ports"], ["127.0.0.1:8000:8000/tcp"]) - def test_run_rejects_invalid_docker_publish_mapping(self): with self.assertRaisesRegex(modulus.config.config_error, "tooling.run.ports\\[0\\]"): modulus.config.normalize({ @@ -239,13 +224,11 @@ class upload_packages_test(unittest.TestCase): }, }, }) - @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) def test_run_is_disabled_by_default(self, docker): status = modulus.cli.product_run(self.context(), []) self.assertEqual(status, 1) docker.assert_not_called() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) def test_run_executes_configured_product_executable(self, docker, run): @@ -284,7 +267,6 @@ class upload_packages_test(unittest.TestCase): ) self.assertEqual(run.call_args.kwargs["env_overrides"], {"SAMPLE_WEB_ROOT": "build/web"}) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "docker_run", return_value=0) @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=False) def test_run_publishes_configured_ports_when_wrapped_by_docker(self, docker, docker_run): @@ -308,7 +290,6 @@ class upload_packages_test(unittest.TestCase): ], ) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_build_image_honors_docker_platform_env(self, run): context = self.context() @@ -334,10 +315,11 @@ class upload_packages_test(unittest.TestCase): "sample-build:dev", "--label", f"{modulus.cli.modulus.clangd.image_label}={modulus.cli.modulus.clangd.recipe_digest(context)}", + "--build-arg", + "MODULUS_TOOLS=tools", ".", ], ) - @unittest.mock.patch.object(modulus.cli, "ensure_build_image", return_value=0) @unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "in_container", return_value=False) @@ -356,11 +338,9 @@ class upload_packages_test(unittest.TestCase): self.assertIn("linux/arm64", run.call_args.args[1]) ensure_image.assert_called_once_with(context) in_container.assert_called_once() - def test_conan_remote_url_uses_config_by_default(self): with unittest.mock.patch.dict(os.environ, {}, clear=True): self.assertEqual(modulus.cli.conan_remote_url(self.context()), "https://example.test/conan") - def test_conan_remote_url_uses_config_in_ci(self): with unittest.mock.patch.dict( os.environ, @@ -370,7 +350,6 @@ class upload_packages_test(unittest.TestCase): clear=True, ): self.assertEqual(modulus.cli.conan_remote_url(self.context()), "https://example.test/conan") - def test_conan_credentials_only_uses_conan_environment(self): with unittest.mock.patch.dict( os.environ, @@ -384,7 +363,6 @@ class upload_packages_test(unittest.TestCase): user, token = modulus.cli.conan_credentials() self.assertEqual(user, "dev") self.assertEqual(token, "secret") - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_configure_remote_skips_login_without_conan_credentials(self, run): with unittest.mock.patch.dict(os.environ, {}, clear=True): @@ -402,14 +380,12 @@ class upload_packages_test(unittest.TestCase): "https://example.test/conan", ], ) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_configure_remote_rejects_partial_conan_credentials(self, run): with unittest.mock.patch.dict(os.environ, {"CONAN_USER": "dev"}, clear=True): status = modulus.cli.configure_conan_remote(self.context()) self.assertEqual(status, 1) run.assert_not_called() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_configure_remote_skips_login_until_authentication_is_requested(self, run): context = self.context() @@ -435,7 +411,6 @@ class upload_packages_test(unittest.TestCase): "https://example.test/conan", ], ) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_configure_remote_logs_in_with_hidden_token_when_authenticating(self, run): context = self.context() @@ -484,7 +459,6 @@ class upload_packages_test(unittest.TestCase): "", ], ) - def multi_target_context(self): config = { "package": { @@ -537,7 +511,6 @@ class upload_packages_test(unittest.TestCase): }, } return modulus.cli.build_context(pathlib.Path(os.getcwd()), config) - def test_selected_targets_default_to_default_target_only(self): context = self.multi_target_context() selected = context.selected_targets( @@ -545,7 +518,6 @@ class upload_packages_test(unittest.TestCase): False, ) self.assertEqual([target.name for target in selected], ["debug"]) - def test_selected_targets_all_returns_every_target(self): context = self.multi_target_context() selected = context.selected_targets( @@ -553,7 +525,6 @@ class upload_packages_test(unittest.TestCase): True, ) self.assertEqual([target.name for target in selected], ["debug", "release"]) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) def test_build_can_target_one_cmake_target(self, docker, run): @@ -586,7 +557,6 @@ class upload_packages_test(unittest.TestCase): ], ) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @@ -609,7 +579,6 @@ class upload_packages_test(unittest.TestCase): ], ) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @@ -635,7 +604,6 @@ class upload_packages_test(unittest.TestCase): ], ) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @@ -664,7 +632,6 @@ class upload_packages_test(unittest.TestCase): ], ) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=37) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @@ -678,7 +645,6 @@ class upload_packages_test(unittest.TestCase): configure_one.assert_called_once_with(context, context.default_target, update=False) run.assert_not_called() docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) @@ -697,7 +663,6 @@ class upload_packages_test(unittest.TestCase): configure_remote.assert_not_called() configure_one.assert_called_once_with(context, context.default_target, False, no_remote=True) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "upload_packages", return_value=0) @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @@ -724,7 +689,6 @@ class upload_packages_test(unittest.TestCase): create_package.assert_called_once_with(context, context.default_target, False, no_remote=True) upload.assert_not_called() docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "upload_packages", return_value=0) @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @@ -752,7 +716,6 @@ class upload_packages_test(unittest.TestCase): create_package.assert_called_once_with(context, context.default_target, False, no_remote=False) upload.assert_called_once_with(context, replace=False) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "upload_packages", return_value=1) @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @@ -778,7 +741,6 @@ class upload_packages_test(unittest.TestCase): create_package.assert_called_once_with(context, context.default_target, False, no_remote=False) upload.assert_called_once_with(context, replace=False) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) @unittest.mock.patch.object(modulus.cli, "run", return_value=0) @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) @@ -803,7 +765,6 @@ class upload_packages_test(unittest.TestCase): create_package.assert_not_called() run.assert_not_called() docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "clear_conan_deps") @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_configure_one_passes_no_remote_to_conan_install(self, run, clear): @@ -819,7 +780,6 @@ class upload_packages_test(unittest.TestCase): self.assertIn(f"-DCMAKE_TOOLCHAIN_FILE={context.default_target.build_dir / 'conan' / 'conan_toolchain.cmake'}", run.call_args_list[1].args[1]) self.assertIn("-DCMAKE_BUILD_TYPE=Release", run.call_args_list[1].args[1]) clear.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "clear_conan_deps") @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_configure_one_passes_target_cmake_definitions(self, run, clear): @@ -836,7 +796,6 @@ class upload_packages_test(unittest.TestCase): self.assertIn("-DSAMPLE_MODE=dev", cmake_command) self.assertIn("-DSAMPLE_LIMIT=2", cmake_command) clear.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "configure_lsp_database", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) @@ -864,7 +823,6 @@ class upload_packages_test(unittest.TestCase): configure_one.assert_called_once_with(context, context.default_target, False, no_remote=False) configure_lsp.assert_called_once_with(context, database) docker.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_configure_lsp_database_passes_cmake_definitions_and_unsets_env(self, run): with tempfile.TemporaryDirectory() as repo_dir: @@ -891,7 +849,6 @@ class upload_packages_test(unittest.TestCase): self.assertIn("-DVAMP_PICO_FIRMWARE_PROJECT=ON", command) self.assertEqual(run.call_args.kwargs["env_overrides"], {"PICO_SDK_PATH": "/opt/pico-sdk"}) self.assertEqual(run.call_args.kwargs["env_unset"], ["CC", "CXX"]) - @unittest.mock.patch.object(modulus.cli, "clear_conan_deps") @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_configure_one_allows_public_repos_to_build_missing_dependencies(self, run, clear): @@ -901,7 +858,6 @@ class upload_packages_test(unittest.TestCase): conan_command = run.call_args_list[0].args[1] self.assertIn("--build=missing", conan_command) clear.assert_called_once() - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_create_package_passes_no_remote_to_conan_create(self, run): with tempfile.TemporaryDirectory() as repo_dir: @@ -912,7 +868,6 @@ class upload_packages_test(unittest.TestCase): self.assertIn("--build=sample/*", run.call_args.args[1]) self.assertIn("--build=missing", run.call_args.args[1]) self.assertIn("--no-remote", run.call_args.args[1]) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_create_package_allows_public_repos_to_build_missing_dependencies(self, run): with tempfile.TemporaryDirectory() as repo_dir: @@ -921,7 +876,6 @@ class upload_packages_test(unittest.TestCase): self.assertEqual(status, 0) self.assertIn("--build=sample/*", run.call_args.args[1]) self.assertIn("--build=missing", run.call_args.args[1]) - def test_create_artifact_package_installs_archives_and_checksums_tree(self): with tempfile.TemporaryDirectory() as repo_dir: repo = pathlib.Path(repo_dir) @@ -937,14 +891,12 @@ class upload_packages_test(unittest.TestCase): "with_appliance_adapters": True, }, }) - def fake_run(run_context, command, **kwargs): if command[:2] == ["cmake", "--install"]: install_root = modulus.cli.package_artifact_build_root(context, context.default_target) / "stage" / "usr" (install_root / "bin").mkdir(parents=True) (install_root / "bin" / "sample").write_text("binary\n", encoding="utf-8") return 0 - with unittest.mock.patch.object(modulus.cli, "run", side_effect=fake_run) as run: status, artifacts = modulus.cli.create_artifact_package( context, @@ -971,7 +923,6 @@ class upload_packages_test(unittest.TestCase): cmake_command = run.call_args_list[1].args[1] self.assertIn("-DSAMPLE_WITH_WEB=ON", cmake_command) self.assertIn(f"-DCMAKE_INSTALL_PREFIX={repo / 'build' / 'build_common' / 'artifacts' / 'native' / 'stage' / 'usr'}", cmake_command) - @unittest.mock.patch.object(modulus.cli, "run", return_value=0) def test_generic_artifact_upload_uses_package_registry_credentials(self, run): context = self.context() diff --git a/tests/test_export.py b/tests/test_export.py index 959caa0..8276569 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -23,10 +23,8 @@ class modulus_export_test(unittest.TestCase): "name": "sample", }, } - def write_config(self, root, config): (root / "modulus.json").write_text(json.dumps(config), encoding="utf-8") - def write_package(self, package): for directory in ( "modulus", @@ -43,16 +41,19 @@ class modulus_export_test(unittest.TestCase): (package / "modulus" / "cli.py").write_text("", encoding="utf-8") (package / "modulus" / "config.py").write_text("", encoding="utf-8") (package / "modulus" / "export.py").write_text("", encoding="utf-8") + (package / "modulus" / "formatting.py").write_text("", encoding="utf-8") + (package / "modulus" / "format_layout.py").write_text("", encoding="utf-8") (package / "tools" / "configure.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") (package / "tools" / "lsp.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") (package / "tools" / "run.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + (package / "tools" / "format.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + (package / "tools" / "docker" / "format-requirements.txt").write_text("autopep8==2.3.2\n", encoding="utf-8") (package / "tools" / "docker" / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") (package / "include" / "modulus" / "types.hpp").write_text("", encoding="utf-8") (package / "tests" / "test.cpp").write_text("", encoding="utf-8") (package / "lib" / "cmake" / "modulus" / "modulus-config.cmake").write_text("", encoding="utf-8") (package / "share" / "modulus" / "duplicate.py").write_text("", encoding="utf-8") (package / "bin" / "modulus-tool").write_text("", encoding="utf-8") - def test_boost_requirement_options_enable_configured_components(self): config = modulus.config.normalize(self.config()) boost_options = modulus.config.boost_requirement_options(config) @@ -61,7 +62,6 @@ class modulus_export_test(unittest.TestCase): self.assertIs(boost_options["without_container"], False) self.assertIs(boost_options["without_charconv"], False) self.assertIs(boost_options["without_atomic"], True) - def test_default_targets_include_architecture_and_build_type_matrix(self): config = modulus.config.normalize(self.config()) targets = config["tooling"]["targets"] @@ -82,7 +82,6 @@ class modulus_export_test(unittest.TestCase): self.assertFalse(config["tooling"]["run"]["enabled"]) self.assertFalse(config["tooling"]["lsp"]["enabled"]) self.assertEqual(config["tooling"]["lsp"]["compile_databases"], []) - def test_product_artifact_and_run_config_are_validated(self): config = self.config() config["package"]["artifact"] = { @@ -111,11 +110,9 @@ class modulus_export_test(unittest.TestCase): self.assertEqual(normalized["package"]["artifact"]["install_prefix"], "usr") self.assertEqual(normalized["package"]["artifact"]["registry_url"], "https://git.thyme-labs.com/api/packages/khodo/generic") self.assertEqual(normalized["tooling"]["run"]["args"], []) - config["package"]["artifact"]["targets"] = ["missing"] with self.assertRaisesRegex(modulus.config.config_error, "unknown target"): modulus.config.normalize(config) - def test_target_cmake_definitions_are_validated_and_defaulted(self): config = self.config() config["tooling"] = { @@ -148,7 +145,6 @@ class modulus_export_test(unittest.TestCase): }, ) self.assertEqual(normalized["tooling"]["targets"]["release"]["cmake_definitions"], {}) - config["tooling"]["targets"]["native"]["cmake_definitions"] = { "SAMPLE_BAD": [ "invalid", @@ -156,7 +152,6 @@ class modulus_export_test(unittest.TestCase): } with self.assertRaisesRegex(modulus.config.config_error, "tooling.targets.native.cmake_definitions"): modulus.config.normalize(config) - def test_lsp_compile_databases_are_validated_and_defaulted(self): config = self.config() config["tooling"] = { @@ -183,11 +178,9 @@ class modulus_export_test(unittest.TestCase): self.assertEqual(database["generator"], "Ninja") self.assertEqual(database["env"], {}) self.assertEqual(database["unset_env"], ["CC", "CXX"]) - config["tooling"]["lsp"]["compile_databases"][0]["unset_env"] = ["bad-name"] with self.assertRaisesRegex(modulus.config.config_error, "tooling.lsp.compile_databases\\[0\\].unset_env"): modulus.config.normalize(config) - def test_checked_in_profile_overrides_exported_default_profile(self): with tempfile.TemporaryDirectory() as repo_dir: repo = pathlib.Path(repo_dir) @@ -197,14 +190,12 @@ class modulus_export_test(unittest.TestCase): profile.write_text("[settings]\n", encoding="utf-8") config = modulus.config.load_repo_config(repo) self.assertEqual(config["tooling"]["targets"]["x86_64_gcc12_release"]["profile"], "conan/profiles/x86_64_gcc12_release") - def test_docker_defaults_use_shared_modulus_marker(self): config = modulus.config.normalize(self.config()) self.assertEqual(config["tooling"]["docker"]["image"], "modulus-build:dev") self.assertEqual(config["tooling"]["docker"]["marker"], "/opt/modulus-build-image") self.assertNotIn("apt_architectures", config["tooling"]["docker"]) self.assertNotIn("apt_packages", config["tooling"]["docker"]) - def test_export_project_copies_runtime_and_exported_tools(self): with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as package_dir: repo = pathlib.Path(repo_dir) @@ -226,6 +217,10 @@ class modulus_export_test(unittest.TestCase): self.assertTrue((runtime / "modulus" / "cli.py").is_file()) self.assertTrue((runtime / "modulus" / "config.py").is_file()) self.assertTrue((runtime / "modulus" / "export.py").is_file()) + self.assertTrue((runtime / "modulus" / "formatting.py").is_file()) + self.assertTrue((runtime / "modulus" / "format_layout.py").is_file()) + self.assertTrue((repo / "build/build_tools/format.py").is_file()) + self.assertTrue((repo / "build/build_tools/docker/format-requirements.txt").is_file()) self.assertFalse((runtime / "CMakeLists.txt").exists()) self.assertFalse((runtime / "README.md").exists()) self.assertFalse((runtime / "conanfile.py").exists()) @@ -239,7 +234,6 @@ class modulus_export_test(unittest.TestCase): self.assertFalse((runtime / "lib").exists()) self.assertFalse((runtime / "share").exists()) self.assertFalse((runtime / "bin").exists()) - def test_editor_defaults_use_configured_target(self): config = modulus.config.normalize(self.config()) config["tooling"].pop("editor") @@ -250,7 +244,6 @@ class modulus_export_test(unittest.TestCase): self.assertEqual(editor["clangd"][0]["Diagnostics"]["ClangTidy"]["Remove"], ["*"]) self.assertIn("conanfile.py", editor["pyright"]["include"]) self.assertNotIn("package/conanfile.py", editor["pyright"]["include"]) - def test_editor_replacements_and_refresh_are_idempotent(self): config = self.config() editor = { @@ -277,7 +270,6 @@ class modulus_export_test(unittest.TestCase): before = {path.name: path.stat().st_mtime_ns for path in root.iterdir()} modulus.export.sync_editor_files(root, normalized) self.assertEqual(before, {path.name: path.stat().st_mtime_ns for path in root.iterdir()}) - def test_editor_config_rejects_invalid_shapes(self): for editor in (None, [], {"unknown": {}}, {"clangd": {}}, {"clangd": []}, {"clangd": ["bad"]}, {"pyright": []}, {"clang_format": None}, {"pyright": {"value": float("nan")}}): with self.subTest(editor=editor): @@ -285,7 +277,6 @@ class modulus_export_test(unittest.TestCase): config["tooling"] = {"editor": editor} with self.assertRaises(modulus.config.config_error): modulus.config.normalize(config) - def test_export_requires_packaged_tools(self): with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as package_dir: repo = pathlib.Path(repo_dir) @@ -295,7 +286,6 @@ class modulus_export_test(unittest.TestCase): (package / "modulus" / "cli.py").write_text("", encoding="utf-8") with self.assertRaisesRegex(RuntimeError, "missing tools directory"): modulus.export.sync_project(repo, package) - def test_normalize_rejects_invalid_cmake_values(self): config = self.config() config["package"]["name"] = "BadName" @@ -305,13 +295,11 @@ class modulus_export_test(unittest.TestCase): config["docs"] = {} with self.assertRaisesRegex(modulus.config.config_error, "unknown keys: docs"): modulus.config.normalize(config) - def test_unknown_top_level_config_keys_are_rejected(self): config = self.config() config["old_config"] = {} with self.assertRaisesRegex(modulus.config.config_error, "unknown keys: old_config"): modulus.config.normalize(config) - def test_removed_setup_generation_fields_are_rejected(self): config = self.config() config["schema_version"] = 1 diff --git a/tests/test_formatting.py b/tests/test_formatting.py new file mode 100644 index 0000000..eb0cde8 --- /dev/null +++ b/tests/test_formatting.py @@ -0,0 +1,165 @@ +import ast +import contextlib +import io +import json +import os +import pathlib +import subprocess +import tempfile +import sys +import unittest +from unittest.mock import patch + +import modulus.cli +import modulus.config +import modulus.export +import modulus.format_layout +import modulus.formatting + +class formatting_test(unittest.TestCase): + def formatted(self, source, suffix): + result = modulus.format_layout.format_source(source, suffix) + self.assertEqual(modulus.format_layout.format_source(result, suffix), result) + return result + def test_cpp_units_namespaces_and_include_groups(self): + source = '#include \n#include \n\n#include "local.hpp"\n\nnamespace sample {\nint one() {\n\nint x=1;\n\nx++;\nreturn x;\n}\nint two() {return 2;}\n}\n' + result = self.formatted(source, ".cpp") + self.assertTrue(result.startswith(source.split("namespace")[0])) + self.assertIn("namespace sample {\n\nint one()", result) + self.assertIn("int x = 1;\n x++;\n return x;", result) + self.assertIn("}\n\nint two()", result) + self.assertTrue(result.endswith("}\n\n}\n")) + def test_cpp_no_column_limit_and_simple_lists(self): + name = "argument_" + "a" * 180 + result = self.formatted(f"void function(\nint {name},\nint b) {{\n call(\n{name},\nb);\n}}\n", ".cpp") + self.assertIn(f"void function(int {name}, int b)", result) + self.assertIn(f"call({name}, b);", result) + def test_cpp_class_and_struct_members_have_no_blank_lines(self): + for kind in ("class", "struct"): + with self.subTest(kind=kind): + source = f'{kind} source_location {{\npublic:\n\n source_location() = default;\n\n source_location(source_location const&) = delete;\n\n int line() const;\n\n int value() const {{\n\n return line_;\n\n }}\n\n struct nested {{\n\n int value;\n\n }};\n\nprivate:\n\n int line_;\n\n}};\n\nstruct next {{ int value; }};\n' + result = self.formatted(source, ".hpp") + body = result.split(f"{kind} source_location {{\n", 1)[1].split("\n};", 1)[0] + self.assertNotIn("\n\n", body) + self.assertIn("};\n\nstruct next", result) + def test_cpp_class_keeps_literal_and_comment_blank_lines(self): + literal = 'R"raw(first\n\nsecond)raw"' + comment = '/* first\n\nsecond */' + source = f'struct example {{\n\n const char* text = {literal};\n\n {comment}\n\n int value;\n}};\n' + result = self.formatted(source, ".hpp") + self.assertIn(literal, result) + self.assertIn(comment, result) + self.assertNotIn("\n\n", result.replace(literal, "literal").replace(comment, "comment")) + def test_cpp_literals_macros_templates_and_lambdas(self): + literal = 'R"raw(first\n\n second\n)raw"' + source = f'template\nvoid test(T v) {{\n const char* s = {literal};\n\n visit(v, [](auto a) {{\n\nreturn a;\n}});\n}}\nBOOST_AUTO_TEST_CASE(example) {{\n\nCHECK(true);\n\n}}\n' + result = self.formatted(source, ".cpp") + self.assertIn(literal, result) + self.assertIn("BOOST_AUTO_TEST_CASE(example) {\n CHECK(true);\n}", result) + self.assertIn("[](auto a) {\n", result) + def test_python_ast_literals_decorators_and_nested_units(self): + source = 'import os\n\nimport pathlib\n\n@decorator\ndef outer(\n first,\n second,\n):\n text = """first\n\n second\n"""\n\n first += 1\n def nested():\n return 1\n return call(\n first,\n second,\n )\n' + result = self.formatted(source, ".py") + self.assertEqual(ast.dump(ast.parse(source)), ast.dump(ast.parse(result))) + self.assertIn('"""first\n\n second\n"""', result) + self.assertIn("@decorator\ndef outer(first, second):", result) + self.assertIn("first += 1\n\n def nested()", result) + self.assertIn("return 1\n\n return call(first, second)", result) + self.assertTrue(result.startswith("import os\n\nimport pathlib\n")) + def test_python_no_column_limit_and_structured_literals(self): + name = "a" * 250 + source = f"def test({name},\n other):\n return call({name},\nother)\n" + result = self.formatted(source, ".py") + self.assertIn(f"def test({name}, other):", result) + data = 'def test():\n return call({\n "a": 1,\n "b": 2,\n })\n' + self.assertIn('{\n', self.formatted(data, ".py")) + def test_python_classes_keep_members_together(self): + source = 'class Example:\n\n """first\n\nsecond"""\n\n field = 1\n\n @staticmethod\n def value():\n\n return 1\n\n class Nested:\n\n field = 2\n\n async def other(self):\n\n return 2\n\nclass Next:\n\n field = 3\n' + result = self.formatted(source, ".py") + self.assertEqual(ast.dump(ast.parse(source)), ast.dump(ast.parse(result))) + self.assertIn('"""first\n\nsecond"""', result) + self.assertNotIn("\n\n", result.split("\n\nclass Next", 1)[0].replace('"""first\n\nsecond"""', 'docstring')) + self.assertIn("return 2\n\nclass Next:\n field = 3", result) + def test_python_tuple_continuations_use_two_spaces(self): + source = 'runtime_ignore_patterns = (\n ".git",\n ".tmp",\n "__pycache__",\n)\n' + expected = 'runtime_ignore_patterns = (\n ".git",\n ".tmp",\n "__pycache__",\n)\n' + self.assertEqual(self.formatted(source, ".py"), expected) + def test_python_nested_collections_use_two_spaces_per_opening_line(self): + source = 'def example():\n result = build({\n "items": [\n (\n 1,\n 2,\n ),\n ],\n "names": {\n "a",\n "b",\n },\n })\n return result\n' + expected = 'def example():\n result = build({\n "items": [\n (\n 1,\n 2,\n ),\n ],\n "names": {\n "a",\n "b",\n },\n })\n return result\n' + self.assertEqual(self.formatted(source, ".py"), expected) + self.assertEqual(self.formatted("values = [[\n 1,\n 2,\n]]\n", ".py"), "values = [[\n 1,\n 2,\n]]\n") + def test_python_continued_imports_headers_and_comments(self): + source = 'from somewhere import (\n first,\n second,\n)\n\n@decorator(\n first, # comment\n second,\n)\ndef example(\n first, # comment\n second,\n):\n if (\n first # condition\n and second\n ):\n return first\n' + expected = 'from somewhere import (\n first,\n second,\n)\n\n@decorator(\n first, # comment\n second,\n)\ndef example(\n first, # comment\n second,\n):\n if (\n first # condition\n and second\n ):\n return first\n' + self.assertEqual(self.formatted(source, ".py"), expected) + def test_python_explicit_continuations_use_two_spaces(self): + source = 'def example():\n with first() as one, \\\n second() as two:\n return one + \\\n two\n' + expected = 'def example():\n with first() as one, \\\n second() as two:\n return one + \\\n two\n' + self.assertEqual(self.formatted(source, ".py"), expected) + def test_python_continuations_preserve_multiline_literal_contents(self): + source = 'def example():\n return [\n # first value\n f"{call({1: 2})}",\n """first\n second\n\n third""",\n f"""start {name}\n {value}\n\n end""",\n "next",\n ]\n' + expected = 'def example():\n return [\n # first value\n f"{call({1: 2})}",\n """first\n second\n\n third""",\n f"""start {name}\n {value}\n\n end""",\n "next",\n ]\n' + self.assertEqual(self.formatted(source, ".py"), expected) + def test_python_adjacent_literals_are_continuations_not_literal_contents(self): + source = 'def example():\n text = (\n "first"\n "second"\n f"{value}"\n f"{other}"\n )\n return text\n' + expected = 'def example():\n text = (\n "first"\n "second"\n f"{value}"\n f"{other}"\n )\n return text\n' + self.assertEqual(self.formatted(source, ".py"), expected) + def test_aliases_and_portability_exception(self): + cpp = "namespace std { using bool_t = decltype(true); }\nusing integer = int;\nnamespace fs = std::filesystem;\ntypedef int count;\n" + self.assertEqual(len(list(modulus.format_layout.alias_diagnostics(cpp, ".hpp"))), 4) + self.assertEqual(len(list(modulus.format_layout.alias_diagnostics(cpp, ".hpp", True))), 3) + python = "import pathlib as p\nfrom typing import Literal\nRole = Literal['a', 'b']\n" + self.assertEqual(len(list(modulus.format_layout.alias_diagnostics(python, ".py"))), 2) + def test_stdin_failure_has_no_output_and_never_builds_image(self): + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + context = modulus.cli.build_context(root, modulus.config.normalize({"package": {"name": "sample"}})) + context.docker_marker = root / "not-in-container" + output, errors = io.StringIO(), io.StringIO() + with patch.dict(os.environ, {"MODULUS_DIRECT": "1"}), patch("sys.stdin", io.StringIO("def broken(")), contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): + self.assertEqual(modulus.formatting.launch(context, ["--stdin-filename", "test.py"]), 1) + self.assertEqual(output.getvalue(), "") + with patch.dict(os.environ, {"MODULUS_DIRECT": "0"}), patch.object(modulus.clangd, "current_image", return_value=None), patch.object(modulus.cli, "ensure_build_image") as build, contextlib.redirect_stderr(errors): + self.assertEqual(modulus.formatting.launch(context, ["--check"]), 1) + build.assert_not_called() + def test_discovery_excludes_generated_and_symlinks(self): + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + subprocess.run(["git", "init", "-q", str(root)], check=True) + for name in ["keep.cpp", "skip.cpp", "build/generated.cpp", "vendor/library.cpp"]: + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + (root / "link.cpp").symlink_to(root / "keep.cpp") + self.assertEqual(modulus.formatting.source_files(root, [], ["skip.cpp"]), [root / "keep.cpp"]) + def test_nested_lists_join_in_one_pass_without_changing_string_spaces(self): + cpp = 'void example() { outer(\n inner(\n 1,\n 2),\n "a b"); }\n' + self.assertIn('outer(inner(1, 2), "a b");', self.formatted(cpp, ".cpp")) + python = 'def example():\n return outer(\n inner(\n 1,\n 2),\n "a b")\n' + self.assertIn('outer(inner(1, 2), "a b")', self.formatted(python, ".py")) + def test_multiline_fstrings_preserve_blank_lines_on_new_python_tokenizers(self): + source = 'def example(name):\n return f"""first {name}\n\nsecond\n\nthird"""\n' + self.assertEqual(self.formatted(source, ".py"), source) + def test_alias_check_distinguishes_module_aliases_from_saved_callbacks(self): + source = 'import modulus.cli\nsaved = modulus.cli.docker_run\nconfig = modulus.cli\nItems = list[int]\n' + self.assertEqual([line for line, _ in modulus.format_layout.alias_diagnostics(source, ".py")], [3, 4]) + def test_exported_stdin_formatter_needs_no_compile_database(self): + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + (root / "modulus.json").write_text(json.dumps({"package": {"name": "sample"}})) + modulus.export.sync_project(root, pathlib.Path(__file__).resolve().parent.parent) + result = subprocess.run([sys.executable, str(root / "build/build_tools/format.py"), "--stdin-filename", "sample.py"], cwd=root, env=dict(os.environ, MODULUS_DIRECT="1", PYTHONDONTWRITEBYTECODE="1"), input="def sample():\n values = (\n 1,\n 2,\n )\n return values\n", text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "def sample():\n values = (\n 1,\n 2,\n )\n return values\n") + self.assertEqual(result.stderr, "") + @unittest.skipUnless(os.environ.get("MODULUS_FORMAT_REPO") and os.environ.get("MODULUS_CONFORM_PATH"), "set MODULUS_FORMAT_REPO and MODULUS_CONFORM_PATH for the Neovim integration test") + def test_neovim_container_formatting(self): + root = pathlib.Path(__file__).resolve().parent.parent + env = dict(os.environ, MODULUS_FORMAT_HELPER=str(root / "tools/nvim/modulus.lua")) + result = subprocess.run(["nvim", "--headless", "-u", "NONE", "-l", str(root / "tests/nvim_format.lua")], env=env, text=True, capture_output=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("MODULUS_FORMAT_OK", result.stdout + result.stderr) + +if __name__ == "__main__": + unittest.main() diff --git a/tools/clangd_headers.py b/tools/clangd_headers.py index f0aff9c..a20e1b0 100755 --- a/tools/clangd_headers.py +++ b/tools/clangd_headers.py @@ -11,7 +11,7 @@ def header_roots(config): for driver in config["query_drivers"]: for language in ("c", "c++"): result = subprocess.run([driver, "-E", "-v", "-x", language, "-"], input="", - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) listing = False for line in result.stderr.splitlines(): if line == "#include <...> search starts here:": diff --git a/tools/docker/Dockerfile b/tools/docker/Dockerfile index 7dae6ee..7e0aef4 100755 --- a/tools/docker/Dockerfile +++ b/tools/docker/Dockerfile @@ -44,6 +44,11 @@ ENV PATH=/opt/modulus-python/bin:${PATH} RUN python -m pip install --no-cache-dir \ conan==2.26.2 +# The path is shared by source builds and exported consumer tooling. +ARG MODULUS_TOOLS=tools +COPY ${MODULUS_TOOLS}/docker/format-requirements.txt /tmp/modulus-format-requirements.txt +RUN python -m pip install --no-cache-dir -r /tmp/modulus-format-requirements.txt + WORKDIR /workspace RUN mkdir -p /opt && touch /opt/modulus-build-image diff --git a/tools/docker/format-requirements.txt b/tools/docker/format-requirements.txt new file mode 100644 index 0000000..b035bc0 --- /dev/null +++ b/tools/docker/format-requirements.txt @@ -0,0 +1,5 @@ +clang-format==19.1.7 +autopep8==2.3.2 +pycodestyle==2.14.0 +tree-sitter==0.25.2 +tree-sitter-cpp==0.23.4 diff --git a/tools/format.py b/tools/format.py new file mode 100755 index 0000000..8cb303e --- /dev/null +++ b/tools/format.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +import modulus_tool + +if __name__ == "__main__": + modulus_tool.run("format") diff --git a/tools/nvim/modulus.lua b/tools/nvim/modulus.lua index 9eb54ad..f469f1d 100644 --- a/tools/nvim/modulus.lua +++ b/tools/nvim/modulus.lua @@ -4,6 +4,71 @@ local function within(path, root) return path == root or path:sub(1, #root + 1) == root .. "/" end +local function setup_formatting(state) + if state.formatting then + return + end + local ok, conform = pcall(require, "conform") + if not ok then + vim.notify("Modulus formatting requires conform.nvim", vim.log.levels.WARN) + return + end + state.formatting = true + local function project(buf) + local ft = vim.bo[buf].filetype + if ft ~= "c" and ft ~= "cpp" and ft ~= "python" then + return nil + end + return state.project(vim.api.nvim_buf_get_name(buf)) + end + local fallback = conform.formatters_by_ft["_"] + for _, ft in ipairs({ "c", "cpp", "python", "*" }) do + local original = conform.formatters_by_ft[ft] + if ft ~= "*" and (not original or (type(original) == "table" and vim.tbl_isempty(original))) then + original = fallback + end + conform.formatters_by_ft[ft] = function(buf) + if project(buf) then + if ft == "*" then + return {} + end + return { "modulus", lsp_format = "never", timeout_ms = 10000 } + end + if type(original) == "function" then + return original(buf) or {} + end + return original or {} + end + end + conform.formatters.modulus = { + command = "python3", + env = { MODULUS_DIRECT = "0" }, + args = function(_, context) + local root = assert(project(context.buf)) + local args = vim.uv.fs_stat(root .. "/modulus/cli.py") + and { root .. "/tools/format.py" } + or { root .. "/modulus.py", "format" } + vim.list_extend(args, { "--stdin-filename", context.filename }) + return args + end, + cwd = function(_, context) return project(context.buf) end, + stdin = true, + } + -- LazyVim already owns save/manual formatting and its global/buffer toggles. + local has_save, save_hooks = pcall(vim.api.nvim_get_autocmds, { event = "BufWritePre", group = "Conform" }) + if not rawget(_G, "LazyVim") and (not has_save or #save_hooks == 0) then + vim.api.nvim_create_autocmd("BufWritePre", { + group = vim.api.nvim_create_augroup("ModulusFormat", { clear = true }), + callback = function(event) + if project(event.buf) and vim.g.autoformat ~= false and vim.b[event.buf].autoformat ~= false + and not vim.g.disable_autoformat and not vim.b[event.buf].disable_autoformat then + conform.format({ bufnr = event.buf, lsp_format = "never", timeout_ms = 10000 }) + end + end, + }) + end +end + function M.setup(root) if vim.fn.has("nvim-0.11") ~= 1 then vim.notify("Modulus container clangd requires Neovim 0.11+", vim.log.levels.ERROR) @@ -13,7 +78,7 @@ function M.setup(root) -- Finish LazyVim's server setup before changing only the command and root routing. local has_lazy, lazy = pcall(require, "lazy") if has_lazy then - lazy.load({ plugins = { "nvim-lspconfig" } }) + lazy.load({ plugins = { "nvim-lspconfig", "conform.nvim" } }) end local state = rawget(_G, "modulus_clangd") if not state then @@ -85,6 +150,7 @@ function M.setup(root) command = { "python3", root .. "/modulus.py", "clangd" } end state.roots[root] = command + setup_formatting(state) for _, client in ipairs(vim.lsp.get_clients({ name = "clangd" })) do if not client.config._modulus_container and client.config.root_dir and within(client.config.root_dir, root) then client:stop()